feat: update custom status line prompt input (#938)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ">";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ">")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user