feat: configurable status lines for CLI footer (#904)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
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