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

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