CLI: compact sessions table output
parent
4ea2518e79
commit
e863fd78d6
|
|
@ -18,6 +18,7 @@
|
||||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
- 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.
|
- 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.
|
- 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
|
## 1.4.1 — 2025-12-04
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
|
@ -26,6 +28,76 @@ type SessionRow = {
|
||||||
contextTokens?: number;
|
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) => {
|
const formatAge = (ms: number | null | undefined) => {
|
||||||
if (!ms || ms < 0) return "unknown";
|
if (!ms || ms < 0) return "unknown";
|
||||||
const minutes = Math.round(ms / 60_000);
|
const minutes = Math.round(ms / 60_000);
|
||||||
|
|
@ -134,6 +206,18 @@ export async function sessionsCommand(
|
||||||
return;
|
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) {
|
for (const row of rows) {
|
||||||
const model = row.model ?? configModel;
|
const model = row.model ?? configModel;
|
||||||
const contextTokens =
|
const contextTokens =
|
||||||
|
|
@ -141,26 +225,19 @@ export async function sessionsCommand(
|
||||||
const input = row.inputTokens ?? 0;
|
const input = row.inputTokens ?? 0;
|
||||||
const output = row.outputTokens ?? 0;
|
const output = row.outputTokens ?? 0;
|
||||||
const total = row.totalTokens ?? input + output;
|
const total = row.totalTokens ?? input + output;
|
||||||
const pct = contextTokens
|
|
||||||
? `${Math.min(100, Math.round((total / contextTokens) * 100))}%`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const parts = [
|
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
|
||||||
`${row.key} [${row.kind}]`,
|
const keyCell = rich ? chalk.cyan(keyLabel) : keyLabel;
|
||||||
row.updatedAt ? formatAge(Date.now() - row.updatedAt) : "age unknown",
|
|
||||||
];
|
const line = [
|
||||||
if (row.sessionId) parts.push(`id ${row.sessionId}`);
|
formatKindCell(row.kind, rich),
|
||||||
if (row.thinkingLevel) parts.push(`think=${row.thinkingLevel}`);
|
keyCell,
|
||||||
if (row.verboseLevel) parts.push(`verbose=${row.verboseLevel}`);
|
formatAgeCell(row.updatedAt, rich),
|
||||||
if (row.systemSent) parts.push("systemSent");
|
formatModelCell(model, rich),
|
||||||
if (row.abortedLastRun) parts.push("aborted");
|
formatTokensCell(total, contextTokens ?? null, rich),
|
||||||
if (total > 0) {
|
formatFlagsCell(row, rich),
|
||||||
const tokenStr = `tokens in:${input} out:${output} total:${total}`;
|
].join(" ");
|
||||||
parts.push(
|
|
||||||
contextTokens ? `${tokenStr} (${pct} of ${contextTokens})` : tokenStr,
|
runtime.log(line.trimEnd());
|
||||||
);
|
|
||||||
}
|
|
||||||
if (model) parts.push(`model=${model}`);
|
|
||||||
runtime.log(`- ${parts.join(" | ")}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue