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 ">") } /**