feat: configurable status lines for CLI footer (#904)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-02-11 17:35:34 -08:00
committed by GitHub
parent 74b369d1ca
commit c3a7f6c646
16 changed files with 1689 additions and 15 deletions

View File

@@ -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 <command> [-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}
/>
</Box>

View File

@@ -226,6 +226,15 @@ export const commands: Record<string, Command> = {
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,

View File

@@ -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(
<Text key={`${keyPrefix}-${lastIndex}`}>
{line.slice(lastIndex, match.index)}
</Text>,
);
}
const url = match[1] ?? "";
const display = match[2] ?? "";
parts.push(
<Link key={`${keyPrefix}-${match.index}`} url={url}>
<Text>{display}</Text>
</Link>,
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < line.length) {
parts.push(
<Text key={`${keyPrefix}-${lastIndex}`}>{line.slice(lastIndex)}</Text>,
);
}
if (parts.length === 0) {
parts.push(<Text key={keyPrefix}>{line}</Text>);
}
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 (
<Text wrap="wrap">
<Text>{parts}</Text>
{modeName && modeColor && (
<>
{"\n"}
<Text color={modeColor}> {modeName}</Text>
<Text color={modeColor} dimColor>
{" "}
(shift+tab to {showExitHint ? "exit" : "cycle"})
</Text>
</>
)}
</Text>
);
}
/**
* 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)
</Text>
</Text>
) : statusLineText ? (
<StatusLineContent
text={statusLineText}
padding={statusLinePadding ?? 0}
modeName={modeName}
modeColor={modeColor}
showExitHint={showExitHint}
/>
) : modeName && modeColor ? (
<Text>
<Text color={modeColor}> {modeName}</Text>
@@ -200,9 +295,26 @@ const InputFooter = memo(function InputFooter({
<Text dimColor>Press / for commands</Text>
)}
</Box>
<Box width={rightColumnWidth} flexShrink={0}>
<Box
flexDirection={
statusLineRight && !hideFooterContent ? "column" : undefined
}
alignItems={
statusLineRight && !hideFooterContent ? "flex-end" : undefined
}
width={
statusLineRight && !hideFooterContent ? undefined : rightColumnWidth
}
flexShrink={0}
>
{hideFooterContent ? (
<Text>{" ".repeat(rightColumnWidth)}</Text>
) : statusLineRight ? (
statusLineRight.split("\n").map((line) => (
<Text key={line} wrap="truncate-end">
{parseOsc8Line(line, line)}
</Text>
))
) : (
<Text>{rightLabel}</Text>
)}
@@ -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}
/>
</Box>
) : 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

View File

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

View File

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

View File

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

View File

@@ -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<StatusLineResult> {
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<StatusLineResult> {
return new Promise<StatusLineResult>((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,
});
});
});
}

View File

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

View File

@@ -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<string | null>(null);
const [padding, setPadding] = useState(0);
const inputsRef = useRef(inputs);
const configRef = useRef<NormalizedStatusLineConfig | null>(null);
const abortRef = useRef<AbortController | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}

View File

@@ -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<string, string>;
// Server-indexed settings (agent IDs are server-specific)
sessionsByServer?: Record<string, SessionRef>; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283")
@@ -74,6 +88,7 @@ export interface Settings {
export interface ProjectSettings {
localSharedBlockIds: Record<string, string>;
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<string, string>; // 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<string, string>) ?? {},
hooks: rawSettings.hooks as HooksConfig | undefined,
statusLine: rawSettings.statusLine as StatusLineConfig | undefined,
};
this.projectSettings.set(workingDirectory, projectSettings);

View File

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

View File

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

View File

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

View File

@@ -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,
});
});
});

View File

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

View File

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