diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 9d01081..55da497 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -197,6 +197,10 @@ import { } from "./helpers/queuedMessageParts"; import { safeJsonParseOr } from "./helpers/safeJsonParse"; import { getDeviceType, getLocalTime } from "./helpers/sessionContext"; +import { resolveStatusLineConfig } from "./helpers/statusLineConfig"; +import { formatStatusLineHelp } from "./helpers/statusLineHelp"; +import { buildStatusLinePayload } from "./helpers/statusLinePayload"; +import { executeStatusLineCommand } from "./helpers/statusLineRuntime"; import { type ApprovalRequest, drainStreamWithResume } from "./helpers/stream"; import { collectFinishedTaskToolCalls, @@ -231,6 +235,7 @@ import { alwaysRequiresUserInput, isTaskTool, } from "./helpers/toolNameMapping.js"; +import { useConfigurableStatusLine } from "./hooks/useConfigurableStatusLine"; import { useSuspend } from "./hooks/useSuspend/useSuspend.ts"; import { useSyncedState } from "./hooks/useSyncedState"; import { useTerminalRows, useTerminalWidth } from "./hooks/useTerminalWidth"; @@ -332,6 +337,7 @@ const NON_STATE_COMMANDS = new Set([ "/feedback", "/export", "/download", + "/statusline", ]); // Check if a command is interactive (opens overlay, should not be queued) @@ -807,6 +813,8 @@ export default function App({ setAgentState((prev) => (prev ? { ...prev, name } : prev)); }, []); + const projectDirectory = process.cwd(); + // Track current conversation (always created fresh on startup) const [conversationId, setConversationId] = useState(initialConversationId); @@ -876,6 +884,12 @@ export default function App({ const [networkPhase, setNetworkPhase] = useState< "upload" | "download" | "error" | null >(null); + // Track permission mode changes for UI updates + const [uiPermissionMode, setUiPermissionMode] = useState( + permissionMode.getMode(), + ); + const statusLineTriggerVersionRef = useRef(0); + const [statusLineTriggerVersion, setStatusLineTriggerVersion] = useState(0); useEffect(() => { if (!streaming) { @@ -883,6 +897,11 @@ export default function App({ } }, [streaming]); + const triggerStatusLineRefresh = useCallback(() => { + statusLineTriggerVersionRef.current += 1; + setStatusLineTriggerVersion(statusLineTriggerVersionRef.current); + }, []); + // Guard ref for preventing concurrent processConversation calls // Separate from streaming state which may be set early for UI responsiveness // Tracks depth to allow intentional reentry while blocking parallel calls @@ -1936,6 +1955,45 @@ export default function App({ buffersRef.current.tokenStreamingEnabled = tokenStreamingEnabled; }, [tokenStreamingEnabled]); + // Configurable status line hook + const sessionStatsSnapshot = sessionStatsRef.current.getSnapshot(); + const contextWindowSize = llmConfigRef.current?.context_window; + const statusLine = useConfigurableStatusLine({ + modelId: llmConfigRef.current?.model ?? null, + modelDisplayName: currentModelDisplay, + currentDirectory: process.cwd(), + projectDirectory, + sessionId: conversationId, + agentName, + totalDurationMs: sessionStatsSnapshot.totalWallMs, + totalApiDurationMs: sessionStatsSnapshot.totalApiMs, + totalInputTokens: sessionStatsSnapshot.usage.promptTokens, + totalOutputTokens: sessionStatsSnapshot.usage.completionTokens, + contextWindowSize, + usedContextTokens: contextTrackerRef.current.lastContextTokens, + permissionMode: uiPermissionMode, + networkPhase, + terminalWidth: columns, + triggerVersion: statusLineTriggerVersion, + }); + + const previousStreamingForStatusLineRef = useRef(streaming); + useEffect(() => { + // Trigger status line when an assistant stream completes. + if (previousStreamingForStatusLineRef.current && !streaming) { + triggerStatusLineRefresh(); + } + previousStreamingForStatusLineRef.current = streaming; + }, [streaming, triggerStatusLineRefresh]); + + const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}`; + + // Trigger status line when key session identity/display state changes. + useEffect(() => { + void statusLineRefreshIdentity; + triggerStatusLineRefresh(); + }, [statusLineRefreshIdentity, triggerStatusLineRefresh]); + // Keep buffers in sync with agentId for server-side tool hooks useEffect(() => { buffersRef.current.agentId = agentState?.id; @@ -5731,6 +5789,172 @@ export default function App({ return { submitted: true }; } + // Special handling for /statusline command + if (trimmed === "/statusline" || trimmed.startsWith("/statusline ")) { + const parts = trimmed.slice("/statusline".length).trim().split(/\s+/); + const sub = parts[0] || "show"; + const rest = parts.slice(1).join(" "); + const cmd = commandRunner.start(trimmed, "Managing status line..."); + + (async () => { + try { + const wd = process.cwd(); + if (sub === "help") { + cmd.finish(formatStatusLineHelp(), true, true); + } else if (sub === "show") { + // Display config from all levels + resolved effective + const lines: string[] = []; + try { + const global = settingsManager.getSettings().statusLine; + lines.push( + `Global: ${global?.command ? `command="${global.command}" refreshInterval=${global.refreshIntervalMs ?? "off"} timeout=${global.timeout ?? "default"} debounce=${global.debounceMs ?? "default"} padding=${global.padding ?? 0} disabled=${global.disabled ?? false}` : "(not set)"}`, + ); + } catch { + lines.push("Global: (unavailable)"); + } + try { + const project = + settingsManager.getProjectSettings(wd)?.statusLine; + lines.push( + `Project: ${project?.command ? `command="${project.command}"` : "(not set)"}`, + ); + } catch { + lines.push("Project: (not loaded)"); + } + try { + const local = + settingsManager.getLocalProjectSettings(wd)?.statusLine; + lines.push( + `Local: ${local?.command ? `command="${local.command}"` : "(not set)"}`, + ); + } catch { + lines.push("Local: (not loaded)"); + } + const effective = resolveStatusLineConfig(wd); + lines.push( + `Effective: ${effective ? `command="${effective.command}" refreshInterval=${effective.refreshIntervalMs ?? "off"} timeout=${effective.timeout}ms debounce=${effective.debounceMs}ms padding=${effective.padding}` : "(inactive)"}`, + ); + cmd.finish(lines.join("\n"), true); + } else if (sub === "set") { + if (!rest) { + cmd.finish("Usage: /statusline set [-l|-p]", false); + return; + } + const isLocal = rest.endsWith(" -l"); + const isProject = rest.endsWith(" -p"); + const command = rest.replace(/\s+-(l|p)$/, ""); + const config = { command }; + if (isLocal) { + settingsManager.updateLocalProjectSettings( + { statusLine: config }, + wd, + ); + cmd.finish(`Status line set (local): ${command}`, true); + } else if (isProject) { + settingsManager.updateProjectSettings( + { statusLine: config }, + wd, + ); + cmd.finish(`Status line set (project): ${command}`, true); + } else { + settingsManager.updateSettings({ statusLine: config }); + cmd.finish(`Status line set (global): ${command}`, true); + } + } else if (sub === "clear") { + const isLocal = rest === "-l"; + const isProject = rest === "-p"; + if (isLocal) { + settingsManager.updateLocalProjectSettings( + { statusLine: undefined }, + wd, + ); + cmd.finish("Status line cleared (local)", true); + } else if (isProject) { + settingsManager.updateProjectSettings( + { statusLine: undefined }, + wd, + ); + cmd.finish("Status line cleared (project)", true); + } else { + settingsManager.updateSettings({ statusLine: undefined }); + cmd.finish("Status line cleared (global)", true); + } + } else if (sub === "test") { + const config = resolveStatusLineConfig(wd); + if (!config) { + cmd.finish("No status line configured", false); + return; + } + const stats = sessionStatsRef.current.getSnapshot(); + const result = await executeStatusLineCommand( + config.command, + buildStatusLinePayload({ + modelId: llmConfigRef.current?.model ?? null, + modelDisplayName: currentModelDisplay, + currentDirectory: wd, + projectDirectory, + sessionId: conversationIdRef.current, + agentName, + totalDurationMs: stats.totalWallMs, + totalApiDurationMs: stats.totalApiMs, + totalInputTokens: stats.usage.promptTokens, + totalOutputTokens: stats.usage.completionTokens, + contextWindowSize: llmConfigRef.current?.context_window, + usedContextTokens: + contextTrackerRef.current.lastContextTokens, + permissionMode: uiPermissionMode, + networkPhase, + terminalWidth: columns, + }), + { timeout: config.timeout, workingDirectory: wd }, + ); + if (result.ok) { + cmd.finish( + `Output: ${result.text} (${result.durationMs}ms)`, + true, + ); + } else { + cmd.finish( + `Error: ${result.error} (${result.durationMs}ms)`, + false, + ); + } + } else if (sub === "disable") { + settingsManager.updateSettings({ + statusLine: { + ...settingsManager.getSettings().statusLine, + command: + settingsManager.getSettings().statusLine?.command ?? "", + disabled: true, + }, + }); + cmd.finish("Status line disabled", true); + } else if (sub === "enable") { + const current = settingsManager.getSettings().statusLine; + if (current) { + settingsManager.updateSettings({ + statusLine: { ...current, disabled: false }, + }); + } + cmd.finish("Status line enabled", true); + } else { + cmd.finish( + `Unknown subcommand: ${sub}. Use help|show|set|clear|test|enable|disable`, + false, + ); + } + } catch (error) { + cmd.finish( + `Error: ${error instanceof Error ? error.message : String(error)}`, + false, + ); + } + })(); + + triggerStatusLineRefresh(); + return { submitted: true }; + } + // Special handling for /usage command - show session stats if (trimmed === "/usage") { const cmd = commandRunner.start( @@ -9439,11 +9663,6 @@ ${SYSTEM_REMINDER_CLOSE} } }, [commandRunner, profileConfirmPending]); - // Track permission mode changes for UI updates - const [uiPermissionMode, setUiPermissionMode] = useState( - permissionMode.getMode(), - ); - // Handle ralph mode exit from Input component (shift+tab) const handleRalphExit = useCallback(() => { const ralph = ralphMode.getState(); @@ -9459,15 +9678,19 @@ ${SYSTEM_REMINDER_CLOSE} }, []); // Handle permission mode changes from the Input component (e.g., shift+tab cycling) - const handlePermissionModeChange = useCallback((mode: PermissionMode) => { - // When entering plan mode via tab cycling, generate and set the plan file path - if (mode === "plan") { - const planPath = generatePlanFilePath(); - permissionMode.setPlanFilePath(planPath); - } - // permissionMode.setMode() is called in InputRich.tsx before this callback - setUiPermissionMode(mode); - }, []); + const handlePermissionModeChange = useCallback( + (mode: PermissionMode) => { + // When entering plan mode via tab cycling, generate and set the plan file path + if (mode === "plan") { + const planPath = generatePlanFilePath(); + permissionMode.setPlanFilePath(planPath); + } + // permissionMode.setMode() is called in InputRich.tsx before this callback + setUiPermissionMode(mode); + triggerStatusLineRefresh(); + }, + [triggerStatusLineRefresh], + ); const handlePlanApprove = useCallback( async (acceptEdits: boolean = false) => { @@ -10354,6 +10577,9 @@ Plan file path: ${planFilePath}`; networkPhase={networkPhase} terminalWidth={columns} shouldAnimate={shouldAnimate} + statusLineText={statusLine.text || undefined} + statusLineRight={statusLine.rightText || undefined} + statusLinePadding={statusLine.padding || 0} /> diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index c53794e..d406674 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -226,6 +226,15 @@ export const commands: Record = { return "Opening hooks manager..."; }, }, + "/statusline": { + desc: "Configure status line (help|show|set|clear|test|enable|disable)", + args: "[subcommand]", + order: 36.5, + handler: () => { + // Handled specially in App.tsx + return "Managing status line..."; + }, + }, "/terminal": { desc: "Setup terminal shortcuts [--revert]", order: 37, diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 129e35a..fdded73 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -4,10 +4,12 @@ import { EventEmitter } from "node:events"; import { stdin } from "node:process"; import chalk from "chalk"; import { Box, useInput } from "ink"; +import Link from "ink-link"; import SpinnerLib from "ink-spinner"; import { type ComponentType, memo, + type ReactNode, useCallback, useEffect, useMemo, @@ -108,6 +110,85 @@ function findCursorLine( }; } +// Matches OSC 8 hyperlink sequences: \x1b]8;;URL\x1b\DISPLAY\x1b]8;;\x1b\ +// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 escape sequences require \x1b +const OSC8_REGEX = /\x1b\]8;;([^\x1b]*)\x1b\\([^\x1b]*)\x1b\]8;;\x1b\\/g; + +function parseOsc8Line(line: string, keyPrefix: string): ReactNode[] { + const parts: ReactNode[] = []; + let lastIndex = 0; + const regex = new RegExp(OSC8_REGEX.source, "g"); + + for (let match = regex.exec(line); match !== null; match = regex.exec(line)) { + if (match.index > lastIndex) { + parts.push( + + {line.slice(lastIndex, match.index)} + , + ); + } + const url = match[1] ?? ""; + const display = match[2] ?? ""; + parts.push( + + {display} + , + ); + lastIndex = match.index + match[0].length; + } + if (lastIndex < line.length) { + parts.push( + {line.slice(lastIndex)}, + ); + } + if (parts.length === 0) { + parts.push({line}); + } + return parts; +} + +function StatusLineContent({ + text, + padding, + modeName, + modeColor, + showExitHint, +}: { + text: string; + padding: number; + modeName: string | null; + modeColor: string | null; + showExitHint: boolean; +}) { + const lines = text.split("\n"); + const paddingStr = padding > 0 ? " ".repeat(padding) : ""; + const parts: ReactNode[] = []; + for (let i = 0; i < lines.length; i++) { + if (i > 0) { + parts.push("\n"); + } + if (paddingStr) { + parts.push(paddingStr); + } + parts.push(...parseOsc8Line(lines[i] ?? "", `l${i}`)); + } + return ( + + {parts} + {modeName && modeColor && ( + <> + {"\n"} + ⏵⏵ {modeName} + + {" "} + (shift+tab to {showExitHint ? "exit" : "cycle"}) + + + )} + + ); +} + /** * Memoized footer component to prevent re-renders during high-frequency * shimmer/timer updates. Only updates when its specific props change. @@ -125,6 +206,9 @@ const InputFooter = memo(function InputFooter({ isByokProvider, hideFooter, rightColumnWidth, + statusLineText, + statusLineRight, + statusLinePadding, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -138,6 +222,9 @@ const InputFooter = memo(function InputFooter({ isByokProvider: boolean; hideFooter: boolean; rightColumnWidth: number; + statusLineText?: string; + statusLineRight?: string; + statusLinePadding?: number; }) { const hideFooterContent = hideFooter; const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45)); @@ -188,6 +275,14 @@ const InputFooter = memo(function InputFooter({ (backspace to exit) + ) : statusLineText ? ( + ) : modeName && modeColor ? ( ⏵⏵ {modeName} @@ -200,9 +295,26 @@ const InputFooter = memo(function InputFooter({ Press / for commands )} - + {hideFooterContent ? ( {" ".repeat(rightColumnWidth)} + ) : statusLineRight ? ( + statusLineRight.split("\n").map((line) => ( + + {parseOsc8Line(line, line)} + + )) ) : ( {rightLabel} )} @@ -423,6 +535,9 @@ export function Input({ networkPhase = null, terminalWidth, shouldAnimate = true, + statusLineText, + statusLineRight, + statusLinePadding = 0, }: { visible?: boolean; streaming: boolean; @@ -458,6 +573,9 @@ export function Input({ networkPhase?: "upload" | "download" | "error" | null; terminalWidth: number; shouldAnimate?: boolean; + statusLineText?: string; + statusLineRight?: string; + statusLinePadding?: number; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -1192,6 +1310,9 @@ export function Input({ } hideFooter={hideFooter} rightColumnWidth={footerRightColumnWidth} + statusLineText={statusLineText} + statusLineRight={statusLineRight} + statusLinePadding={statusLinePadding} /> ) : reserveInputSpace ? ( @@ -1232,6 +1353,9 @@ export function Input({ footerRightColumnWidth, reserveInputSpace, inputChromeHeight, + statusLineText, + statusLineRight, + statusLinePadding, ]); // If not visible, render nothing but keep component mounted to preserve state diff --git a/src/cli/helpers/statusLineConfig.ts b/src/cli/helpers/statusLineConfig.ts new file mode 100644 index 0000000..7f2b2b9 --- /dev/null +++ b/src/cli/helpers/statusLineConfig.ts @@ -0,0 +1,166 @@ +// Config resolution for user-defined status line commands. +// Precedence: local project > project > global settings. + +import type { StatusLineConfig } from "../../settings-manager"; +import { settingsManager } from "../../settings-manager"; +import { debugLog } from "../../utils/debug"; + +/** Minimum allowed polling interval (1 second). */ +export const MIN_STATUS_LINE_INTERVAL_MS = 1_000; + +/** Default execution timeout (5 seconds). */ +export const DEFAULT_STATUS_LINE_TIMEOUT_MS = 5_000; + +/** Maximum allowed execution timeout (30 seconds). */ +export const MAX_STATUS_LINE_TIMEOUT_MS = 30_000; + +/** Default trigger debounce (300ms). */ +export const DEFAULT_STATUS_LINE_DEBOUNCE_MS = 300; + +/** Minimum allowed debounce. */ +export const MIN_STATUS_LINE_DEBOUNCE_MS = 50; + +/** Maximum allowed debounce. */ +export const MAX_STATUS_LINE_DEBOUNCE_MS = 5_000; + +/** Maximum allowed padding. */ +export const MAX_STATUS_LINE_PADDING = 16; + +export interface NormalizedStatusLineConfig { + type: "command"; + command: string; + padding: number; + timeout: number; + debounceMs: number; + refreshIntervalMs?: number; + disabled?: boolean; +} + +/** + * Clamp status line config to valid ranges and fill defaults. + */ +export function normalizeStatusLineConfig( + config: StatusLineConfig, +): NormalizedStatusLineConfig { + const refreshIntervalMs = + config.refreshIntervalMs === undefined + ? undefined + : Math.max(MIN_STATUS_LINE_INTERVAL_MS, config.refreshIntervalMs); + + return { + type: "command", + command: config.command, + padding: Math.max( + 0, + Math.min(MAX_STATUS_LINE_PADDING, config.padding ?? 0), + ), + timeout: Math.min( + MAX_STATUS_LINE_TIMEOUT_MS, + Math.max(1_000, config.timeout ?? DEFAULT_STATUS_LINE_TIMEOUT_MS), + ), + debounceMs: Math.max( + MIN_STATUS_LINE_DEBOUNCE_MS, + Math.min( + MAX_STATUS_LINE_DEBOUNCE_MS, + config.debounceMs ?? DEFAULT_STATUS_LINE_DEBOUNCE_MS, + ), + ), + ...(refreshIntervalMs !== undefined && { refreshIntervalMs }), + ...(config.disabled !== undefined && { disabled: config.disabled }), + }; +} + +/** + * Check whether the status line is disabled across settings levels. + * + * Precedence (mirrors `areHooksDisabled` in hooks/loader.ts): + * 1. User `disabled: false` → ENABLED (explicit override) + * 2. User `disabled: true` → DISABLED + * 3. Project or local-project `disabled: true` → DISABLED + * 4. Default → ENABLED (if a config exists) + */ +export function isStatusLineDisabled( + workingDirectory: string = process.cwd(), +): boolean { + try { + const userDisabled = settingsManager.getSettings().statusLine?.disabled; + if (userDisabled === false) return false; + if (userDisabled === true) return true; + + try { + const projectDisabled = + settingsManager.getProjectSettings(workingDirectory)?.statusLine + ?.disabled; + if (projectDisabled === true) return true; + } catch { + // Project settings not loaded + } + + try { + const localDisabled = + settingsManager.getLocalProjectSettings(workingDirectory)?.statusLine + ?.disabled; + if (localDisabled === true) return true; + } catch { + // Local project settings not loaded + } + + return false; + } catch (error) { + debugLog( + "statusline", + "isStatusLineDisabled: Failed to check disabled status", + error, + ); + return false; + } +} + +/** + * Resolve effective status line config from all settings levels. + * Returns null if no config is defined or the status line is disabled. + * + * Precedence: local project > project > global. + */ +export function resolveStatusLineConfig( + workingDirectory: string = process.cwd(), +): NormalizedStatusLineConfig | null { + try { + if (isStatusLineDisabled(workingDirectory)) return null; + + // Local project settings (highest priority) + try { + const local = + settingsManager.getLocalProjectSettings(workingDirectory)?.statusLine; + if (local?.command) return normalizeStatusLineConfig(local); + } catch { + // Not loaded + } + + // Project settings + try { + const project = + settingsManager.getProjectSettings(workingDirectory)?.statusLine; + if (project?.command) return normalizeStatusLineConfig(project); + } catch { + // Not loaded + } + + // Global settings + try { + const global = settingsManager.getSettings().statusLine; + if (global?.command) return normalizeStatusLineConfig(global); + } catch { + // Not initialized + } + + return null; + } catch (error) { + debugLog( + "statusline", + "resolveStatusLineConfig: Failed to resolve config", + error, + ); + return null; + } +} diff --git a/src/cli/helpers/statusLineHelp.ts b/src/cli/helpers/statusLineHelp.ts new file mode 100644 index 0000000..1771983 --- /dev/null +++ b/src/cli/helpers/statusLineHelp.ts @@ -0,0 +1,49 @@ +import { + STATUSLINE_DERIVED_FIELDS, + STATUSLINE_NATIVE_FIELDS, +} from "./statusLineSchema"; + +export function formatStatusLineHelp(): string { + const allFields = [...STATUSLINE_NATIVE_FIELDS, ...STATUSLINE_DERIVED_FIELDS]; + const fieldList = allFields.map((f) => ` - ${f.path}`).join("\n"); + + return [ + "/statusline help", + "", + "Configure a custom CLI status line command.", + "", + "USAGE", + " /statusline show", + " /statusline set [-l|-p]", + " /statusline clear [-l|-p]", + " /statusline test", + " /statusline enable", + " /statusline disable", + " /statusline help", + "", + "SCOPES", + " (default) global ~/.letta/settings.json", + " -p project ./.letta/settings.json", + " -l local ./.letta/settings.local.json", + "", + "CONFIGURATION", + ' "statusLine": {', + ' "type": "command",', + ' "command": "~/.letta/statusline-command.sh",', + ' "padding": 2,', + ' "timeout": 5000,', + ' "debounceMs": 300,', + ' "refreshIntervalMs": 10000', + " }", + "", + ' type must be "command"', + " command shell command to execute", + " padding left padding in spaces (default 0, max 16)", + " 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)", + "", + "INPUT (via JSON stdin)", + fieldList, + ].join("\n"); +} diff --git a/src/cli/helpers/statusLinePayload.ts b/src/cli/helpers/statusLinePayload.ts new file mode 100644 index 0000000..e7c1aec --- /dev/null +++ b/src/cli/helpers/statusLinePayload.ts @@ -0,0 +1,156 @@ +import { getVersion } from "../../version"; + +export interface StatusLinePayloadBuildInput { + modelId?: string | null; + modelDisplayName?: string | null; + currentDirectory: string; + projectDirectory: string; + sessionId?: string; + agentName?: string | null; + totalDurationMs?: number; + totalApiDurationMs?: number; + totalInputTokens?: number; + totalOutputTokens?: number; + contextWindowSize?: number; + usedContextTokens?: number; + permissionMode?: string; + networkPhase?: "upload" | "download" | "error" | null; + terminalWidth?: number; +} + +/** + * Status line payload piped as JSON to the command's stdin. + * + * Unsupported fields are set to null to keep JSON stable for scripts. + */ +export interface StatusLinePayload { + cwd: string; + workspace: { + current_dir: string; + project_dir: string; + }; + session_id?: string; + transcript_path: string | null; + version: string; + model: { + id: string | null; + display_name: string | null; + }; + output_style: { + name: string | null; + }; + cost: { + total_cost_usd: number | null; + total_duration_ms: number; + total_api_duration_ms: number; + total_lines_added: number | null; + total_lines_removed: number | null; + }; + context_window: { + total_input_tokens: number; + total_output_tokens: number; + context_window_size: number; + used_percentage: number | null; + remaining_percentage: number | null; + current_usage: { + input_tokens: number | null; + output_tokens: number | null; + cache_creation_input_tokens: number | null; + cache_read_input_tokens: number | null; + } | null; + }; + exceeds_200k_tokens: boolean; + vim: { + mode: string | null; + } | null; + agent: { + name: string | null; + }; + permission_mode: string | null; + network_phase: "upload" | "download" | "error" | null; + terminal_width: number | null; +} + +export function calculateContextPercentages( + usedTokens: number, + contextWindowSize: number, +): { used: number; remaining: number } { + if (contextWindowSize <= 0) { + return { used: 0, remaining: 100 }; + } + + const used = Math.max( + 0, + Math.min(100, Math.round((usedTokens / contextWindowSize) * 100)), + ); + return { used, remaining: Math.max(0, 100 - used) }; +} + +export function buildStatusLinePayload( + input: StatusLinePayloadBuildInput, +): StatusLinePayload { + const totalDurationMs = Math.max(0, Math.floor(input.totalDurationMs ?? 0)); + const totalApiDurationMs = Math.max( + 0, + Math.floor(input.totalApiDurationMs ?? 0), + ); + const totalInputTokens = Math.max(0, Math.floor(input.totalInputTokens ?? 0)); + const totalOutputTokens = Math.max( + 0, + Math.floor(input.totalOutputTokens ?? 0), + ); + const contextWindowSize = Math.max( + 0, + Math.floor(input.contextWindowSize ?? 0), + ); + const usedContextTokens = Math.max( + 0, + Math.floor(input.usedContextTokens ?? 0), + ); + + const percentages = + contextWindowSize > 0 + ? calculateContextPercentages(usedContextTokens, contextWindowSize) + : null; + + return { + cwd: input.currentDirectory, + workspace: { + current_dir: input.currentDirectory, + project_dir: input.projectDirectory, + }, + ...(input.sessionId ? { session_id: input.sessionId } : {}), + transcript_path: null, + version: getVersion(), + model: { + id: input.modelId ?? null, + display_name: input.modelDisplayName ?? null, + }, + output_style: { + name: null, + }, + cost: { + total_cost_usd: null, + total_duration_ms: totalDurationMs, + total_api_duration_ms: totalApiDurationMs, + total_lines_added: null, + total_lines_removed: null, + }, + context_window: { + total_input_tokens: totalInputTokens, + total_output_tokens: totalOutputTokens, + context_window_size: contextWindowSize, + used_percentage: percentages?.used ?? null, + remaining_percentage: percentages?.remaining ?? null, + current_usage: null, + }, + exceeds_200k_tokens: usedContextTokens > 200_000, + vim: null, + agent: { + name: input.agentName ?? null, + }, + permission_mode: input.permissionMode ?? null, + network_phase: input.networkPhase ?? null, + terminal_width: input.terminalWidth ?? null, + }; +} diff --git a/src/cli/helpers/statusLineRuntime.ts b/src/cli/helpers/statusLineRuntime.ts new file mode 100644 index 0000000..7d4640c --- /dev/null +++ b/src/cli/helpers/statusLineRuntime.ts @@ -0,0 +1,221 @@ +// src/cli/helpers/statusLineRuntime.ts +// Executes a status-line shell command, pipes JSON to stdin, collects stdout. + +import { type ChildProcess, spawn } from "node:child_process"; +import { buildShellLaunchers } from "../../tools/impl/shellLaunchers"; + +/** Maximum stdout bytes collected (4 KB). */ +const MAX_STDOUT_BYTES = 4096; + +/** Result returned by executeStatusLineCommand. */ +export interface StatusLineResult { + text: string; + ok: boolean; + durationMs: number; + error?: string; +} + +/** + * Execute a status-line command. + * + * Spawns the command via platform-appropriate shell launchers (same strategy + * as hook execution), pipes `payload` as JSON to stdin, and collects up to + * MAX_STDOUT_BYTES of stdout. + */ +export async function executeStatusLineCommand( + command: string, + payload: unknown, + options: { + timeout: number; + signal?: AbortSignal; + workingDirectory?: string; + }, +): Promise { + const startTime = Date.now(); + const { timeout, signal, workingDirectory } = options; + + // Early abort check + if (signal?.aborted) { + return { text: "", ok: false, durationMs: 0, error: "Aborted" }; + } + + const launchers = buildShellLaunchers(command); + if (launchers.length === 0) { + return { + text: "", + ok: false, + durationMs: Date.now() - startTime, + error: "No shell launchers available", + }; + } + + const inputJson = JSON.stringify(payload); + let lastError: string | null = null; + + for (const launcher of launchers) { + try { + const result = await runWithLauncher( + launcher, + inputJson, + timeout, + signal, + workingDirectory, + startTime, + ); + return result; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + lastError = error.message; + continue; + } + return { + text: "", + ok: false, + durationMs: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + return { + text: "", + ok: false, + durationMs: Date.now() - startTime, + error: lastError ?? "No suitable shell found", + }; +} + +function runWithLauncher( + launcher: string[], + inputJson: string, + timeout: number, + signal: AbortSignal | undefined, + workingDirectory: string | undefined, + startTime: number, +): Promise { + return new Promise((resolve, reject) => { + const [executable, ...args] = launcher; + if (!executable) { + reject(new Error("Empty launcher")); + return; + } + + let stdout = ""; + let stdoutBytes = 0; + let timedOut = false; + let resolved = false; + + const safeResolve = (result: StatusLineResult) => { + if (!resolved) { + resolved = true; + resolve(result); + } + }; + + let child: ChildProcess; + try { + child = spawn(executable, args, { + cwd: workingDirectory || process.cwd(), + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + reject(error); + return; + } + + // Timeout + const timeoutId = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!resolved) child.kill("SIGKILL"); + }, 500); + }, timeout); + + // AbortSignal + const onAbort = () => { + if (!resolved) { + child.kill("SIGTERM"); + clearTimeout(timeoutId); + safeResolve({ + text: "", + ok: false, + durationMs: Date.now() - startTime, + error: "Aborted", + }); + } + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + // Stdin + if (child.stdin) { + child.stdin.on("error", () => {}); + child.stdin.write(inputJson); + child.stdin.end(); + } + + // Stdout (capped) + if (child.stdout) { + child.stdout.on("data", (data: Buffer) => { + if (stdoutBytes < MAX_STDOUT_BYTES) { + const remaining = MAX_STDOUT_BYTES - stdoutBytes; + const chunk = data.toString( + "utf-8", + 0, + Math.min(data.length, remaining), + ); + stdout += chunk; + stdoutBytes += data.length; + } + }); + } + + // Stderr (ignored for status line) + + child.on("close", (code: number | null) => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + const durationMs = Date.now() - startTime; + + if (timedOut) { + safeResolve({ + text: "", + ok: false, + durationMs, + error: `Status line command timed out after ${timeout}ms`, + }); + return; + } + + const ok = code === 0; + safeResolve({ + text: ok ? stdout.trim() : "", + ok, + durationMs, + ...(!ok && { error: `Exit code ${code ?? "null"}` }), + }); + }); + + child.on("error", (error: NodeJS.ErrnoException) => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + + if (error.code === "ENOENT") { + reject(error); + return; + } + + safeResolve({ + text: "", + ok: false, + durationMs: Date.now() - startTime, + error: error.message, + }); + }); + }); +} diff --git a/src/cli/helpers/statusLineSchema.ts b/src/cli/helpers/statusLineSchema.ts new file mode 100644 index 0000000..18f1ada --- /dev/null +++ b/src/cli/helpers/statusLineSchema.ts @@ -0,0 +1,30 @@ +// Status line input field definitions for Letta Code. + +export interface StatusLineFieldSpec { + path: string; +} + +export const STATUSLINE_NATIVE_FIELDS: StatusLineFieldSpec[] = [ + { path: "cwd" }, + { path: "workspace.current_dir" }, + { path: "workspace.project_dir" }, + { path: "session_id" }, + { path: "version" }, + { path: "model.id" }, + { path: "model.display_name" }, + { path: "agent.name" }, + { path: "cost.total_duration_ms" }, + { path: "cost.total_api_duration_ms" }, + { path: "context_window.context_window_size" }, + { path: "context_window.total_input_tokens" }, + { path: "context_window.total_output_tokens" }, + { path: "permission_mode" }, + { path: "network_phase" }, + { path: "terminal_width" }, +]; + +export const STATUSLINE_DERIVED_FIELDS: StatusLineFieldSpec[] = [ + { path: "context_window.used_percentage" }, + { path: "context_window.remaining_percentage" }, + { path: "exceeds_200k_tokens" }, +]; diff --git a/src/cli/hooks/useConfigurableStatusLine.ts b/src/cli/hooks/useConfigurableStatusLine.ts new file mode 100644 index 0000000..936d091 --- /dev/null +++ b/src/cli/hooks/useConfigurableStatusLine.ts @@ -0,0 +1,226 @@ +// React hook that executes a user-defined status-line command. +// +// Behavior: +// - Event-driven refreshes with debounce (default 300ms) +// - Cancel in-flight execution on retrigger (latest data wins) +// - Optional polling when refreshIntervalMs is configured + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type NormalizedStatusLineConfig, + resolveStatusLineConfig, +} from "../helpers/statusLineConfig"; +import { + buildStatusLinePayload, + type StatusLinePayloadBuildInput, +} from "../helpers/statusLinePayload"; +import { executeStatusLineCommand } from "../helpers/statusLineRuntime"; + +/** Inputs supplied by App.tsx to build the payload and triggers. */ +export interface StatusLineInputs { + modelId?: string | null; + modelDisplayName?: string | null; + currentDirectory: string; + projectDirectory: string; + sessionId?: string; + agentName?: string | null; + totalDurationMs?: number; + totalApiDurationMs?: number; + totalInputTokens?: number; + totalOutputTokens?: number; + contextWindowSize?: number; + usedContextTokens?: number; + permissionMode?: string; + networkPhase?: "upload" | "download" | "error" | null; + terminalWidth?: number; + triggerVersion: number; +} + +/** ASCII Record Separator used to split left/right column output. */ +const RS = "\x1e"; + +export interface StatusLineState { + text: string; + rightText: string; + active: boolean; + executing: boolean; + lastError: string | null; + padding: number; +} + +function toPayloadInput(inputs: StatusLineInputs): StatusLinePayloadBuildInput { + return { + modelId: inputs.modelId, + modelDisplayName: inputs.modelDisplayName, + currentDirectory: inputs.currentDirectory, + projectDirectory: inputs.projectDirectory, + sessionId: inputs.sessionId, + agentName: inputs.agentName, + totalDurationMs: inputs.totalDurationMs, + totalApiDurationMs: inputs.totalApiDurationMs, + totalInputTokens: inputs.totalInputTokens, + totalOutputTokens: inputs.totalOutputTokens, + contextWindowSize: inputs.contextWindowSize, + usedContextTokens: inputs.usedContextTokens, + permissionMode: inputs.permissionMode, + networkPhase: inputs.networkPhase, + terminalWidth: inputs.terminalWidth, + }; +} + +export function useConfigurableStatusLine( + inputs: StatusLineInputs, +): StatusLineState { + const [text, setText] = useState(""); + const [rightText, setRightText] = useState(""); + const [active, setActive] = useState(false); + const [executing, setExecuting] = useState(false); + const [lastError, setLastError] = useState(null); + const [padding, setPadding] = useState(0); + + const inputsRef = useRef(inputs); + const configRef = useRef(null); + const abortRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const refreshIntervalRef = useRef | null>( + null, + ); + + useEffect(() => { + inputsRef.current = inputs; + }, [inputs]); + + const clearDebounceTimer = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); + + const clearRefreshInterval = useCallback(() => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + }, []); + + const resolveActiveConfig = useCallback(() => { + const workingDirectory = inputsRef.current.currentDirectory; + const config = resolveStatusLineConfig(workingDirectory); + + if (!config) { + configRef.current = null; + setActive(false); + setText(""); + setRightText(""); + setPadding(0); + return null; + } + + configRef.current = config; + setActive(true); + setPadding(config.padding); + return config; + }, []); + + const executeNow = useCallback(async () => { + const config = configRef.current ?? resolveActiveConfig(); + if (!config) return; + + // Cancel in-flight execution so only the latest result is used. + abortRef.current?.abort(); + + const ac = new AbortController(); + abortRef.current = ac; + setExecuting(true); + + try { + const currentInputs = inputsRef.current; + const result = await executeStatusLineCommand( + config.command, + buildStatusLinePayload(toPayloadInput(currentInputs)), + { + timeout: config.timeout, + signal: ac.signal, + workingDirectory: currentInputs.currentDirectory, + }, + ); + + if (ac.signal.aborted) return; + + if (result.ok) { + const rsIdx = result.text.indexOf(RS); + if (rsIdx >= 0) { + setText(result.text.slice(0, rsIdx)); + setRightText(result.text.slice(rsIdx + 1)); + } else { + setText(result.text); + setRightText(""); + } + setLastError(null); + } else { + setLastError(result.error ?? "Unknown error"); + } + } catch { + if (!ac.signal.aborted) { + setLastError("Execution exception"); + } + } finally { + if (abortRef.current === ac) { + abortRef.current = null; + } + setExecuting(false); + } + }, [resolveActiveConfig]); + + const scheduleDebouncedRun = useCallback(() => { + const config = resolveActiveConfig(); + if (!config) return; + + clearDebounceTimer(); + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + void executeNow(); + }, config.debounceMs); + }, [clearDebounceTimer, executeNow, resolveActiveConfig]); + + const triggerVersion = inputs.triggerVersion; + + // Event-driven trigger updates. + useEffect(() => { + // tie this effect explicitly to triggerVersion for lint + semantics + void triggerVersion; + scheduleDebouncedRun(); + }, [scheduleDebouncedRun, triggerVersion]); + + const currentDirectory = inputs.currentDirectory; + + // Re-resolve config and optional polling whenever working directory changes. + useEffect(() => { + // tie this effect explicitly to currentDirectory for lint + semantics + void currentDirectory; + const config = resolveActiveConfig(); + + clearRefreshInterval(); + if (config?.refreshIntervalMs) { + refreshIntervalRef.current = setInterval(() => { + scheduleDebouncedRun(); + }, config.refreshIntervalMs); + } + + return () => { + clearRefreshInterval(); + clearDebounceTimer(); + abortRef.current?.abort(); + abortRef.current = null; + }; + }, [ + clearDebounceTimer, + clearRefreshInterval, + resolveActiveConfig, + scheduleDebouncedRun, + currentDirectory, + ]); + + return { text, rightText, active, executing, lastError, padding }; +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index ccc758a..d090a23 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -24,6 +24,19 @@ export interface SessionRef { conversationId: string; } +/** + * Configuration for a user-defined status line command. + */ +export interface StatusLineConfig { + type?: "command"; + command: string; // Shell command (receives JSON stdin, outputs text) + padding?: number; // Left padding for status line output + timeout?: number; // Execution timeout ms (default 5000, max 30000) + debounceMs?: number; // Debounce for event-driven refreshes (default 300) + refreshIntervalMs?: number; // Optional polling interval ms (opt-in) + disabled?: boolean; // Disable at this level +} + /** * Per-agent settings stored in a flat array. * baseUrl is omitted/undefined for Letta API (api.letta.com). @@ -49,6 +62,7 @@ export interface Settings { createDefaultAgents?: boolean; // Create Memo/Incognito default agents on startup (default: true) permissions?: PermissionRules; hooks?: HooksConfig; // Hook commands that run at various lifecycle points (includes disabled flag) + statusLine?: StatusLineConfig; // Configurable status line command env?: Record; // Server-indexed settings (agent IDs are server-specific) sessionsByServer?: Record; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283") @@ -74,6 +88,7 @@ export interface Settings { export interface ProjectSettings { localSharedBlockIds: Record; hooks?: HooksConfig; // Project-specific hook commands (checked in) + statusLine?: StatusLineConfig; // Project-specific status line command } export interface LocalProjectSettings { @@ -81,6 +96,7 @@ export interface LocalProjectSettings { lastSession?: SessionRef; // DEPRECATED: kept for backwards compat, use sessionsByServer permissions?: PermissionRules; hooks?: HooksConfig; // Project-specific hook commands + statusLine?: StatusLineConfig; // Local project-specific status line command profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer memoryReminderInterval?: number | null; // null = disabled, number = overrides global @@ -527,6 +543,7 @@ class SettingsManager { localSharedBlockIds: (rawSettings.localSharedBlockIds as Record) ?? {}, hooks: rawSettings.hooks as HooksConfig | undefined, + statusLine: rawSettings.statusLine as StatusLineConfig | undefined, }; this.projectSettings.set(workingDirectory, projectSettings); diff --git a/src/tests/cli/statusline-config.test.ts b/src/tests/cli/statusline-config.test.ts new file mode 100644 index 0000000..6a2757f --- /dev/null +++ b/src/tests/cli/statusline-config.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_STATUS_LINE_DEBOUNCE_MS, + DEFAULT_STATUS_LINE_TIMEOUT_MS, + isStatusLineDisabled, + MAX_STATUS_LINE_TIMEOUT_MS, + MIN_STATUS_LINE_DEBOUNCE_MS, + MIN_STATUS_LINE_INTERVAL_MS, + normalizeStatusLineConfig, + resolveStatusLineConfig, +} from "../../cli/helpers/statusLineConfig"; +import { settingsManager } from "../../settings-manager"; +import { setServiceName } from "../../utils/secrets.js"; + +const originalHome = process.env.HOME; +let testHomeDir: string; +let testProjectDir: string; + +beforeEach(async () => { + setServiceName("letta-code-test"); + await settingsManager.reset(); + testHomeDir = await mkdtemp(join(tmpdir(), "letta-sl-home-")); + testProjectDir = await mkdtemp(join(tmpdir(), "letta-sl-project-")); + process.env.HOME = testHomeDir; +}); + +afterEach(async () => { + await settingsManager.reset(); + process.env.HOME = originalHome; + await rm(testHomeDir, { recursive: true, force: true }).catch(() => {}); + await rm(testProjectDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe("normalizeStatusLineConfig", () => { + test("fills defaults for timeout/debounce and command type", () => { + const result = normalizeStatusLineConfig({ command: "echo hi" }); + expect(result.command).toBe("echo hi"); + expect(result.type).toBe("command"); + expect(result.timeout).toBe(DEFAULT_STATUS_LINE_TIMEOUT_MS); + expect(result.debounceMs).toBe(DEFAULT_STATUS_LINE_DEBOUNCE_MS); + expect(result.refreshIntervalMs).toBeUndefined(); + expect(result.padding).toBe(0); + }); + + test("respects explicit refreshIntervalMs", () => { + const result = normalizeStatusLineConfig({ + command: "echo hi", + refreshIntervalMs: 2500, + }); + expect(result.refreshIntervalMs).toBe(2500); + }); + + test("clamps timeout to maximum", () => { + const result = normalizeStatusLineConfig({ + command: "echo hi", + timeout: 999_999, + }); + expect(result.timeout).toBe(MAX_STATUS_LINE_TIMEOUT_MS); + }); + + test("clamps debounce minimum", () => { + const result = normalizeStatusLineConfig({ + command: "echo hi", + debounceMs: 1, + }); + expect(result.debounceMs).toBe(MIN_STATUS_LINE_DEBOUNCE_MS); + }); + + test("preserves disabled flag", () => { + const result = normalizeStatusLineConfig({ + command: "echo hi", + disabled: true, + }); + expect(result.disabled).toBe(true); + }); +}); + +describe("resolveStatusLineConfig", () => { + test("returns null when no config is defined", async () => { + await settingsManager.initialize(); + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir); + expect(resolveStatusLineConfig(testProjectDir)).toBeNull(); + }); + + test("returns global config when only global is set", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + statusLine: { command: "echo global" }, + }); + await settingsManager.flush(); + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir); + + const result = resolveStatusLineConfig(testProjectDir); + expect(result).not.toBeNull(); + expect(result?.command).toBe("echo global"); + }); + + test("local overrides project and global", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + statusLine: { command: "echo global" }, + }); + await settingsManager.loadProjectSettings(testProjectDir); + settingsManager.updateProjectSettings( + { statusLine: { command: "echo project" } }, + testProjectDir, + ); + await settingsManager.loadLocalProjectSettings(testProjectDir); + settingsManager.updateLocalProjectSettings( + { statusLine: { command: "echo local" } }, + testProjectDir, + ); + await settingsManager.flush(); + + const result = resolveStatusLineConfig(testProjectDir); + expect(result).not.toBeNull(); + expect(result?.command).toBe("echo local"); + }); + + test("returns null when disabled at user level", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + statusLine: { command: "echo global", disabled: true }, + }); + await settingsManager.flush(); + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir); + + expect(resolveStatusLineConfig(testProjectDir)).toBeNull(); + }); +}); + +describe("isStatusLineDisabled", () => { + test("returns false when no disabled flag is set", async () => { + await settingsManager.initialize(); + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir); + expect(isStatusLineDisabled(testProjectDir)).toBe(false); + }); + + test("returns true when user has disabled: true", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + statusLine: { command: "echo hi", disabled: true }, + }); + await settingsManager.flush(); + await settingsManager.loadProjectSettings(testProjectDir); + await settingsManager.loadLocalProjectSettings(testProjectDir); + expect(isStatusLineDisabled(testProjectDir)).toBe(true); + }); + + test("user disabled: false overrides project disabled: true", async () => { + await settingsManager.initialize(); + settingsManager.updateSettings({ + statusLine: { command: "echo hi", disabled: false }, + }); + await settingsManager.loadProjectSettings(testProjectDir); + settingsManager.updateProjectSettings( + { statusLine: { command: "echo proj", disabled: true } }, + testProjectDir, + ); + await settingsManager.loadLocalProjectSettings(testProjectDir); + await settingsManager.flush(); + expect(isStatusLineDisabled(testProjectDir)).toBe(false); + }); + + test("returns true when project has disabled: true (user undefined)", async () => { + await settingsManager.initialize(); + await settingsManager.loadProjectSettings(testProjectDir); + settingsManager.updateProjectSettings( + { statusLine: { command: "echo proj", disabled: true } }, + testProjectDir, + ); + await settingsManager.loadLocalProjectSettings(testProjectDir); + await settingsManager.flush(); + expect(isStatusLineDisabled(testProjectDir)).toBe(true); + }); +}); diff --git a/src/tests/cli/statusline-controller.test.ts b/src/tests/cli/statusline-controller.test.ts new file mode 100644 index 0000000..92a6c7f --- /dev/null +++ b/src/tests/cli/statusline-controller.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_STATUS_LINE_DEBOUNCE_MS, + normalizeStatusLineConfig, +} from "../../cli/helpers/statusLineConfig"; + +describe("statusline controller-related config", () => { + test("normalizes debounce and refresh interval defaults", () => { + const normalized = normalizeStatusLineConfig({ command: "echo hi" }); + expect(normalized.debounceMs).toBe(DEFAULT_STATUS_LINE_DEBOUNCE_MS); + expect(normalized.refreshIntervalMs).toBeUndefined(); + }); + + test("keeps explicit refreshIntervalMs", () => { + const normalized = normalizeStatusLineConfig({ + command: "echo hi", + refreshIntervalMs: 4500, + }); + expect(normalized.refreshIntervalMs).toBe(4500); + }); + + test("clamps padding and debounce", () => { + const normalized = normalizeStatusLineConfig({ + command: "echo hi", + padding: 999, + debounceMs: 10, + }); + expect(normalized.padding).toBe(16); + expect(normalized.debounceMs).toBe(50); + }); +}); diff --git a/src/tests/cli/statusline-help.test.ts b/src/tests/cli/statusline-help.test.ts new file mode 100644 index 0000000..3b0bcf4 --- /dev/null +++ b/src/tests/cli/statusline-help.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { formatStatusLineHelp } from "../../cli/helpers/statusLineHelp"; + +describe("statusLineHelp", () => { + test("includes configuration and input sections", () => { + const output = formatStatusLineHelp(); + + expect(output).toContain("/statusline help"); + expect(output).toContain("CONFIGURATION"); + expect(output).toContain("INPUT (via JSON stdin)"); + expect(output).toContain("model.display_name"); + expect(output).toContain("context_window.used_percentage"); + }); + + test("lists all fields without section separation", () => { + const output = formatStatusLineHelp(); + + // Native and derived fields both present in a single list + expect(output).toContain("cwd"); + expect(output).toContain("session_id"); + expect(output).toContain("context_window.remaining_percentage"); + expect(output).toContain("exceeds_200k_tokens"); + + // No native/derived subheadings + expect(output).not.toContain("\nnative\n"); + expect(output).not.toContain("\nderived\n"); + }); + + test("does not include effective config section", () => { + const output = formatStatusLineHelp(); + + expect(output).not.toContain("Effective config:"); + }); +}); diff --git a/src/tests/cli/statusline-payload.test.ts b/src/tests/cli/statusline-payload.test.ts new file mode 100644 index 0000000..f7084c3 --- /dev/null +++ b/src/tests/cli/statusline-payload.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + buildStatusLinePayload, + calculateContextPercentages, +} from "../../cli/helpers/statusLinePayload"; + +describe("statusLinePayload", () => { + test("builds payload with all fields", () => { + const payload = buildStatusLinePayload({ + modelId: "anthropic/claude-sonnet-4", + modelDisplayName: "Sonnet", + currentDirectory: "/repo", + projectDirectory: "/repo", + sessionId: "conv-123", + agentName: "Test Agent", + totalDurationMs: 10_000, + totalApiDurationMs: 3_000, + totalInputTokens: 1200, + totalOutputTokens: 450, + contextWindowSize: 200_000, + usedContextTokens: 40_000, + permissionMode: "default", + networkPhase: "download", + terminalWidth: 120, + }); + + expect(payload.cwd).toBe("/repo"); + expect(payload.workspace.current_dir).toBe("/repo"); + expect(payload.workspace.project_dir).toBe("/repo"); + expect(payload.model.id).toBe("anthropic/claude-sonnet-4"); + expect(payload.model.display_name).toBe("Sonnet"); + expect(payload.context_window.used_percentage).toBe(20); + expect(payload.context_window.remaining_percentage).toBe(80); + expect(payload.permission_mode).toBe("default"); + expect(payload.network_phase).toBe("download"); + expect(payload.terminal_width).toBe(120); + }); + + test("marks unsupported fields as null", () => { + const payload = buildStatusLinePayload({ + currentDirectory: "/repo", + projectDirectory: "/repo", + }); + + expect(payload.transcript_path).toBeNull(); + expect(payload.output_style.name).toBeNull(); + expect(payload.vim).toBeNull(); + expect(payload.cost.total_cost_usd).toBeNull(); + expect(payload.context_window.current_usage).toBeNull(); + }); + + test("calculates context percentages safely", () => { + expect(calculateContextPercentages(50, 200)).toEqual({ + used: 25, + remaining: 75, + }); + expect(calculateContextPercentages(500, 200)).toEqual({ + used: 100, + remaining: 0, + }); + }); +}); diff --git a/src/tests/cli/statusline-runtime.test.ts b/src/tests/cli/statusline-runtime.test.ts new file mode 100644 index 0000000..c30dcb1 --- /dev/null +++ b/src/tests/cli/statusline-runtime.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { executeStatusLineCommand } from "../../cli/helpers/statusLineRuntime"; + +const isWindows = process.platform === "win32"; + +describe.skipIf(isWindows)("executeStatusLineCommand", () => { + test("echo command returns stdout", async () => { + const result = await executeStatusLineCommand( + "echo hello", + {}, + { + timeout: 5000, + }, + ); + expect(result.ok).toBe(true); + expect(result.text).toBe("hello"); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + test("receives JSON payload on stdin", async () => { + // cat reads stdin and outputs it; we verify the command receives JSON + const result = await executeStatusLineCommand( + "cat", + { + agent_id: "test-agent", + streaming: false, + }, + { + timeout: 5000, + }, + ); + expect(result.ok).toBe(true); + const parsed = JSON.parse(result.text); + expect(parsed.agent_id).toBe("test-agent"); + expect(parsed.streaming).toBe(false); + }); + + test("non-zero exit code returns ok: false", async () => { + const result = await executeStatusLineCommand( + "exit 1", + {}, + { + timeout: 5000, + }, + ); + expect(result.ok).toBe(false); + expect(result.error).toContain("Exit code"); + }); + + test("command timeout", async () => { + const result = await executeStatusLineCommand( + "sleep 10", + {}, + { + timeout: 500, + }, + ); + expect(result.ok).toBe(false); + expect(result.error).toContain("timed out"); + }); + + test("AbortSignal cancellation", async () => { + const ac = new AbortController(); + const promise = executeStatusLineCommand( + "sleep 10", + {}, + { + timeout: 10000, + signal: ac.signal, + }, + ); + + // Abort after a short delay + setTimeout(() => ac.abort(), 100); + + const result = await promise; + expect(result.ok).toBe(false); + expect(result.error).toBe("Aborted"); + }); + + test("stdout is capped at 4KB", async () => { + // Generate 8KB of output (each 'x' char is ~1 byte) + const result = await executeStatusLineCommand( + "python3 -c \"print('x' * 8192)\"", + {}, + { timeout: 5000 }, + ); + expect(result.ok).toBe(true); + // Stdout should be truncated to approximately 4KB + expect(result.text.length).toBeLessThanOrEqual(4096); + }); + + test("empty command returns error", async () => { + const result = await executeStatusLineCommand( + "", + {}, + { + timeout: 5000, + }, + ); + expect(result.ok).toBe(false); + }); + + test("pre-aborted signal returns immediately", async () => { + const ac = new AbortController(); + ac.abort(); + const result = await executeStatusLineCommand( + "echo hi", + {}, + { + timeout: 5000, + signal: ac.signal, + }, + ); + expect(result.ok).toBe(false); + expect(result.error).toBe("Aborted"); + expect(result.durationMs).toBe(0); + }); +}); diff --git a/src/tests/cli/statusline-schema.test.ts b/src/tests/cli/statusline-schema.test.ts new file mode 100644 index 0000000..a44d48e --- /dev/null +++ b/src/tests/cli/statusline-schema.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { + STATUSLINE_DERIVED_FIELDS, + STATUSLINE_NATIVE_FIELDS, +} from "../../cli/helpers/statusLineSchema"; + +describe("statusLineSchema", () => { + test("contains native and derived fields", () => { + expect(STATUSLINE_NATIVE_FIELDS.length).toBeGreaterThan(0); + expect(STATUSLINE_DERIVED_FIELDS.length).toBeGreaterThan(0); + }); + + test("field paths are unique", () => { + const allPaths = [ + ...STATUSLINE_NATIVE_FIELDS, + ...STATUSLINE_DERIVED_FIELDS, + ].map((f) => f.path); + const unique = new Set(allPaths); + expect(unique.size).toBe(allPaths.length); + }); +});