feat: update custom status line prompt input (#938)

This commit is contained in:
jnjpng
2026-02-12 16:23:00 -08:00
committed by GitHub
parent 58002fb28a
commit 86a0fc9b1d
8 changed files with 151 additions and 55 deletions

View File

@@ -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" ? (
<WelcomeScreen loadingState="ready" {...item.snapshot} />
) : item.kind === "user" ? (
<UserMessage line={item} />
<UserMessage line={item} prompt={statusLine.prompt} />
) : item.kind === "reasoning" ? (
<ReasoningMessage line={item} />
) : item.kind === "assistant" ? (
@@ -10794,7 +10799,7 @@ Plan file path: ${planFilePath}`;
showPreview={showApprovalPreview}
/>
) : ln.kind === "user" ? (
<UserMessage line={ln} />
<UserMessage line={ln} prompt={statusLine.prompt} />
) : ln.kind === "reasoning" ? (
<ReasoningMessage line={ln} />
) : 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}
/>
</Box>

View File

@@ -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<number | null>(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 */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Box width={promptVisualWidth} flexShrink={0}>
<Text
color={isBashMode ? colors.bash.prompt : colors.input.prompt}
>
{isBashMode ? "!" : ">"}
{promptChar}
</Text>
<Text> </Text>
</Box>
@@ -1356,6 +1362,8 @@ export function Input({
statusLineText,
statusLineRight,
statusLinePadding,
promptChar,
promptVisualWidth,
]);
// If not visible, render nothing but keep component mounted to preserve state

View File

@@ -7,6 +7,8 @@ type UserLine = {
text: string;
};
export const UserMessage = memo(({ line }: { line: UserLine }) => {
return <Text>{`> ${line.text}`}</Text>;
});
export const UserMessage = memo(
({ line, prompt }: { line: UserLine; prompt?: string }) => {
return <Text>{`${prompt || ">"} ${line.text}`}</Text>;
},
);

View File

@@ -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 <Text>{allLines.join("\n")}</Text>;
});
// 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 <Text>{allLines.join("\n")}</Text>;
},
);
UserMessage.displayName = "UserMessage";

View File

@@ -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 ">";
}
}

View File

@@ -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,

View File

@@ -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<string | null>(null);
const [padding, setPadding] = useState(0);
const [prompt, setPrompt] = useState(">");
const inputsRef = useRef(inputs);
const configRef = useRef<NormalizedStatusLineConfig | null>(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 };
}