diff --git a/src/cli/App.tsx b/src/cli/App.tsx
index aca9ea3..11b9312 100644
--- a/src/cli/App.tsx
+++ b/src/cli/App.tsx
@@ -205,7 +205,10 @@ import {
} from "./helpers/queuedMessageParts";
import { safeJsonParseOr } from "./helpers/safeJsonParse";
import { getDeviceType, getLocalTime } from "./helpers/sessionContext";
-import { resolveStatusLineConfig } from "./helpers/statusLineConfig";
+import {
+ resolvePromptChar,
+ resolveStatusLineConfig,
+} from "./helpers/statusLineConfig";
import { formatStatusLineHelp } from "./helpers/statusLineHelp";
import { buildStatusLinePayload } from "./helpers/statusLinePayload";
import { executeStatusLineCommand } from "./helpers/statusLineRuntime";
@@ -5994,6 +5997,8 @@ export default function App({
lines.push(
`Effective: ${effective ? `command="${effective.command}" refreshInterval=${effective.refreshIntervalMs ?? "off"} timeout=${effective.timeout}ms debounce=${effective.debounceMs}ms padding=${effective.padding}` : "(inactive)"}`,
);
+ const effectivePrompt = resolvePromptChar(wd);
+ lines.push(`Prompt: "${effectivePrompt}"`);
cmd.finish(lines.join("\n"), true);
} else if (sub === "set") {
if (!rest) {
@@ -10661,7 +10666,7 @@ Plan file path: ${planFilePath}`;
{item.kind === "welcome" ? (
) : item.kind === "user" ? (
-
+
) : item.kind === "reasoning" ? (
) : item.kind === "assistant" ? (
@@ -10794,7 +10799,7 @@ Plan file path: ${planFilePath}`;
showPreview={showApprovalPreview}
/>
) : ln.kind === "user" ? (
-
+
) : ln.kind === "reasoning" ? (
) : ln.kind === "assistant" ? (
@@ -10980,6 +10985,7 @@ Plan file path: ${planFilePath}`;
statusLineText={statusLine.text || undefined}
statusLineRight={statusLine.rightText || undefined}
statusLinePadding={statusLine.padding || 0}
+ statusLinePrompt={statusLine.prompt}
/>
diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx
index 80c6086..29e34b1 100644
--- a/src/cli/components/InputRich.tsx
+++ b/src/cli/components/InputRich.tsx
@@ -16,6 +16,7 @@ import {
useRef,
useState,
} from "react";
+import stringWidth from "string-width";
import { LETTA_CLOUD_API_URL } from "../../auth/oauth";
import {
ELAPSED_DISPLAY_THRESHOLD_MS,
@@ -538,6 +539,7 @@ export function Input({
statusLineText,
statusLineRight,
statusLinePadding = 0,
+ statusLinePrompt,
}: {
visible?: boolean;
streaming: boolean;
@@ -576,6 +578,7 @@ export function Input({
statusLineText?: string;
statusLineRight?: string;
statusLinePadding?: number;
+ statusLinePrompt?: string;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
@@ -592,7 +595,13 @@ export function Input({
// Terminal width is sourced from App.tsx to avoid duplicate resize subscriptions.
const columns = terminalWidth;
- const contentWidth = Math.max(0, columns - 2);
+
+ // Bash mode state (declared early so prompt width can feed into contentWidth)
+ const [isBashMode, setIsBashMode] = useState(false);
+
+ const promptChar = isBashMode ? "!" : statusLinePrompt || ">";
+ const promptVisualWidth = stringWidth(promptChar) + 1; // +1 for trailing space
+ const contentWidth = Math.max(0, columns - promptVisualWidth);
const interactionEnabled = visible && inputEnabled;
const reserveInputSpace = !collapseInputWhenDisabled;
@@ -668,9 +677,6 @@ export function Input({
// Track preferred column for vertical navigation (sticky column behavior)
const [preferredColumn, setPreferredColumn] = useState(null);
- // Bash mode state
- const [isBashMode, setIsBashMode] = useState(false);
-
// Restore input from error (only if current value is empty)
useEffect(() => {
if (restoredInput && value === "") {
@@ -1247,11 +1253,11 @@ export function Input({
{/* Two-column layout for input, matching message components */}
-
+
- {isBashMode ? "!" : ">"}
+ {promptChar}
@@ -1356,6 +1362,8 @@ export function Input({
statusLineText,
statusLineRight,
statusLinePadding,
+ promptChar,
+ promptVisualWidth,
]);
// If not visible, render nothing but keep component mounted to preserve state
diff --git a/src/cli/components/UserMessage.tsx b/src/cli/components/UserMessage.tsx
index 9c9101a..c722874 100644
--- a/src/cli/components/UserMessage.tsx
+++ b/src/cli/components/UserMessage.tsx
@@ -7,6 +7,8 @@ type UserLine = {
text: string;
};
-export const UserMessage = memo(({ line }: { line: UserLine }) => {
- return {`> ${line.text}`};
-});
+export const UserMessage = memo(
+ ({ line, prompt }: { line: UserLine; prompt?: string }) => {
+ return {`${prompt || ">"} ${line.text}`};
+ },
+);
diff --git a/src/cli/components/UserMessageRich.tsx b/src/cli/components/UserMessageRich.tsx
index f7d2bac..87f8355 100644
--- a/src/cli/components/UserMessageRich.tsx
+++ b/src/cli/components/UserMessageRich.tsx
@@ -112,7 +112,8 @@ export function splitSystemReminderBlocks(
}
/**
- * Render a block of text with "> " prefix (first line) and " " continuation.
+ * Render a block of text with a prompt prefix (first line) and matching-width
+ * continuation spaces on subsequent lines.
* If highlighted, applies background and foreground colors. Otherwise plain text.
*/
function renderBlock(
@@ -121,6 +122,8 @@ function renderBlock(
columns: number,
highlighted: boolean,
colorAnsi: string, // combined bg + fg ANSI codes
+ promptPrefix: string,
+ continuationPrefix: string,
): string[] {
const inputLines = text.split("\n");
const outputLines: string[] = [];
@@ -141,13 +144,20 @@ function renderBlock(
const isSingleLine = outputLines.length === 1;
return outputLines.map((ol, i) => {
- const prefix = i === 0 ? "> " : " ";
- const content = prefix + ol;
+ const prefix = i === 0 ? promptPrefix : continuationPrefix;
if (!highlighted) {
- return content;
+ return prefix + ol;
}
+ // Re-apply colorAnsi after the prompt character on the first line because
+ // the prompt string may contain an ANSI reset (\x1b[0m) that clears
+ // the background highlight. Insert before the trailing space so it's
+ // also highlighted.
+ const content =
+ i === 0
+ ? `${promptPrefix.slice(0, -1)}${colorAnsi} ${ol}`
+ : `${prefix}${ol}`;
const visWidth = stringWidth(content);
if (isSingleLine) {
return `${colorAnsi}${content}${" ".repeat(COMPACT_PAD)}\x1b[0m`;
@@ -161,48 +171,57 @@ function renderBlock(
* UserMessageRich - Rich formatting for user messages with background highlight
*
* Renders user messages as pre-formatted text with ANSI background codes:
- * - "> " prompt prefix on first line, " " continuation on subsequent lines
+ * - Custom prompt prefix on first line, matching-width spaces on subsequent lines
* - Single-line messages: compact highlight (content + small padding)
* - Multi-line messages: full-width highlight box extending to terminal edge
- * - Word wrapping respects the 2-char prefix width
+ * - Word wrapping respects the prompt prefix width
* - System-reminder parts are shown plain (no highlight), user parts highlighted
*/
-export const UserMessage = memo(({ line }: { line: UserLine }) => {
- const columns = useTerminalWidth();
- const contentWidth = Math.max(1, columns - 2);
- const cleanedText = extractTaskNotificationsForDisplay(line.text).cleanedText;
- const displayText = cleanedText.trim();
- if (!displayText) {
- return null;
- }
-
- // Build combined ANSI code for background + optional foreground
- const { background, text: textColor } = colors.userMessage;
- const bgAnsi = hexToBgAnsi(background);
- const fgAnsi = textColor ? hexToFgAnsi(textColor) : "";
- const colorAnsi = bgAnsi + fgAnsi;
-
- // Split into system-reminder blocks and user content blocks
- const blocks = splitSystemReminderBlocks(displayText);
-
- const allLines: string[] = [];
-
- for (const block of blocks) {
- if (!block.text.trim()) continue;
- if (allLines.length > 0) {
- allLines.push("");
+export const UserMessage = memo(
+ ({ line, prompt }: { line: UserLine; prompt?: string }) => {
+ const columns = useTerminalWidth();
+ const promptPrefix = `${prompt || ">"} `;
+ const prefixWidth = stringWidth(promptPrefix);
+ const continuationPrefix = " ".repeat(prefixWidth);
+ const contentWidth = Math.max(1, columns - prefixWidth);
+ const cleanedText = extractTaskNotificationsForDisplay(
+ line.text,
+ ).cleanedText;
+ const displayText = cleanedText.trim();
+ if (!displayText) {
+ return null;
}
- const blockLines = renderBlock(
- block.text,
- contentWidth,
- columns,
- !block.isSystemReminder,
- colorAnsi,
- );
- allLines.push(...blockLines);
- }
- return {allLines.join("\n")};
-});
+ // Build combined ANSI code for background + optional foreground
+ const { background, text: textColor } = colors.userMessage;
+ const bgAnsi = hexToBgAnsi(background);
+ const fgAnsi = textColor ? hexToFgAnsi(textColor) : "";
+ const colorAnsi = bgAnsi + fgAnsi;
+
+ // Split into system-reminder blocks and user content blocks
+ const blocks = splitSystemReminderBlocks(displayText);
+
+ const allLines: string[] = [];
+
+ for (const block of blocks) {
+ if (!block.text.trim()) continue;
+ if (allLines.length > 0) {
+ allLines.push("");
+ }
+ const blockLines = renderBlock(
+ block.text,
+ contentWidth,
+ columns,
+ !block.isSystemReminder,
+ colorAnsi,
+ promptPrefix,
+ continuationPrefix,
+ );
+ allLines.push(...blockLines);
+ }
+
+ return {allLines.join("\n")};
+ },
+);
UserMessage.displayName = "UserMessage";
diff --git a/src/cli/helpers/statusLineConfig.ts b/src/cli/helpers/statusLineConfig.ts
index 7f2b2b9..89f7a67 100644
--- a/src/cli/helpers/statusLineConfig.ts
+++ b/src/cli/helpers/statusLineConfig.ts
@@ -34,6 +34,7 @@ export interface NormalizedStatusLineConfig {
debounceMs: number;
refreshIntervalMs?: number;
disabled?: boolean;
+ prompt?: string;
}
/**
@@ -67,6 +68,7 @@ export function normalizeStatusLineConfig(
),
...(refreshIntervalMs !== undefined && { refreshIntervalMs }),
...(config.disabled !== undefined && { disabled: config.disabled }),
+ ...(config.prompt !== undefined && { prompt: config.prompt }),
};
}
@@ -164,3 +166,53 @@ export function resolveStatusLineConfig(
return null;
}
}
+
+/**
+ * Resolve the prompt character from status line settings.
+ * Independent of whether a `command` is configured.
+ * Returns `">"` when disabled or no prompt is configured at any level.
+ *
+ * Precedence: local project > project > global.
+ */
+export function resolvePromptChar(
+ workingDirectory: string = process.cwd(),
+): string {
+ try {
+ if (isStatusLineDisabled(workingDirectory)) return ">";
+
+ // Local project settings (highest priority)
+ try {
+ const local =
+ settingsManager.getLocalProjectSettings(workingDirectory)?.statusLine;
+ if (local?.prompt !== undefined) return local.prompt;
+ } catch {
+ // Not loaded
+ }
+
+ // Project settings
+ try {
+ const project =
+ settingsManager.getProjectSettings(workingDirectory)?.statusLine;
+ if (project?.prompt !== undefined) return project.prompt;
+ } catch {
+ // Not loaded
+ }
+
+ // Global settings
+ try {
+ const global = settingsManager.getSettings().statusLine;
+ if (global?.prompt !== undefined) return global.prompt;
+ } catch {
+ // Not initialized
+ }
+
+ return ">";
+ } catch (error) {
+ debugLog(
+ "statusline",
+ "resolvePromptChar: Failed to resolve prompt",
+ error,
+ );
+ return ">";
+ }
+}
diff --git a/src/cli/helpers/statusLineHelp.ts b/src/cli/helpers/statusLineHelp.ts
index 1771983..9925b71 100644
--- a/src/cli/helpers/statusLineHelp.ts
+++ b/src/cli/helpers/statusLineHelp.ts
@@ -33,7 +33,8 @@ export function formatStatusLineHelp(): string {
' "padding": 2,',
' "timeout": 5000,',
' "debounceMs": 300,',
- ' "refreshIntervalMs": 10000',
+ ' "refreshIntervalMs": 10000,',
+ ' "prompt": "→"',
" }",
"",
' type must be "command"',
@@ -42,6 +43,7 @@ export function formatStatusLineHelp(): string {
" timeout command timeout in ms (default 5000, max 30000)",
" debounceMs event debounce in ms (default 300)",
" refreshIntervalMs optional polling interval in ms (off by default)",
+ ' prompt custom input prompt character (default ">")',
"",
"INPUT (via JSON stdin)",
fieldList,
diff --git a/src/cli/hooks/useConfigurableStatusLine.ts b/src/cli/hooks/useConfigurableStatusLine.ts
index a72ce31..abd952e 100644
--- a/src/cli/hooks/useConfigurableStatusLine.ts
+++ b/src/cli/hooks/useConfigurableStatusLine.ts
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
type NormalizedStatusLineConfig,
+ resolvePromptChar,
resolveStatusLineConfig,
} from "../helpers/statusLineConfig";
import {
@@ -46,6 +47,7 @@ export interface StatusLineState {
executing: boolean;
lastError: string | null;
padding: number;
+ prompt: string;
}
function toPayloadInput(inputs: StatusLineInputs): StatusLinePayloadBuildInput {
@@ -77,6 +79,7 @@ export function useConfigurableStatusLine(
const [executing, setExecuting] = useState(false);
const [lastError, setLastError] = useState(null);
const [padding, setPadding] = useState(0);
+ const [prompt, setPrompt] = useState(">");
const inputsRef = useRef(inputs);
const configRef = useRef(null);
@@ -108,6 +111,9 @@ export function useConfigurableStatusLine(
const workingDirectory = inputsRef.current.currentDirectory;
const config = resolveStatusLineConfig(workingDirectory);
+ // Always resolve prompt, independent of whether a command is configured.
+ setPrompt(resolvePromptChar(workingDirectory));
+
if (!config) {
configRef.current = null;
// Abort any in-flight execution so stale results don't surface.
@@ -225,5 +231,5 @@ export function useConfigurableStatusLine(
currentDirectory,
]);
- return { text, rightText, active, executing, lastError, padding };
+ return { text, rightText, active, executing, lastError, padding, prompt };
}
diff --git a/src/settings-manager.ts b/src/settings-manager.ts
index c0328c8..0805a5f 100644
--- a/src/settings-manager.ts
+++ b/src/settings-manager.ts
@@ -35,6 +35,7 @@ export interface StatusLineConfig {
debounceMs?: number; // Debounce for event-driven refreshes (default 300)
refreshIntervalMs?: number; // Optional polling interval ms (opt-in)
disabled?: boolean; // Disable at this level
+ prompt?: string; // Custom input prompt character (default ">")
}
/**