diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d7e86ed..e0b88a46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements. - RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text. - Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings. +- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window. ## 1.4.1 — 2025-12-04 diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts new file mode 100644 index 000000000..6c6245b73 --- /dev/null +++ b/src/commands/sessions.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Disable colors for deterministic snapshots. +process.env.FORCE_COLOR = "0"; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({ + inbound: { + reply: { + agent: { model: "pi:opus", contextTokens: 32000 }, + }, + }, + }), +})); + +import { sessionsCommand } from "./sessions.js"; + +const makeRuntime = () => { + const logs: string[] = []; + return { + runtime: { + log: (msg: unknown) => logs.push(String(msg)), + error: (msg: unknown) => { + throw new Error(String(msg)); + }, + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }, + logs, + } as const; +}; + +const writeStore = (data: unknown) => { + const file = path.join( + os.tmpdir(), + `sessions-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(file, JSON.stringify(data, null, 2)); + return file; +}; + +describe("sessionsCommand", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-06T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders a tabular view with token percentages", async () => { + const store = writeStore({ + "+15551234567": { + sessionId: "abc123", + updatedAt: Date.now() - 45 * 60_000, + inputTokens: 1200, + outputTokens: 800, + model: "pi:opus", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store }, runtime); + + fs.rmSync(store); + + const tableHeader = logs.find((line) => line.includes("Tokens (ctx %")); + expect(tableHeader).toBeTruthy(); + + const row = logs.find((line) => line.includes("+15551234567")) ?? ""; + expect(row).toContain("2.0k/32k (6%)"); + expect(row).toContain("45m ago"); + expect(row).toContain("pi:opus"); + }); + + it("shows placeholder rows when tokens are missing", async () => { + const store = writeStore({ + "group:demo": { + sessionId: "xyz", + updatedAt: Date.now() - 5 * 60_000, + thinkingLevel: "high", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store }, runtime); + + fs.rmSync(store); + + const row = logs.find((line) => line.includes("group:demo")) ?? ""; + expect(row).toContain("-".padEnd(20)); + expect(row).toContain("think:high"); + expect(row).toContain("5m ago"); + }); +}); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index aa8218f28..5f2a8669b 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,3 +1,5 @@ +import chalk from "chalk"; + import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { loadConfig } from "../config/config.js"; @@ -26,6 +28,76 @@ type SessionRow = { contextTokens?: number; }; +const KIND_PAD = 6; +const KEY_PAD = 26; +const AGE_PAD = 9; +const MODEL_PAD = 14; +const TOKENS_PAD = 20; + +const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); + +const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; + +const truncateKey = (key: string) => { + if (key.length <= KEY_PAD) return key; + const head = Math.max(4, KEY_PAD - 10); + return `${key.slice(0, head)}...${key.slice(-6)}`; +}; + +const colorByPct = (label: string, pct: number | null, rich: boolean) => { + if (!rich || pct === null) return label; + if (pct >= 95) return chalk.red(label); + if (pct >= 80) return chalk.yellow(label); + if (pct >= 60) return chalk.green(label); + return chalk.gray(label); +}; + +const formatTokensCell = ( + total: number, + contextTokens: number | null, + rich: boolean, +) => { + if (!total) return "-".padEnd(TOKENS_PAD); + const totalLabel = formatKTokens(total); + const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?"; + const pct = contextTokens ? Math.min(999, Math.round((total / contextTokens) * 100)) : null; + const label = `${totalLabel}/${ctxLabel} (${pct ?? "?"}%)`; + const padded = label.padEnd(TOKENS_PAD); + return colorByPct(padded, pct, rich); +}; + +const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { + const label = kind.padEnd(KIND_PAD); + if (!rich) return label; + if (kind === "group") return chalk.magenta(label); + if (kind === "global") return chalk.yellow(label); + if (kind === "direct") return chalk.cyan(label); + return chalk.gray(label); +}; + +const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => { + const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown"; + const padded = ageLabel.padEnd(AGE_PAD); + return rich ? chalk.gray(padded) : padded; +}; + +const formatModelCell = (model: string | null | undefined, rich: boolean) => { + const label = (model ?? "unknown").padEnd(MODEL_PAD); + return rich ? chalk.white(label) : label; +}; + +const formatFlagsCell = (row: SessionRow, rich: boolean) => { + const flags = [ + row.thinkingLevel ? `think:${row.thinkingLevel}` : null, + row.verboseLevel ? `verbose:${row.verboseLevel}` : null, + row.systemSent ? "system" : null, + row.abortedLastRun ? "aborted" : null, + row.sessionId ? `id:${row.sessionId}` : null, + ].filter(Boolean); + const label = flags.join(" "); + return label.length === 0 ? "" : rich ? chalk.gray(label) : label; +}; + const formatAge = (ms: number | null | undefined) => { if (!ms || ms < 0) return "unknown"; const minutes = Math.round(ms / 60_000); @@ -134,6 +206,18 @@ export async function sessionsCommand( return; } + const rich = isRich(); + const header = [ + "Kind".padEnd(KIND_PAD), + "Key".padEnd(KEY_PAD), + "Age".padEnd(AGE_PAD), + "Model".padEnd(MODEL_PAD), + "Tokens (ctx %)".padEnd(TOKENS_PAD), + "Flags", + ].join(" "); + + runtime.log(rich ? chalk.bold(header) : header); + for (const row of rows) { const model = row.model ?? configModel; const contextTokens = @@ -141,26 +225,19 @@ export async function sessionsCommand( const input = row.inputTokens ?? 0; const output = row.outputTokens ?? 0; const total = row.totalTokens ?? input + output; - const pct = contextTokens - ? `${Math.min(100, Math.round((total / contextTokens) * 100))}%` - : null; - const parts = [ - `${row.key} [${row.kind}]`, - row.updatedAt ? formatAge(Date.now() - row.updatedAt) : "age unknown", - ]; - if (row.sessionId) parts.push(`id ${row.sessionId}`); - if (row.thinkingLevel) parts.push(`think=${row.thinkingLevel}`); - if (row.verboseLevel) parts.push(`verbose=${row.verboseLevel}`); - if (row.systemSent) parts.push("systemSent"); - if (row.abortedLastRun) parts.push("aborted"); - if (total > 0) { - const tokenStr = `tokens in:${input} out:${output} total:${total}`; - parts.push( - contextTokens ? `${tokenStr} (${pct} of ${contextTokens})` : tokenStr, - ); - } - if (model) parts.push(`model=${model}`); - runtime.log(`- ${parts.join(" | ")}`); + const keyLabel = truncateKey(row.key).padEnd(KEY_PAD); + const keyCell = rich ? chalk.cyan(keyLabel) : keyLabel; + + const line = [ + formatKindCell(row.kind, rich), + keyCell, + formatAgeCell(row.updatedAt, rich), + formatModelCell(model, rich), + formatTokensCell(total, contextTokens ?? null, rich), + formatFlagsCell(row, rich), + ].join(" "); + + runtime.log(line.trimEnd()); } }