feat: configurable status lines for CLI footer (#904)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
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" },
|
||||
];
|
||||
Reference in New Issue
Block a user