feat: configurable status lines for CLI footer (#904)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
254
src/cli/App.tsx
254
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 <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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
166
src/cli/helpers/statusLineConfig.ts
Normal file
166
src/cli/helpers/statusLineConfig.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/cli/helpers/statusLineHelp.ts
Normal file
49
src/cli/helpers/statusLineHelp.ts
Normal 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");
|
||||
}
|
||||
156
src/cli/helpers/statusLinePayload.ts
Normal file
156
src/cli/helpers/statusLinePayload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
221
src/cli/helpers/statusLineRuntime.ts
Normal file
221
src/cli/helpers/statusLineRuntime.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
30
src/cli/helpers/statusLineSchema.ts
Normal file
30
src/cli/helpers/statusLineSchema.ts
Normal 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" },
|
||||
];
|
||||
226
src/cli/hooks/useConfigurableStatusLine.ts
Normal file
226
src/cli/hooks/useConfigurableStatusLine.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
183
src/tests/cli/statusline-config.test.ts
Normal file
183
src/tests/cli/statusline-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
src/tests/cli/statusline-controller.test.ts
Normal file
31
src/tests/cli/statusline-controller.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
src/tests/cli/statusline-help.test.ts
Normal file
34
src/tests/cli/statusline-help.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
62
src/tests/cli/statusline-payload.test.ts
Normal file
62
src/tests/cli/statusline-payload.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
119
src/tests/cli/statusline-runtime.test.ts
Normal file
119
src/tests/cli/statusline-runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/tests/cli/statusline-schema.test.ts
Normal file
21
src/tests/cli/statusline-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user