diff --git a/README.ko.md b/README.ko.md index ac66254..22d1cd1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -186,7 +186,9 @@ Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에 |---|---| | `/help` | 명령과 단축키 | | `/status` (`/cost`) | 연결, 설정, 세션 누적치 | -| `/config` | 유효한 설정과 각각의 출처, 설정 파일 | +| `/config` | 대시보드에서 설정 변경; `/config show`로 값과 출처 확인 | +| `/stats` | 로컬 세션 기록의 작업·도구 통계 | +| `/usage` | Infron 잔액, 기록된 토큰 사용량과 요청 비용 | | `/doctor` | 엔드포인트 점검: 인증, 파서, 캐시, 채널 | | `/login`, `/logout` | Infron API 키를 붙여 넣어 확인 후 `~/.motif/.env`에 저장; 저장된 키 삭제 | | `/model [id]`, `/endpoint [url]` | 다음 작업에 쓸 모델 id나 엔드포인트를 보거나 바꿈 | @@ -206,10 +208,20 @@ Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에 ``` enter send · \ + enter newline · esc interrupt or clear · ctrl-c twice quit · ctrl-d quit -↑ ↓ history · tab show or hide reasoning · ctrl-o full tool output · ctrl-l redraw · shift-tab permissions +↑ ↓ history · tab show or hide reasoning · ctrl-o output viewer · ctrl-l redraw · shift-tab permissions @ attach a file · ! run a shell line · # add a project note · / commands · ? hide this ``` +도구 결과는 긴 한 줄 JSON과 오류를 포함해 기본적으로 화면 3줄까지만 표시합니다. +`Ctrl+O`로 전체 출력 뷰어를 열고 ↑/↓, PageUp/PageDown, Home/End로 탐색합니다. +Esc, `q`, `Ctrl+O`로 닫으면 입력 중이던 문장과 스크롤 기록으로 돌아옵니다. +모델에 전달되는 도구 데이터는 이 표시 방식 때문에 잘리지 않습니다. +항상 전체 출력을 표시하려면 `--verbose` 또는 `/config verbose true`를 사용합니다. + +최초 로그인 때 입력한 키로 Infron 잔액이 자동 연결됩니다. Usage에서 `r`로 +새로고침할 수 있습니다. 계정 잔액과 로컬 기록의 요청 비용은 별도로 표시하며, +보고되지 않았거나 기록에 없는 비용을 추정해서 더하지 않습니다. + --- ## 기능 diff --git a/README.md b/README.md index 64b9c67..f75c2c4 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,9 @@ spawned — the agent's `bash` cannot see it, and neither can a project hook. |---|---| | `/help` | commands and keys | | `/status` (`/cost`) | connection, settings and session totals | -| `/config` | effective settings, where each came from, and the files | +| `/config` | edit settings in the dashboard; `/config show` lists values and sources | +| `/stats` | task and tool statistics from local session journals | +| `/usage` | Infron balance, recorded token usage and reported request costs | | `/doctor` | probe the endpoint: auth, parsers, cache, channels | | `/login`, `/logout` | paste an Infron API key, checked and saved to `~/.motif/.env`; remove the saved key | | `/model [id]`, `/endpoint [url]` | show or set the model id or endpoint for the next task | @@ -220,10 +222,20 @@ spawned — the agent's `bash` cannot see it, and neither can a project hook. ``` enter send · \ + enter newline · esc interrupt or clear · ctrl-c twice quit · ctrl-d quit -↑ ↓ history · tab show or hide reasoning · ctrl-o full tool output · ctrl-l redraw · shift-tab permissions +↑ ↓ history · tab show or hide reasoning · ctrl-o output viewer · ctrl-l redraw · shift-tab permissions @ attach a file · ! run a shell line · # add a project note · / commands · ? hide this ``` +Tool results show a three-row preview by default, including long single-line +JSON and errors. `Ctrl+O` opens the full output in a read-only transcript viewer. +Use ↑/↓, PageUp/PageDown or Home/End to browse, then Esc, `q` or `Ctrl+O` to return +to the same draft and scrollback. This does not truncate the model's tool data. +`--verbose` or `/config verbose true` explicitly keeps full inline output. + +The Infron balance connects automatically using the key entered at first login. +Press `r` in Usage to refresh. The account balance and costs recorded in local +journals are separate: unreported or unrecorded charges are not estimated. + --- ## Features diff --git a/packages/agents/src/builtin.ts b/packages/agents/src/builtin.ts index 4210c04..492af15 100644 --- a/packages/agents/src/builtin.ts +++ b/packages/agents/src/builtin.ts @@ -23,7 +23,12 @@ export const BUILTIN_AGENTS: readonly AgentDef[] = Object.freeze([ readOnly: true, description: "Read-only reconnaissance of unfamiliar code — returns a map, changes nothing", toolCount: 3, // done, bash, read - maxTurns: 20, + // Motif-3 spends a turn per call here. Mapping a 200-file repository, 3 of + // 11 runs hit a ceiling of 20 before calling `done`, and the parent got + // nothing back; the runs that finished used 9 to 19. This is headroom for + // those, not a cure: a child that never proposes `done` runs out at any + // ceiling, as two of seven did again at 40. + maxTurns: 40, source: "builtin", instructions: [ "You explore and report. You cannot modify anything, and you should not try.", diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 972bb47..bad6182 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -51,6 +51,23 @@ export interface ResolvedAgent extends AgentDef { tools: Tool[]; } +/** + * What the parent does with what comes back. + * + * Motif-3 treats a subagent's summary as a lead to follow up rather than as + * the result: in each of 11 delegated runs the parent went on with 1 to 16 + * more calls, mostly `ls`, `package.json` and the README the child had just + * read. Once, told that a child had hit its turn limit, it reported the + * child's work as completed. This states the contract; on its own it did not + * measurably shorten the follow-up in a rerun. + */ +const AFTER_DELEGATING = [ + "A subagent's summary is its result: build on it rather than redoing the work it", + "reports, and check a claim yourself only when your next step depends on it. A", + "subagent that did not finish has not done the work; say so rather than reporting", + "it as done.", +].join("\n"); + export class AgentRegistry { private readonly agents = new Map(); @@ -73,11 +90,11 @@ export class AgentRegistry { return [...this.agents.values()].sort((a, b) => a.name.localeCompare(b.name)); } - /** One line each, for the system prompt. */ + /** One line each, for the system prompt, then what to do with the result. */ index(): string { const rows = this.list().map((a) => ` ${a.name} — ${a.description}`); if (rows.length === 0) return ""; - return ["# Subagents", "", "Delegate with the `task` tool:", ...rows].join("\n"); + return ["# Subagents", "", "Delegate with the `task` tool:", ...rows, "", AFTER_DELEGATING].join("\n"); } } @@ -114,10 +131,16 @@ export class AgentScheduler { return this.queue; } + /** + * `failed` classifies a result that came back rather than being thrown. A + * child that hits its turn limit returns normally, with `ok: false`, and + * without it the queue showed that child as done. + */ async submit( agent: string, prompt: string, run: (agent: string, prompt: string) => Promise, + failed?: (result: T) => boolean, ): Promise { const entry: QueueEntry = { agent, prompt, state: "queued" }; this.queue.push(entry); @@ -132,7 +155,7 @@ export class AgentScheduler { this.onChange?.(entry); try { const result = await run(agent, prompt); - entry.state = "done"; + entry.state = failed?.(result) ? "failed" : "done"; entry.result = describe(result); this.onChange?.(entry); return result; diff --git a/packages/agents/test/agents.test.ts b/packages/agents/test/agents.test.ts index 92ef811..4380612 100644 --- a/packages/agents/test/agents.test.ts +++ b/packages/agents/test/agents.test.ts @@ -57,6 +57,27 @@ describe("built-in agents", () => { it("bounds every agent's turns", () => { for (const def of reg.list()) expect(def.maxTurns, def.name).toBeGreaterThan(0); }); + + it("gives the explorer room to map a repository", () => { + // At 20, three of eleven live runs ran out before calling `done` and the + // parent got nothing back; the ones that finished used up to 19. + expect(reg.get("explorer")!.maxTurns).toBeGreaterThanOrEqual(40); + }); +}); + +describe("the index the parent sees", () => { + it("lists each agent and says what to do with what comes back", () => { + const index = reg.index(); + for (const def of reg.list()) expect(index).toContain(` ${def.name} — ${def.description}`); + // The parent went back over a finished child's ground after every + // delegation, and once reported an unfinished child as done. + expect(index).toContain("summary is its result: build on it rather than redoing"); + expect(index).toContain("did not finish has not done the work"); + }); + + it("is empty when there is no one to delegate to", () => { + expect(new AgentRegistry().index()).toBe(""); + }); }); describe("scheduling", () => { @@ -97,6 +118,17 @@ describe("scheduling", () => { expect(states).toEqual(["queued", "running", "done"]); }); + it("marks a result the caller calls a failure as failed, and still returns it", async () => { + // A child that hits its turn limit comes back with `ok: false` instead of + // throwing, and the queue used to show it as done. + const states: string[] = []; + const s = new AgentScheduler(1, (e) => states.push(e.state)); + const out = await s.submit("explorer", "x", async () => ({ ok: false }), (r) => !r.ok); + expect(out).toEqual({ ok: false }); + expect(states).toEqual(["queued", "running", "failed"]); + expect(s.pending).toHaveLength(0); + }); + it("marks a failure and rethrows", async () => { const states: string[] = []; const s = new AgentScheduler(1, (e) => states.push(e.state)); diff --git a/packages/cli/src/billing-view.ts b/packages/cli/src/billing-view.ts new file mode 100644 index 0000000..317efa7 --- /dev/null +++ b/packages/cli/src/billing-view.ts @@ -0,0 +1,21 @@ +import type { BalanceState } from "./billing.js"; +import type { InsightRow } from "./insights.js"; + +export function balanceRows(state: BalanceState): InsightRow[] { + if (state.kind === "unsupported") return [{ label: "Account balance", value: "Not supported for this endpoint", detail: "Automatic account balance is available for the official Infron endpoint. Local recorded usage is shown below.", tone: "muted" }]; + if (state.kind === "signed-out") return [{ label: "Infron balance", value: "Sign in with /login", detail: "Uses the same Infron API key as your model requests.", tone: "muted" }]; + if (state.kind === "idle") return [{ label: "Infron balance", value: "Not checked yet", detail: "Press r to fetch the account balance.", tone: "muted" }]; + const snapshot = state.kind === "ready" ? state : state.previous; + const amount = snapshot?.credits.toLocaleString("en-US", { maximumFractionDigits: 8 }); + const checked = snapshot ? new Date(snapshot.checkedAt).toLocaleString("en-US", { hour12: false }) : undefined; + const rows: InsightRow[] = [{ + label: "Infron balance", + value: snapshot ? `${amount} credits${state.kind === "ready" ? "" : " (last known)"}` : state.kind === "loading" ? "Checking…" : "Could not refresh", + detail: "Account-wide remaining credit, separate from local recorded task costs. Uses your existing Infron API key.", + tone: state.kind === "error" || (snapshot && snapshot.credits < 0) ? "warn" : "normal", + }]; + if (checked) rows.push({ label: "Balance checked", value: checked, detail: "Time of the last successful balance query; press r to refresh.", tone: "muted" }); + if (state.kind === "loading") rows.push({ label: "Balance status", value: "Refreshing…", detail: "Tasks can continue while the balance is fetched.", tone: "muted" }); + if (state.kind === "error") rows.push({ label: "Balance status", value: state.message, detail: "The last known balance, if shown, is stale. Press r to retry; a balance error does not block model requests.", tone: "warn" }); + return rows; +} diff --git a/packages/cli/src/billing.ts b/packages/cli/src/billing.ts new file mode 100644 index 0000000..7355434 --- /dev/null +++ b/packages/cli/src/billing.ts @@ -0,0 +1,185 @@ +/** Opt-in-by-endpoint account balance, using the session's existing Infron key. */ +import { isInfronEndpoint } from "@motifcode/core"; + +export interface BalanceSnapshot { + credits: number; + checkedAt: number; +} + +export type BalanceState = + | { kind: "unsupported" } + | { kind: "signed-out" } + | { kind: "idle" } + | { kind: "loading"; previous?: BalanceSnapshot } + | ({ kind: "ready" } & BalanceSnapshot) + | { kind: "error"; message: string; previous?: BalanceSnapshot }; + +export interface InfronBillingOptions { + fetchImpl?: typeof fetch; + now?: () => number; + onChange?: () => void; + timeoutMs?: number; + ttlMs?: number; + retryDelayMs?: number; +} + +const BALANCE_URL = "https://api.onerouter.pro/v1/balance"; +const MAX_RESPONSE_BYTES = 16 * 1024; +class BillingFailure extends Error {} + +/** Never store or display server error bodies, account names, or credentials. */ +export class InfronBilling { + private current: BalanceState = { kind: "unsupported" }; + private snapshot: BalanceSnapshot | undefined; + private endpoint = ""; + private apiKey: string | undefined; + private generation = 0; + private lastAttempt = -Infinity; + private closed = false; + private inFlight: { generation: number; controller: AbortController; promise: Promise } | undefined; + private readonly fetchImpl: typeof fetch; + private readonly now: () => number; + private readonly timeoutMs: number; + private readonly ttlMs: number; + private readonly retryDelayMs: number; + + constructor(private readonly opts: InfronBillingOptions = {}) { + this.fetchImpl = opts.fetchImpl ?? fetch; + this.now = opts.now ?? Date.now; + this.timeoutMs = opts.timeoutMs ?? 5_000; + this.ttlMs = opts.ttlMs ?? 60_000; + this.retryDelayMs = opts.retryDelayMs ?? 5_000; + } + + get state(): BalanceState { return this.current; } + + configure(endpoint: string, apiKey?: string): void { + if (this.closed || (endpoint === this.endpoint && apiKey === this.apiKey)) return; + this.generation++; + this.inFlight?.controller.abort(); + this.inFlight = undefined; + this.endpoint = endpoint; + this.apiKey = apiKey; + this.snapshot = undefined; + this.lastAttempt = -Infinity; + this.update(!isInfronEndpoint(endpoint) ? { kind: "unsupported" } + : !apiKey?.trim() ? { kind: "signed-out" } : { kind: "idle" }); + } + + /** Coalesced and non-throwing. Forced refresh still has a short request cooldown. */ + refresh(force = false): Promise { + if (this.closed || !isInfronEndpoint(this.endpoint) || !this.apiKey?.trim()) return Promise.resolve(); + if (this.inFlight) return this.inFlight.promise; + const now = this.now(); + if (now - this.lastAttempt < this.retryDelayMs || (!force && this.snapshot && now - this.snapshot.checkedAt < this.ttlMs)) return Promise.resolve(); + const generation = this.generation; + const controller = new AbortController(); + const apiKey = this.apiKey; + this.lastAttempt = now; + // Defer work so callbacks cannot start a second request before it is registered. + const promise = Promise.resolve().then(async () => { + if (generation !== this.generation || this.closed) return; + let timeout: ReturnType | undefined; + let timedOut = false; + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(new BillingFailure(timedOut ? "Balance request timed out." : "Balance request was cancelled.")); + controller.signal.addEventListener("abort", onAbort, { once: true }); + if (controller.signal.aborted) onAbort(); + }); + try { + timeout = setTimeout(() => { timedOut = true; controller.abort(); }, this.timeoutMs); + timeout.unref?.(); + const credits = await Promise.race([this.request(apiKey, controller.signal), aborted]); + if (generation !== this.generation || this.closed) return; + this.snapshot = { credits, checkedAt: this.now() }; + this.update({ kind: "ready", ...this.snapshot }); + } catch (error) { + if (generation !== this.generation || this.closed) return; + const message = timedOut ? "Balance request timed out." + : error instanceof BillingFailure ? error.message : "Could not reach the balance service."; + this.update({ kind: "error", message, ...(this.snapshot ? { previous: this.snapshot } : {}) }); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (onAbort) controller.signal.removeEventListener("abort", onAbort); + if (this.inFlight?.generation === generation) this.inFlight = undefined; + } + }); + this.inFlight = { generation, controller, promise }; + this.update({ kind: "loading", ...(this.snapshot ? { previous: this.snapshot } : {}) }); + return promise; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.generation++; + this.inFlight?.controller.abort(); + this.inFlight = undefined; + this.apiKey = undefined; + this.endpoint = ""; + this.snapshot = undefined; + this.update({ kind: "unsupported" }); + } + + private update(state: BalanceState): void { + this.current = state; + // A renderer failure cannot break a task or leave a request unhandled. + try { this.opts.onChange?.(); } catch { /* Presentation is owned by the caller. */ } + } + + private async request(apiKey: string, signal: AbortSignal): Promise { + const response = await this.fetchImpl(BALANCE_URL, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + redirect: "error", + signal, + }); + if (signal.aborted) { + void response.body?.cancel().catch(() => {}); + throw new BillingFailure("Balance request was cancelled."); + } + if (response.redirected || !response.ok) { + void response.body?.cancel().catch(() => {}); + throw new BillingFailure(response.redirected ? "Balance service redirected unexpectedly." + : `Balance service returned HTTP ${response.status}.`); + } + const declaredSize = Number(response.headers.get("content-length")); + if (declaredSize > MAX_RESPONSE_BYTES || !response.body) { + void response.body?.cancel().catch(() => {}); + throw new BillingFailure("Balance service returned an invalid response."); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + const cancel = (): void => { void reader.cancel().catch(() => {}); }; + signal.addEventListener("abort", cancel, { once: true }); + try { + while (true) { + const chunk = await reader.read(); + if (signal.aborted) throw new BillingFailure("Balance request was cancelled."); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) { + cancel(); + throw new BillingFailure("Balance service returned an invalid response."); + } + chunks.push(chunk.value); + } + } finally { + signal.removeEventListener("abort", cancel); + reader.releaseLock(); + } + let parsed: unknown; + try { + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { throw new BillingFailure("Balance service returned an invalid response."); } + const credits = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record)["credit_balance"] : undefined; + if (typeof credits !== "number" || !Number.isFinite(credits)) throw new BillingFailure("Balance service returned an invalid response."); + return credits; + } +} diff --git a/packages/cli/src/chat.ts b/packages/cli/src/chat.ts index dd56cfb..5ef36b2 100644 --- a/packages/cli/src/chat.ts +++ b/packages/cli/src/chat.ts @@ -58,12 +58,17 @@ import { type ComposerView, type Key, type MenuItem, + type PanelView, } from "@motifcode/tui"; import { expandMentions, forgetFiles, listFiles, matchFiles } from "./files.js"; import { installCommand } from "./install.js"; import { loginLines, normaliseKeyInput, verifyApiKey, type VerifyResult } from "./login.js"; -import { COMMANDS, findCommand, parseSlash, runSlash, type ChatSettings, type CommandContext, type PersistableKey } from "./commands.js"; -import type { LoadedSettings } from "./settings.js"; +import { COMMANDS, findCommand, parseSlash, runSlash, type ChatSettings, type CommandContext, type PersistableKey, type CommandOutput } from "./commands.js"; +import type { LoadedSettings, StoredSettings } from "./settings.js"; +import { CONFIG_FIELDS, configField, parseConfigValue, type ConfigField } from "./config-controls.js"; +import { collectInsights, statsRows, usageRows } from "./insights.js"; +import { InfronBilling } from "./billing.js"; +import { balanceRows } from "./billing-view.js"; import { doctor, formatChecks } from "./doctor.js"; import { ToolExecutor } from "./executor.js"; import { policyForAgent } from "./policy.js"; @@ -92,6 +97,8 @@ export interface ChatOptions { now?: () => number; /** What the settings files said, for `/config`. */ settingsInfo?: LoadedSettings; + /** Effective startup origins including flags and environment overrides. */ + settingSources?: Partial>; /** Writes one setting to the person's file; returns its path, or null when not persisted. */ persist?: (key: PersistableKey, value: unknown) => string | null; /** Where the composer's history is kept between sessions. */ @@ -114,6 +121,8 @@ export interface ChatOptions { envPath?: string; /** Injected for tests; defaults to a one-token completion with the key. */ verifyKey?: (apiKey: string) => Promise; + /** Injected balance transport for deterministic account integration tests. */ + balanceFetch?: typeof fetch; /** * Offer to install the `motif` command before the first prompt. Set when * this run came from `npx` and no `motif` is on the PATH. @@ -141,6 +150,9 @@ interface Totals { lastContext: number; } +type DashboardTab = "config" | "status" | "stats" | "usage"; +const DASHBOARD_TABS: readonly DashboardTab[] = ["config", "status", "stats", "usage"]; + const PLACEHOLDER = "type a task, / for commands"; /** Empty: the screen shows `? for shortcuts` in its place. */ const IDLE_HINT = ""; @@ -176,6 +188,10 @@ export class Chat { private readonly screen: Screen; private readonly composer = new Composer(); private readonly settings: ChatSettings; + private panel: { tab: DashboardTab; selected: number; offset: number; message?: string; error?: boolean; editing?: ConfigField } | null = null; + private readonly configEditor = new Composer(); + private readonly changedSettings = new Map(); + private insightRows: PanelView["rows"] = []; private menuSelected = 0; private menuFilter = ""; private history: Message[] = []; @@ -185,6 +201,7 @@ export class Chat { private shellSequence = 0; /** A `!command` in flight; tasks sent meanwhile wait behind it. */ private shellBusy = false; + private compacting = false; /** A tool call waiting for the person's yes or no, and which of the three answers is selected. */ private pendingConfirm: { call: ToolInvocation; resolve: (v: "allow" | "deny") => void; selected: number } | null = null; /** A question with numbered answers in place of the prompt, and which one is selected. */ @@ -194,6 +211,10 @@ export class Chat { /** The credential for this session. Starts as the caller's; `/login` replaces it, `/logout` drops it. */ private apiKey: string | undefined; private apiKeySource: string | undefined; + private apiKeyVerified = false; + private authGeneration = 0; + private verifyingLogin: number | null = null; + private readonly billing: InfronBilling; /** Tools the person allowed for the rest of the session with `a`. */ private readonly alwaysAllowed = new Set(); private active: ActiveTask | null = null; @@ -224,8 +245,14 @@ export class Chat { this.apiKey = opts.apiKey; this.apiKeySource = opts.apiKeySource; this.now = opts.now ?? (() => Date.now()); + this.billing = new InfronBilling({ + fetchImpl: opts.balanceFetch, + now: this.now, + onChange: () => { if (!this.quitting && this.panel?.tab === "usage") this.refresh(); }, + }); + this.billing.configure(this.settings.endpoint, this.apiKey); this.scheduler = new AgentScheduler(concurrencyFor(this.settings.endpoint), (entry) => - this.screen.apply({ type: "queue", agent: entry.agent, state: entry.state === "failed" ? "done" : entry.state }), + this.screen.apply({ type: "queue", agent: entry.agent, state: entry.state }), ); this.executor = this.makeExecutor(this.settings.cwd); if (opts.historyPath) this.composer.seedHistory(readHistory(opts.historyPath)); @@ -281,8 +308,11 @@ export class Chat { /** What happens before the first prompt is free: the login, the resumed conversation, the first task. */ private async start(): Promise { + void this.billing.refresh(); if (this.needsLogin) { - this.screen.append({ kind: "system", title: "login", lines: await this.login("startup") }); + const lines = await this.login("startup"); + if (this.quitting) return; + this.screen.append({ kind: "system", title: "login", lines }); this.refresh(); } if (this.opts.offerInstall) await this.offerInstall(); @@ -304,6 +334,7 @@ export class Chat { /* ---------------------------------------------------------------- */ private onKey(key: Key): void { + if (this.screen.handleOutputViewKey(key)) return; if (this.pendingSecret) { this.answerSecret(key); return; @@ -316,11 +347,15 @@ export class Chat { this.answerConfirm(key); return; } + if (this.panel) { + this.panelKey(key); + return; + } const menu = this.menuItems(); switch (key.type) { case "shift-tab": this.settings.permissions = this.settings.permissions === "ask" ? "auto" : "ask"; - this.opts.persist?.("permissions", this.settings.permissions); + this.rememberSetting("permissions", this.settings.permissions); this.screen.append({ kind: "notice", level: "info", @@ -365,6 +400,7 @@ export class Chat { this.composer.insert(`/${item.name}${item.usage ? " " : ""}`); } else if (this.composer.empty) { this.screen.toggleThinking(); + this.changedSettings.set("thinking", "session only"); } break; case "escape": @@ -443,7 +479,7 @@ export class Chat { this.composer.killToEnd(); break; case "o": - this.screen.toggleVerbose(); + this.screen.toggleOutputView(); break; case "l": this.screen.redraw(); @@ -741,6 +777,9 @@ export class Chat { * to open at all. */ private async login(reason: "startup" | "task" | "command"): Promise { + this.invalidateAuthentication(); + const generation = this.authGeneration; + const endpoint = this.settings.endpoint; const title = reason === "command" ? "Paste your Infron API key" : reason === "task" ? "An API key is needed before the task can run" : "Paste your Infron API key to get started"; const cancelHint = reason === "command" ? "enter to check and save · esc to cancel" : "enter to check and save · esc to skip for now"; @@ -753,18 +792,27 @@ export class Chat { } const key = normaliseKeyInput(raw); if (key === "") continue; + this.verifyingLogin = generation; this.screen.setActivity("Checking the key…"); let result: VerifyResult; try { result = await (this.opts.verifyKey ?? ((k: string) => verifyApiKey({ endpoint: this.settings.endpoint, model: this.settings.model, apiKey: k })))(key); } finally { - this.screen.setActivity(null); + if (this.verifyingLogin === generation) { + this.verifyingLogin = null; + if (!this.quitting) this.screen.setActivity(null); + } + } + if (this.quitting || generation !== this.authGeneration || endpoint !== this.settings.endpoint) { + return ["login cancelled because the session or endpoint changed; /login to try again"]; } if (!result.ok) { this.screen.append({ kind: "notice", level: "error", text: `${result.reason} — try again, or press esc` }); continue; } this.apiKey = key; + this.apiKeyVerified = true; + this.syncBilling(); try { const path = saveApiKey(key, this.envPath); this.apiKeySource = `${path} (MOTIF_API_KEY)`; @@ -777,11 +825,14 @@ export class Chat { } private logout(): string[] { + this.invalidateAuthentication(); const source = this.apiKeySource; const had = this.apiKey !== undefined; const removed = forgetApiKey(this.envPath); this.apiKey = undefined; this.apiKeySource = undefined; + this.apiKeyVerified = false; + this.billing.configure(this.settings.endpoint); if (!had && !removed) return ["no key is set"]; const lines = removed ? [`the key was removed from ${this.envPath}`] : [`this session's key came from ${source ?? "the caller"}, which was left as it is`]; lines.push("the session no longer sends a key; the next task asks for one, or /login now"); @@ -802,16 +853,28 @@ export class Chat { /** Repaint the composer, the menu and the hint from current state. */ private refresh(): void { + // Receiving prose is not the end of a task. Keep the live indicator on + // through streaming and tool execution, but pause while a person decides. + this.screen.setWorking(Boolean(this.active) && !this.pendingConfirm && !this.pendingChoice && !this.pendingSecret); const items = this.menuItems(); + // Use the same registry as submission, including aliases and skills. Derive + // the range from the draft so typing, Tab, history and edits stay in sync. + const command = /^(\s*)(\/\S+)/u.exec(this.composer.text); + const name = command?.[2]?.slice(1); + const start = [...(command?.[1] ?? "")].length; const view: ComposerView = { draft: this.composer.snapshot(), + ...(name && (findCommand(name) || this.opts.skills.get(name)) + ? { commandRange: { start, end: start + [...command![2]!].length } } + : {}), placeholder: PLACEHOLDER, ...(this.pendingConfirm ? { confirm: this.confirmView(this.pendingConfirm.call, this.pendingConfirm.selected) } : {}), ...(this.pendingChoice ? { confirm: { title: this.pendingChoice.title, lines: this.pendingChoice.lines, choices: this.pendingChoice.options.map((o, i) => `${i === this.pendingChoice!.selected ? "❯" : " "} ${o}`) } } : {}), ...(this.pendingSecret ? { secret: { title: this.pendingSecret.title, lines: this.pendingSecret.lines, prompt: this.pendingSecret.prompt } } : {}), - ...(items.length > 0 && !this.pendingConfirm && !this.pendingSecret && !this.pendingChoice + ...(this.panel ? { panel: this.panelView() } : {}), + ...(items.length > 0 && !this.panel && !this.pendingConfirm && !this.pendingSecret && !this.pendingChoice ? { menu: { items, selected: clampSelection(this.menuSelected, items.length), prefix: this.mentionOpen() ? "@" : "/" } } : {}), }; @@ -877,8 +940,12 @@ export class Chat { this.refresh(); return; } + const previousEndpoint = this.settings.endpoint; const out = await runSlash(text, this.context()); - if (!this.quitting) { + if (previousEndpoint !== this.settings.endpoint) { this.apiKeyVerified = false; this.invalidateAuthentication(); } + // /endpoint and /login share the same credential-bound billing state. + this.syncBilling(); + if (!this.quitting && !out.silent) { this.screen.append( out.error ? { kind: "notice", level: "error", text: out.lines.join(" ") } @@ -1108,9 +1175,12 @@ export class Chat { this.screen.append({ kind: "notice", level: "error", text: err instanceof Error ? err.message : String(err) }); } finally { this.active = null; + this.screen.setWorking(false); this.screen.setActivity(null); this.screen.setTitle(`motif · ${this.settings.cwd.split("/").pop() ?? this.settings.cwd}`); this.totals.tasks += 1; + this.reloadInsights(); + void this.billing.refresh(true); forgetFiles(); this.refresh(); } @@ -1180,9 +1250,11 @@ export class Chat { * person's messages stay verbatim, the rest becomes a handoff. */ private async compactNow(focus = ""): Promise { + if (this.compacting) throw new Error("A compaction is already in progress."); if (this.history.length === 0) return ["nothing to compact; the conversation is empty"]; const before = this.totals.lastContext; const transport = (this.opts.makeTransport ?? defaultTransport)(this.settings, this.apiKey); + this.compacting = true; this.screen.setActivity("Compacting the conversation…"); try { const summary = await summarizeTranscript({ @@ -1200,7 +1272,9 @@ export class Chat { } catch (err) { throw new Error(`compaction failed: ${err instanceof Error ? err.message : String(err)}`); } finally { + this.compacting = false; this.screen.setActivity(null); + if (!this.quitting) this.refresh(); } } @@ -1216,6 +1290,8 @@ export class Chat { } private finish(): void { + this.invalidateAuthentication(); + this.billing.close(); this.executor.close(); this.screen.finish(); this.finished?.(0); @@ -1341,7 +1417,7 @@ export class Chat { } finally { childExecutor.close(); } - }); + }, (out) => !out.ok); }, }); } @@ -1355,6 +1431,10 @@ export class Chat { settings: this.settings, status: () => this.statusLines(), config: () => this.configLines(), + openPanel: (tab) => this.openPanel(tab), + configure: (args) => this.configure(args), + stats: () => statsRows(this.insights()).flatMap((r) => [`${r.label} ${r.value ?? ""}`, ...(r.detail ? [` ${r.detail}`] : [])]), + usage: () => [...balanceRows(this.billing.state), ...usageRows(this.insights())].flatMap((r) => [`${r.label} ${r.value ?? ""}`, ...(r.detail ? [` ${r.detail}`] : [])]), themes: () => Object.entries(THEMES).map(([name, t]) => `${name.padEnd(10)} ${t.description}`), setTheme: (name) => { if (!applyTheme(name)) return [`no theme named ${name}; /theme lists them`]; @@ -1369,7 +1449,7 @@ export class Chat { return [path, "", ...readFileSync(path, "utf8").replace(/\s+$/, "").split("\n")]; }, hooks: () => this.opts.hookLines ?? ["no hooks"], - persist: (key, value) => (this.opts.persist ? this.opts.persist(key, value) : null), + persist: (key, value) => this.rememberSetting(key, value), login: () => this.login("command"), logout: () => this.logout(), doctor: async () => @@ -1411,10 +1491,217 @@ export class Chat { }; } + private settingSource(key: keyof StoredSettings): string { + return this.changedSettings.get(key) ?? this.opts.settingSources?.[key] ?? this.opts.settingsInfo?.sources[key] ?? "default"; + } + + private rememberSetting(key: PersistableKey, value: unknown): string | null { + const path = this.opts.persist?.(key, value) ?? null; + this.changedSettings.set(key, path ? "session + user file" : "session only"); + return path; + } + + private configValue(key: keyof StoredSettings): string { + if (key === "thinking") return String(this.screen.thinkingShown); + if (key === "verbose") return String(this.screen.verboseOutput); + return String(this.settings[key] ?? "off"); + } + + private configure(args: string): CommandOutput { + const match = /^(\S+)\s+([\s\S]+)$/.exec(args.trim()); + const field = match ? configField(match[1]!) : undefined; + if (!field) return { title: "/config", error: true, lines: [`Use /config . Keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}`] }; + return this.applyConfig(field, match![2]!); + } + + private applyConfig(field: ConfigField, raw: string): CommandOutput { + const fail = (message: string): CommandOutput => ({ title: "/config", error: true, lines: [message] }); + // A running loop captured its budgets/transport at task start. Avoid a UI + // that claims a new value controls that in-flight request. + if ((this.active || this.shellBusy || this.compacting) && !["thinking", "verbose", "theme"].includes(field.key)) { + return fail("Wait for the running task to finish, or close this panel and press Esc to interrupt it."); + } + let value: ReturnType; + let path: string | null; + try { + value = parseConfigValue(field, raw); + // Save first: a failed write must not partially apply the new setting. + path = this.rememberSetting(field.key, value); + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } + switch (field.key) { + case "thinking": + if (this.screen.thinkingShown !== value) this.screen.toggleThinking(); + break; + case "verbose": + if (this.screen.verboseOutput !== value) this.screen.toggleVerbose(); + break; + case "theme": + applyTheme(value as string); + this.settings.theme = value as string; + break; + case "channel": + if (this.settings.channel !== value) { + this.history = []; + this.tasks = []; + this.totals.lastContext = 0; + this.settings.channel = value as ChannelId; + } + break; + default: + (this.settings as unknown as Record)[field.key] = value; + break; + } + if (field.key === "endpoint") { this.apiKeyVerified = false; this.invalidateAuthentication(); this.syncBilling(); } + const suffix = field.key === "channel" ? " Conversation restarted when the channel changed." : ""; + return { + title: "/config", + lines: [ + `${field.key} = ${this.configValue(field.key)}${path ? " · saved" : " · session only"}.${suffix}`, + ...(path ? [`Saved to ${path}. Startup flags, environment and trusted project settings can override this default next time.`] : []), + ], + }; + } + + private invalidateAuthentication(): void { + this.authGeneration++; + if (this.verifyingLogin !== null) { + this.verifyingLogin = null; + if (!this.quitting) this.screen.setActivity(null); + } + } + + private syncBilling(): void { + if (this.quitting) return; + this.billing.configure(this.settings.endpoint, this.apiKey); + void this.billing.refresh(); + } + + private insights() { + return collectInsights(this.opts.journalDir, { now: this.now(), activePath: this.active?.journal.path }); + } + + private reloadInsights(): void { + if (this.panel?.tab === "stats" || this.panel?.tab === "usage") { + const insights = this.insights(); + this.insightRows = this.panel.tab === "stats" ? statsRows(insights) : usageRows(insights); + } + } + + private openPanel(tab: DashboardTab): void { + this.panel = { tab, selected: 0, offset: 0 }; + this.configEditor.clear(); + this.reloadInsights(); + if (tab === "usage") void this.billing.refresh(); + this.refresh(); + } + + private panelView(): PanelView { + const panel = this.panel!; + const rows: PanelView["rows"] = panel.tab === "config" + ? CONFIG_FIELDS.map((f) => ({ label: f.label, value: this.configValue(f.key), detail: `${f.detail}${f.key === "permissions" && this.alwaysAllowed.size ? ` Already allowed this session: ${[...this.alwaysAllowed].join(", ")}.` : ""} Source: ${this.settingSource(f.key)}.` })) + : panel.tab === "status" ? [ + { label: "Version", value: this.opts.version }, + { label: "State", value: this.active ? "Running" : this.shellBusy ? "Shell running" : this.compacting ? "Compacting" : "Ready", tone: "good" }, + { label: "Model", value: this.settings.model }, + { label: "Endpoint", value: this.settings.endpoint }, + { label: "Authentication", value: this.apiKey ? this.apiKeyVerified ? "Verified this session" : "API key loaded" : "No API key · /login" }, + { label: "Working directory", value: this.settings.cwd }, + { label: "Channel / policy", value: `${this.settings.channel} / ${this.opts.channelPolicy}` }, + { label: "Permissions", value: `${this.settings.permissions}${this.alwaysAllowed.size ? ` · ${this.alwaysAllowed.size} session grants` : ""}`, detail: this.alwaysAllowed.size ? `Allowed this session: ${[...this.alwaysAllowed].join(", ")}` : "No per-tool session grants." }, + { label: "Conversation", value: `${this.history.length} messages · ${this.queued.length} queued` }, + { label: "Session tasks", value: `${this.totals.tasks} ended · ${this.totals.done} done · ${this.totals.interrupted} interrupted` }, + { label: "Last context", value: `${this.totals.lastContext.toLocaleString("en-US")} / ${MAX_CONTEXT.toLocaleString("en-US")} tokens`, detail: "Last reported root prompt tokens, not a live estimate." }, + { label: "Skills / agents", value: `${this.opts.skills.list().length} skills · ${this.opts.agents.list().length} agents` }, + { label: "Tool registry", value: `${this.opts.tools.length} tools` }, + { label: "User settings", value: this.opts.settingsInfo?.userPath ?? "Session only" }, + { label: "Project settings", value: this.opts.settingsInfo?.projectApplied ? "Trusted and applied" : "Not applied" }, + { label: "Journals", value: this.opts.journalDir }, + ] : panel.tab === "usage" ? [...balanceRows(this.billing.state), ...this.insightRows] : this.insightRows; + return { + tabs: ["Config", "Status", "Stats", "Usage"], + activeTab: DASHBOARD_TABS.indexOf(panel.tab), + subtitle: panel.tab === "config" + ? "Changes apply here and save to your user defaults." + : panel.tab === "status" ? "Current session" : panel.tab === "usage" ? "Account balance + local recorded usage · r refresh" : "Local task journals · press r to refresh", + rows: rows.map((row) => ({ ...row, detail: row.detail ?? (row.value ? `${row.label}: ${row.value}` : undefined) })), + selected: panel.tab === "config" ? panel.selected : Math.min(panel.offset, Math.max(0, rows.length - 1)), + message: panel.message, + error: panel.error, + hint: panel.editing ? "enter save · esc cancel · ctrl-u clear" + : panel.tab === "config" ? "↑↓ select · enter edit · ←→ change · tab next · esc close" + : "↑↓ inspect · tab / ←→ switch · r refresh · esc close", + ...(panel.editing ? { editing: { label: panel.editing.label, draft: this.configEditor.snapshot() } } : {}), + }; + } + + private panelKey(key: Key): void { + const panel = this.panel!; + const editor = this.configEditor; + if (panel.editing) { + switch (key.type) { + case "escape": panel.editing = undefined; panel.message = undefined; panel.error = false; break; + case "enter": { + const result = this.applyConfig(panel.editing, editor.text); + panel.message = result.lines[0]; panel.error = result.error; + if (!result.error) panel.editing = undefined; + break; + } + case "text": case "paste": editor.insert(key.text.replace(/[\r\n\t]/g, " ")); break; + case "backspace": editor.backspace(); break; + case "delete": editor.deleteForward(); break; + case "left": editor.left(); break; + case "right": editor.right(); break; + case "home": editor.home(); break; + case "end": editor.end(); break; + case "word-left": editor.wordLeft(); break; + case "word-right": editor.wordRight(); break; + case "delete-word": editor.deleteWordBack(); break; + case "ctrl": + if (key.key === "u") editor.killToStart(); + if (key.key === "k") editor.killToEnd(); + if (key.key === "c") { panel.editing = undefined; panel.message = undefined; panel.error = false; } + if (key.key === "l") this.screen.redraw(); + break; + default: break; + } + this.refresh(); + return; + } + if (key.type === "escape" || (key.type === "ctrl" && (key.key === "c" || key.key === "d"))) { + this.panel = null; + } else if (key.type === "tab" || key.type === "shift-tab" || (panel.tab !== "config" && (key.type === "left" || key.type === "right"))) { + const direction = key.type === "shift-tab" || key.type === "left" ? -1 : 1; + this.openPanel(DASHBOARD_TABS[(DASHBOARD_TABS.indexOf(panel.tab) + direction + DASHBOARD_TABS.length) % DASHBOARD_TABS.length]!); + } else if (key.type === "up" || key.type === "down") { + const delta = key.type === "up" ? -1 : 1; + if (panel.tab === "config") panel.selected = Math.max(0, Math.min(CONFIG_FIELDS.length - 1, panel.selected + delta)); + else panel.offset = Math.max(0, Math.min(this.panelView().rows.length - 1, panel.offset + delta)); + panel.message = undefined; panel.error = false; + } else if (panel.tab === "config" && ["enter", "left", "right"].includes(key.type)) { + const field = CONFIG_FIELDS[panel.selected]!; + if (field.choices) { + const current = field.choices.indexOf(this.configValue(field.key)); + const delta = key.type === "left" ? -1 : 1; + const value = field.choices[(current + delta + field.choices.length) % field.choices.length]!; + const result = this.applyConfig(field, value); + panel.message = result.lines[0]; panel.error = result.error; + } else if (key.type === "enter") { + editor.clear(); editor.insert(this.configValue(field.key)); + panel.editing = field; panel.message = undefined; panel.error = false; + } + } else if (key.type === "text" && key.text === "r" && panel.tab !== "config") { + this.reloadInsights(); + if (panel.tab === "usage") void this.billing.refresh(true); + } else if (key.type === "ctrl" && key.key === "l") this.screen.redraw(); + this.refresh(); + } + private configLines(): string[] { const info = this.opts.settingsInfo; const s = this.settings; - const src = (key: keyof LoadedSettings["sources"]): string => info?.sources[key] ?? "default"; + const src = (key: keyof LoadedSettings["sources"]): string => this.settingSource(key); const rows: [string, string, string][] = [ ["model", s.model, src("model")], ["endpoint", s.endpoint, src("endpoint")], @@ -1423,7 +1710,8 @@ export class Chat { ["maxOutputTokens", s.maxOutputTokens === undefined ? "off" : String(s.maxOutputTokens), src("maxOutputTokens")], ["seed", s.seed === undefined ? "off" : String(s.seed), src("seed")], ["theme", s.theme, src("theme")], - ["thinking", this.screen.thinkingShown ? "shown" : "hidden", src("thinking")], + ["thinking", String(this.screen.thinkingShown), src("thinking")], + ["verbose", String(this.screen.verboseOutput), src("verbose")], ["compactAt", String(s.compactAt), src("compactAt")], ["permissions", s.permissions, src("permissions")], ]; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 5cabf6d..50dca52 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -42,7 +42,7 @@ export interface ChatSettings { } /** Settings a command may write to the person's file. */ -export type PersistableKey = "model" | "endpoint" | "channel" | "maxTurns" | "maxOutputTokens" | "seed" | "theme" | "thinking" | "compactAt" | "permissions"; +export type PersistableKey = "model" | "endpoint" | "channel" | "maxTurns" | "maxOutputTokens" | "seed" | "theme" | "thinking" | "verbose" | "compactAt" | "permissions"; export interface CommandContext { settings: ChatSettings; @@ -50,6 +50,11 @@ export interface CommandContext { status(): string[]; /** Effective settings with where each came from, and the files involved. */ config(): string[]; + /** Interactive dashboard; plain outputs remain available without a screen. */ + openPanel?(tab: "config" | "status" | "stats" | "usage"): void; + configure?(args: string): CommandOutput; + stats?(): string[]; + usage?(): string[]; doctor(): Promise; skills(): string[]; agents(): string[]; @@ -88,6 +93,8 @@ export interface CommandOutput { lines: string[]; /** True when the command failed and the output is an error. */ error?: boolean; + /** An interactive panel has already handled this command. */ + silent?: boolean; } export interface SlashCommand { @@ -107,7 +114,7 @@ const KEYS: readonly [string, string][] = [ ["?", "on an empty prompt, show or hide the key list"], ["shift-tab", "toggle permissions: ask before tools run, or run everything"], ["1 / 2 / 3", "when asked about a tool call: run it once, stop asking for that tool this session, decline (↑↓ enter and esc work too)"], - ["ctrl-o", "show tool output in full, or clipped again"], + ["ctrl-o", "open full output viewer; ↑↓ / PgUp / PgDn / Home / End browse, esc closes"], ["ctrl-l", "redraw the screen"], ["@path", "attach a file or directory to the message; @skill:name attaches a skill's instructions"], ["!command", "run a shell command here and put its output in the conversation"], @@ -136,6 +143,15 @@ function saved(ctx: CommandContext, key: PersistableKey, value: unknown): string return path ? [`saved to ${path}`] : []; } +function dashboard(ctx: CommandContext, tab: "config" | "status" | "stats" | "usage", args: string, lines: () => string[]): CommandOutput { + if (args === "" && ctx.openPanel) { + ctx.openPanel(tab); + return { title: `/${tab}`, lines: [], silent: true }; + } + if (args && args !== "show") return fail(`/${tab}`, `Use /${tab} or /${tab} show.`); + return ok(`/${tab}`, lines()); +} + export const COMMANDS: readonly SlashCommand[] = [ { name: "help", @@ -155,12 +171,25 @@ export const COMMANDS: readonly SlashCommand[] = [ { name: "status", description: "connection, settings and session totals", - run: (ctx) => ok("/status", ctx.status()), + run: (ctx, args) => dashboard(ctx, "status", args, () => ctx.status()), }, { name: "config", - description: "effective settings, where each came from, and the files", - run: (ctx) => ok("/config", ctx.config()), + description: "edit settings in the session and save defaults", + usage: "[key value|show]", + run: (ctx, args) => args && args !== "show" && ctx.configure + ? ctx.configure(args) + : dashboard(ctx, "config", args, () => ctx.config()), + }, + { + name: "stats", + description: "activity across recorded local tasks", + run: (ctx, args) => dashboard(ctx, "stats", args, () => ctx.stats?.() ?? ["No recorded tasks."]), + }, + { + name: "usage", + description: "reported token usage across local task journals", + run: (ctx, args) => dashboard(ctx, "usage", args, () => ctx.usage?.() ?? ["No reported usage."]), }, { name: "doctor", diff --git a/packages/cli/src/config-controls.ts b/packages/cli/src/config-controls.ts new file mode 100644 index 0000000..9b2e7f7 --- /dev/null +++ b/packages/cli/src/config-controls.ts @@ -0,0 +1,64 @@ +import { normalizeEndpoint } from "@motifcode/core"; +import { THEMES } from "@motifcode/tui"; +import type { StoredSettings } from "./settings.js"; + +export interface ConfigField { + key: keyof StoredSettings; + label: string; + detail: string; + choices?: readonly string[]; +} + +/** The same persisted settings used by startup and the running chat. */ +export const CONFIG_FIELDS: readonly ConfigField[] = [ + { key: "thinking", label: "Show reasoning", choices: ["false", "true"], detail: "Show the model's reasoning in the transcript. Applies immediately; does not change model reasoning." }, + { key: "verbose", label: "Full tool output", choices: ["false", "true"], detail: "Show full tool output instead of clipped results. Applies immediately." }, + { key: "permissions", label: "Tool permissions", choices: ["ask", "auto"], detail: "ask waits for approval before writes and commands; auto runs tools without asking." }, + { key: "model", label: "Model", detail: "Model ID sent to the endpoint on the next task." }, + { key: "endpoint", label: "Endpoint", detail: "API base URL for the next task. The current API key will be sent to this server." }, + { key: "channel", label: "Action channel", choices: ["toolcall", "object", "raw"], detail: "Changing this restarts the conversation. object/raw are experimental and require /v1/completions." }, + { key: "maxTurns", label: "Max turns / task", detail: "Positive integer: maximum model steps per task." }, + { key: "maxOutputTokens", label: "Max output tokens", detail: "Positive integer per model step, or off to use the server default." }, + { key: "seed", label: "Sampling seed", detail: "Integer >= 0, or off. The endpoint must support seeded sampling." }, + { key: "compactAt", label: "Compact at", detail: "Context fraction from 0.1 to 1 at which automatic compaction runs." }, + { key: "theme", label: "Theme", choices: Object.keys(THEMES), detail: "Terminal color palette. Applies immediately; NO_COLOR takes precedence." }, +]; + +export function configField(key: string): ConfigField | undefined { + const normalized = key.toLowerCase().replace(/[-_]/g, ""); + return CONFIG_FIELDS.find((f) => f.key.toLowerCase() === (normalized === "maxtokens" ? "maxoutputtokens" : normalized)); +} + +export function parseConfigValue(field: ConfigField, raw: string): string | number | boolean | undefined { + const value = raw.trim(); + if (/[\x00-\x1f\x7f]/.test(value)) throw new Error("Use a single line without control characters."); + if (value.length > 2048) throw new Error("The value is too long (maximum 2048 characters)."); + if (field.choices) { + if (!field.choices.includes(value)) throw new Error(`Choose ${field.choices.join(" or ")}.`); + return field.key === "thinking" || field.key === "verbose" ? value === "true" : value; + } + if (field.key === "maxOutputTokens" || field.key === "seed") { + if (value === "off") return undefined; + } + if (field.key === "maxTurns" || field.key === "maxOutputTokens" || field.key === "seed") { + const n = Number(value); + const min = field.key === "seed" ? 0 : 1; + if (!/^\d+$/.test(value) || !Number.isSafeInteger(n) || n < min) throw new Error(`Enter an integer >= ${min}${field.key !== "maxTurns" ? ", or off" : ""}.`); + return n; + } + if (field.key === "compactAt") { + const n = Number(value); + if (value === "" || !Number.isFinite(n) || n < 0.1 || n > 1) throw new Error("Enter a context fraction from 0.1 to 1."); + return n; + } + if (field.key === "endpoint") { + let url: URL; + try { url = new URL(value); } catch { throw new Error("Enter a valid http:// or https:// URL."); } + if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) { + throw new Error("Use an HTTP(S) base URL without credentials, query parameters or a fragment."); + } + return normalizeEndpoint(value); + } + if (!value) throw new Error("The value cannot be empty."); + return value; +} diff --git a/packages/cli/src/insights.ts b/packages/cli/src/insights.ts new file mode 100644 index 0000000..f4b3984 --- /dev/null +++ b/packages/cli/src/insights.ts @@ -0,0 +1,314 @@ +/** Local journal facts, not provider quotas or a claim that completed work passed tests. */ +import { closeSync, fstatSync, openSync, opendirSync, readSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseJournal, type JournalEnvelopeV2, type ParsedJournal } from "@motifcode/journal"; + +export interface InsightRow { + label: string; + value?: string; + detail?: string; + tone?: "normal" | "muted" | "good" | "warn" | "bad"; +} + +export interface ReportedCostInsights { + provider: "infron"; + unit: "credits"; + /** Finite subtotal; unavailable as a total when overflow is true. */ + amount: number; + reports: number; + invalidReports: number; + otherReports: number; + overflow: boolean; +} + +interface TokenUsage { + events: number; + promptTokens: number; + completionTokens: number; + cachedTokens: number; + promptReports: number; + completionReports: number; + cachedReports: number; + pairedPromptTokens: number; + pairedCachedTokens: number; + pairedReports: number; + invalidReports: number; + cost: ReportedCostInsights; +} + +export interface ModelInsights extends TokenUsage { + model: string; + sessions: number; + turns: number; + toolCalls: number; +} + +export interface Insights { + sessions: number; + completed: number; + active: number; + interrupted: number; + errors: number; + stopped: number; + unfinished: number; + turns: number; + toolCalls: number; + recentSessions: number; + durationMs: number; + timedSessions: number; + /** Durations of active/incomplete runs are observed spans, not final runtimes. */ + partialDurations: number; + usage: TokenUsage; + models: ModelInsights[]; + files: { + found: number; + read: number; + corrupt: number; + truncated: number; + unreadable: number; + duplicate: number; + oversized: number; + limited: number; + scanLimited: boolean; + }; +} + +// Synchronous commands must remain bounded even when checkpoints contain large transcripts. +const MAX_ENTRIES = 10_000; +const MAX_FILES = 200; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_TOTAL_BYTES = 32 * 1024 * 1024; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const emptyUsage = (): TokenUsage => ({ + events: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, + promptReports: 0, completionReports: 0, cachedReports: 0, + pairedPromptTokens: 0, pairedCachedTokens: 0, pairedReports: 0, invalidReports: 0, + cost: { provider: "infron", unit: "credits", amount: 0, reports: 0, invalidReports: 0, otherReports: 0, overflow: false }, +}); +const tokenCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); + +function addReportedCost(total: ReportedCostInsights, raw: unknown): void { + if (raw === undefined) return; + if (!object(raw) || typeof raw.provider !== "string" || !raw.provider || typeof raw.unit !== "string" || !raw.unit || + typeof raw.amount !== "number" || !Number.isFinite(raw.amount) || raw.amount < 0) { + total.invalidReports++; + return; + } + if (raw.provider !== total.provider || raw.unit !== total.unit) { + total.otherReports++; + return; + } + // Details describe the same charge; adding them would count the cost twice. + // Only the validated total amount is used, including for older malformed detail objects. + total.reports++; + const sum = total.amount + raw.amount; + if (Number.isFinite(sum)) total.amount = sum; + else total.overflow = true; +} + +/** Validate fields used below; malformed JSON objects must not crash an interactive command. */ +function usable(parsed: ParsedJournal): boolean { + const h = parsed.header; + if (parsed.corruption || !h || h.schemaVersion !== 2 || typeof h.runId !== "string" || !h.runId || + typeof h.startedAt !== "string" || !object(h.model) || typeof h.model.id !== "string") return false; + const roots = new Set(); + for (const r of parsed.records) { + if (typeof r.scopeId !== "string" || !r.scopeId || !["root", "subagent"].includes(r.scopeKind) || + typeof r.at !== "string" || !object(r.record) || typeof r.record.t !== "string") return false; + if (r.scopeKind === "root") roots.add(r.scopeId); + if (r.record.t === "event" && (!object(r.record.event) || typeof r.record.event.type !== "string")) return false; + if (r.record.t === "scope_end" && (!object(r.record.result) || typeof r.record.result.endReason !== "string")) return false; + } + return roots.size <= 1; +} + +function addUsage(total: TokenUsage, event: Extract["event"]): void { + if (event.type !== "usage") return; + total.events++; + addReportedCost(total.cost, "reportedCost" in event ? event.reportedCost : undefined); + const prompt = tokenCount(event.promptTokens) ? event.promptTokens : undefined; + const completion = tokenCount(event.completionTokens) ? event.completionTokens : undefined; + // A cache count greater than its known prompt total is inconsistent, not a cache hit rate above 100%. + const cached = tokenCount(event.cachedTokens) && (prompt === undefined || event.cachedTokens <= prompt) ? event.cachedTokens : undefined; + for (const [raw, valid] of [[event.promptTokens, prompt], [event.completionTokens, completion], [event.cachedTokens, cached]]) { + if (raw !== undefined && valid === undefined) total.invalidReports++; + } + if (prompt !== undefined) { total.promptTokens += prompt; total.promptReports++; } + if (completion !== undefined) { total.completionTokens += completion; total.completionReports++; } + if (cached !== undefined) { total.cachedTokens += cached; total.cachedReports++; } + if (prompt !== undefined && cached !== undefined) { + total.pairedPromptTokens += prompt; + total.pairedCachedTokens += cached; + total.pairedReports++; + } +} + +/** Each journal run is counted once; checkpoints and scope summaries do not duplicate its events. */ +export function collectInsights(journalDir: string, opts: { now?: number; activePath?: string } = {}): Insights { + const now = opts.now ?? Date.now(); + const activePath = opts.activePath ? resolve(opts.activePath) : undefined; + const result: Insights = { + sessions: 0, completed: 0, active: 0, interrupted: 0, errors: 0, stopped: 0, unfinished: 0, + turns: 0, toolCalls: 0, recentSessions: 0, durationMs: 0, timedSessions: 0, partialDurations: 0, + usage: emptyUsage(), models: [], + files: { found: 0, read: 0, corrupt: 0, truncated: 0, unreadable: 0, duplicate: 0, oversized: 0, limited: 0, scanLimited: false }, + }; + const candidates: { path: string; mtime: number }[] = []; + try { + const dir = opendirSync(journalDir); + try { + let count = 0; + for (let entry = dir.readSync(); entry; entry = dir.readSync()) { + if (++count > MAX_ENTRIES) { result.files.scanLimited = true; break; } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + result.files.found++; + const path = join(journalDir, entry.name); + try { candidates.push({ path, mtime: statSync(path).mtimeMs }); } + catch { result.files.unreadable++; } + } + } finally { dir.closeSync(); } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") result.files.unreadable++; + return result; + } + candidates.sort((a, b) => Number(resolve(b.path) === activePath) - Number(resolve(a.path) === activePath) || b.mtime - a.mtime || a.path.localeCompare(b.path)); + result.files.limited = Math.max(0, candidates.length - MAX_FILES); + const seen = new Set(); + const models = new Map(); + let bytes = 0; + for (const { path } of candidates.slice(0, MAX_FILES)) { + let text: string; + try { + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size > MAX_FILE_BYTES) { result.files.oversized++; continue; } + if (bytes + size > MAX_TOTAL_BYTES) { result.files.limited++; continue; } + // Read the snapshot size only: an active journal can keep growing while it is inspected. + const buffer = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const n = readSync(fd, buffer, offset, size - offset, offset); + if (n === 0) break; + offset += n; + } + bytes += offset; + text = buffer.subarray(0, offset).toString("utf8"); + } finally { closeSync(fd); } + } catch { result.files.unreadable++; continue; } + result.files.read++; + let parsed: ParsedJournal; + try { parsed = parseJournal(text); } + catch { result.files.corrupt++; continue; } + if (!usable(parsed)) { result.files.corrupt++; continue; } + const header = parsed.header!; + if (seen.has(header.runId)) { result.files.duplicate++; continue; } + seen.add(header.runId); + result.sessions++; + if (parsed.truncatedTail) result.files.truncated++; + const root = parsed.records.find((r) => r.scopeKind === "root")?.scopeId; + const roots = parsed.records.filter((r) => r.scopeId === root); + const ending = roots.findLast((r) => r.record.t === "scope_end"); + const active = resolve(path) === activePath; + if (active) result.active++; + else if (!ending || ending.record.t !== "scope_end") result.unfinished++; + else { + switch (ending.record.result.endReason) { + case "done": result.completed++; break; + case "aborted": result.interrupted++; break; + case "transport_error": result.errors++; break; + default: result.stopped++; break; + } + } + const started = Date.parse(header.startedAt); + if (started >= now - WEEK_MS && started <= now) result.recentSessions++; + const ended = active ? now : Date.parse(ending?.at ?? parsed.records.at(-1)?.at ?? ""); + if (Number.isFinite(started) && Number.isFinite(ended) && ended >= started) { + result.durationMs += ended - started; + result.timedSessions++; + if (active || !ending) result.partialDurations++; + } + const scopeModels = new Map(); + const sessionModels = new Set(); + const modelFor = (name: string): ModelInsights => { + let model = models.get(name); + if (!model) { model = { model: name, sessions: 0, turns: 0, toolCalls: 0, ...emptyUsage() }; models.set(name, model); } + if (!sessionModels.has(name)) { model.sessions++; sessionModels.add(name); } + return model; + }; + modelFor(header.model.id); + for (const r of parsed.records) { + if (r.record.t !== "event") continue; + const event = r.record.event; + if (event.type === "session_start" && typeof event.model === "string") scopeModels.set(r.scopeId, event.model); + const model = modelFor(scopeModels.get(r.scopeId) ?? header.model.id); + if (event.type === "turn_start") { result.turns++; model.turns++; } + if (event.type === "tool_start") { result.toolCalls++; model.toolCalls++; } + addUsage(result.usage, event); + addUsage(model, event); + } + } + result.models = [...models.values()].sort((a, b) => b.turns - a.turns || a.model.localeCompare(b.model)); + return result; +} + +const number = (n: number): string => n.toLocaleString("en-US"); +function reportedCost(cost: ReportedCostInsights): string { + if (!cost.reports) return "Not reported"; + if (cost.overflow) return "Unavailable (total overflow)"; + if (cost.amount > 0 && cost.amount < 0.00000001) return "<0.00000001 credits"; + return `${cost.amount.toLocaleString("en-US", { maximumFractionDigits: 8 })} credits`; +} +function duration(ms: number): string { + if (ms > 0 && ms < 1000) return "<1s"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function qualityRows(data: Insights): InsightRow[] { + const f = data.files; + const rows: InsightRow[] = [{ label: "Source", value: "Local saved journals", detail: "This directory only; root tasks are sessions, child work contributes to turns, tools, tokens and reported costs.", tone: "muted" }]; + if (f.truncated) rows.push({ label: "Partial journals", value: number(f.truncated), detail: "Valid records before an incomplete final line are included.", tone: "warn" }); + if (f.corrupt || f.unreadable || f.duplicate) rows.push({ label: "Skipped journals", value: number(f.corrupt + f.unreadable + f.duplicate), detail: `${f.corrupt} corrupt/unsupported, ${f.unreadable} unreadable, ${f.duplicate} duplicate run IDs.`, tone: "warn" }); + if (f.oversized || f.limited || f.scanLimited) rows.push({ label: "Read limits", value: "Partial totals", detail: `${f.oversized} oversized and ${f.limited} other files skipped; at most 200 files, 8 MiB/file, 32 MiB total and 10,000 directory entries.${f.scanLimited ? " Directory scan stopped early." : ""}`, tone: "warn" }); + return rows; +} + +export function statsRows(data: Insights): InsightRow[] { + return [ + { label: "Saved runs", value: number(data.sessions) }, + { label: "Last 7 days", value: number(data.recentSessions), detail: "Runs started in the last seven days." }, + { label: "Completed", value: number(data.completed), detail: "Agent-reported completion; not a test or quality grade.", tone: "good" }, + { label: "Active", value: number(data.active) }, + { label: "Interrupted", value: number(data.interrupted), detail: "Root runs explicitly ended as aborted." }, + { label: "Errors", value: number(data.errors), detail: "Root runs ended with a transport error." }, + { label: "Other endings", value: number(data.stopped), detail: "Turn, loop, breakage or no-action limits; includes unrecognized end reasons." }, + { label: "No final run record", value: number(data.unfinished), detail: "Root scope_end missing; may be interrupted or running elsewhere, and is not counted as completed." }, + { label: "Model turns", value: number(data.turns), detail: "Recorded turn starts across root and child scopes." }, + { label: "Tool calls", value: number(data.toolCalls), detail: "Recorded tool starts across root and child scopes." }, + { label: "Observed runtime", value: duration(data.durationMs), detail: `${data.timedSessions}/${data.sessions} runs have usable timestamps; ${data.partialDurations} spans are partial. Overlapping runs are summed, not calendar time.` }, + ...qualityRows(data), + ]; +} + +export function usageRows(data: Insights): InsightRow[] { + const u = data.usage; + const tokens = (sum: number, reports: number): string => reports ? number(sum) : "Not reported"; + const coverage = (reports: number): string => `${reports}/${u.events} usage events reported this count; missing values are not estimated.`; + return [ + { label: "Prompt tokens", value: tokens(u.promptTokens, u.promptReports), detail: coverage(u.promptReports) }, + { label: "Completion tokens", value: tokens(u.completionTokens, u.completionReports), detail: coverage(u.completionReports) }, + { label: "Cached prompt tokens", value: tokens(u.cachedTokens, u.cachedReports), detail: coverage(u.cachedReports) }, + { label: "Cache ratio", value: u.pairedPromptTokens > 0 ? `${(100 * u.pairedCachedTokens / u.pairedPromptTokens).toFixed(1)}%` : "Unavailable", detail: `Only ${u.pairedReports}/${u.events} events reporting both prompt and cache counts; cached tokens are part of prompt tokens.` }, + { label: "Usage coverage", value: `${number(u.events)} events / ${number(data.turns)} turns`, detail: "Server-reported journal events only; failed requests or unrecorded operations may be absent." }, + { label: "Reported cost", value: reportedCost(u.cost), detail: "Infron credits reported for these local journal events only; not account-wide spending or remaining balance. Rounded to at most 8 decimal places.", ...(u.cost.overflow ? { tone: "warn" as const } : {}) }, + { label: "Cost coverage", value: `${number(u.cost.reports)}/${number(u.events)} usage events`, detail: "Only valid Infron credit amounts are added; zero is a reported charge. Missing costs in older journals or unrecorded requests are not estimated." }, + ...data.models.map((model): InsightRow => ({ label: model.model, value: `${number(model.turns)} turns`, detail: `${model.sessions} runs · prompt ${tokens(model.promptTokens, model.promptReports)} · completion ${tokens(model.completionTokens, model.completionReports)} · cached ${tokens(model.cachedTokens, model.cachedReports)} · cost ${reportedCost(model.cost)} (${model.cost.reports}/${model.events} events; reported totals only)` })), + ...(u.invalidReports ? [{ label: "Invalid usage fields", value: number(u.invalidReports), detail: "Negative, non-integer or inconsistent token counts were ignored.", tone: "warn" as const }] : []), + ...(u.cost.invalidReports || u.cost.otherReports ? [{ label: "Excluded cost records", value: number(u.cost.invalidReports + u.cost.otherReports), detail: `${u.cost.invalidReports} malformed amounts/metadata; ${u.cost.otherReports} other providers or units. Providers and units are never combined.`, tone: "warn" as const }] : []), + ...qualityRows(data), + ]; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 6de2d3e..fea7ad2 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -63,6 +63,8 @@ import { BUILTIN_SKILLS, SkillRegistry, parseSkill } from "@motifcode/skills"; import { CORE_TOOLS, CORE_TOOL_NAMES, lintTools, formatFindings, toolPrefix } from "@motifcode/tools"; import { Screen, applyTheme, themeNames } from "@motifcode/tui"; import { Chat } from "./chat.js"; +import { InfronBilling } from "./billing.js"; +import { balanceRows } from "./billing-view.js"; import { describePlugins, loadPlugins, type LoadedPlugins } from "./plugins.js"; import { loadSettings, saveUserSetting } from "./settings.js"; import { commandOnPath, ranFromNpx } from "./install.js"; @@ -388,7 +390,7 @@ Flags --interactive open the prompt after the task, or with no task at all --continue open the prompt with the most recent conversation here loaded --thinking show the model's reasoning in the transcript - --verbose show tool output in full (ctrl-o toggles it in the session) + --verbose show full inline tool output (ctrl-o opens the output viewer) --theme colour theme (motif, claude, mono, solarized, dracula) --permissions ask (default) before commands, writes and patches run, or auto --no-hero skip the splash @@ -462,6 +464,15 @@ async function loginAtTerminal(endpoint: string, model: string): Promise { ["seed", stored.values.seed === undefined ? "off" : String(stored.values.seed), stored.sources.seed ?? "default"], ["theme", themeName, typeof args.flags["theme"] === "string" ? "flag" : (stored.sources.theme ?? "default")], ["thinking", showThinking ? "shown" : "hidden", args.flags["thinking"] === true ? "flag" : (stored.sources.thinking ?? "default")], + ["verbose", String(args.flags["verbose"] === true || stored.values.verbose === true), args.flags["verbose"] === true ? "flag" : (stored.sources.verbose ?? "default")], ["compactAt", String(compactAt), stored.sources.compactAt ?? "default"], ["permissions", permissions, typeof args.flags["permissions"] === "string" ? "flag" : (stored.sources.permissions ?? "default")], ]; @@ -869,7 +881,7 @@ async function main(): Promise { } if ((wantsChat || !task) && tty && !resumeFrom) { const chat = new Chat({ - screen: new Screen({ showThinking, verbose: args.flags["verbose"] === true, cwd }), + screen: new Screen({ showThinking, verbose: args.flags["verbose"] === true || stored.values.verbose === true, cwd }), stdin: process.stdin, settings: { model, @@ -884,6 +896,16 @@ async function main(): Promise { permissions, }, settingsInfo: stored, + settingSources: { + ...stored.sources, + model: connection.sources.model, + endpoint: connection.sources.endpoint, + ...Object.fromEntries([ + ["channel", "channel"], ["maxTurns", "max-turns"], ["maxOutputTokens", "max-output-tokens"], + ["seed", "seed"], ["theme", "theme"], ["thinking", "thinking"], + ["verbose", "verbose"], ["permissions", "permissions"], + ].filter(([, flag]) => args.flags[flag!] !== undefined).map(([key]) => [key, "flag"])), + }, persist: (key, value) => saveUserSetting(key, value as never), historyPath: join(cwd, CONFIG_DIR, "history.jsonl"), pluginLines: describePlugins(plugins(cwd)), @@ -970,7 +992,7 @@ async function main(): Promise { } const transport = new HttpTransport({ endpoint, model, ...(sessionKey !== undefined ? { apiKey: sessionKey } : {}) }); - const screen = new Screen({ showThinking, verbose: args.flags["verbose"] === true, cwd }); + const screen = new Screen({ showThinking, verbose: args.flags["verbose"] === true || stored.values.verbose === true, cwd }); const runId = new Date().toISOString().replace(/[:.]/g, "-"); // `--journal` so a benchmark runner knows where the record went without // scraping a directory for the newest file. Two rows finishing in the same @@ -1029,7 +1051,7 @@ async function main(): Promise { }; const scheduler = new AgentScheduler(concurrencyFor(endpoint), (entry) => - emit({ type: "queue", agent: entry.agent, state: entry.state === "failed" ? "done" : entry.state }), + emit({ type: "queue", agent: entry.agent, state: entry.state }), ); const executor = new ToolExecutor({ @@ -1125,7 +1147,7 @@ async function main(): Promise { } finally { childExecutor.close(); } - }); + }, (out) => !out.ok); }, }); diff --git a/packages/cli/src/settings.ts b/packages/cli/src/settings.ts index 865cba8..88471e4 100644 --- a/packages/cli/src/settings.ts +++ b/packages/cli/src/settings.ts @@ -30,6 +30,8 @@ export interface StoredSettings { theme?: string; /** Show the model's reasoning in the transcript. */ thinking?: boolean; + /** Show unabridged tool output. */ + verbose?: boolean; /** Fraction of the context window at which the transcript is compacted. */ compactAt?: number; /** Whether the session asks before a tool that changes the world runs. */ @@ -56,6 +58,7 @@ const KEYS: readonly (keyof StoredSettings)[] = [ "seed", "theme", "thinking", + "verbose", "compactAt", "permissions", ]; @@ -107,9 +110,11 @@ export function parseSettings(text: string): { values: StoredSettings; problems: int("maxTurns", 1); int("maxOutputTokens", 1); int("seed", 0); - if (obj["thinking"] !== undefined) { - if (typeof obj["thinking"] === "boolean") values.thinking = obj["thinking"]; - else problems.push("thinking must be true or false"); + for (const key of ["thinking", "verbose"] as const) { + if (obj[key] !== undefined) { + if (typeof obj[key] === "boolean") values[key] = obj[key]; + else problems.push(`${key} must be true or false`); + } } if (obj["compactAt"] !== undefined) { const v = obj["compactAt"]; diff --git a/packages/cli/test/billing.test.ts b/packages/cli/test/billing.test.ts new file mode 100644 index 0000000..3cd1242 --- /dev/null +++ b/packages/cli/test/billing.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InfronBilling, type BalanceState, type InfronBillingOptions } from "../src/billing.js"; + +const ENDPOINT = "https://llm.onerouter.pro"; +const BALANCE_URL = "https://api.onerouter.pro/v1/balance"; +const KEY = "fixture-private-api-key"; +const clients: InfronBilling[] = []; +const json = (body: unknown, init?: ResponseInit) => new Response(JSON.stringify(body), init); +function client(options: InfronBillingOptions = {}) { + const billing = new InfronBilling(options); + clients.push(billing); + billing.configure(ENDPOINT, KEY); + return billing; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +afterEach(() => { + for (const billing of clients) billing.close(); + clients.length = 0; + vi.useRealTimers(); +}); + +describe("Infron account balance", () => { + it.each([0, -12.5, 3951.9459])("keeps the numeric balance %s and discards account metadata", async (credits) => { + const fetchImpl = vi.fn().mockResolvedValue(json({ credit_balance: credits, account_name: "private account" })); + const billing = client({ fetchImpl, now: () => 123_000 }); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "ready", credits, checkedAt: 123_000 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]).toEqual([BALANCE_URL, { + method: "GET", headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" }, + redirect: "error", signal: expect.any(AbortSignal), + }]); + expect(JSON.stringify(billing.state)).not.toContain("private account"); + expect(JSON.stringify(billing.state)).not.toContain(KEY); + }); + + it.each([ + "https://evil.example", "https://llm.onerouter.pro.evil.example", "http://llm.onerouter.pro", + "https://llm.onerouter.pro/proxy", "https://user:password@llm.onerouter.pro", "https://llm.onerouter.pro?token=private", + ])("never sends a key for an unsupported endpoint: %s", async (endpoint) => { + const fetchImpl = vi.fn(); + const billing = client({ fetchImpl }); + billing.configure(endpoint, KEY); + await billing.refresh(true); + expect(billing.state).toEqual({ kind: "unsupported" }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("does not request account data without a key", async () => { + const fetchImpl = vi.fn(); + const billing = client({ fetchImpl }); + billing.configure(ENDPOINT); + await billing.refresh(true); + expect(billing.state).toEqual({ kind: "signed-out" }); + billing.configure(ENDPOINT, " "); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "signed-out" }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("coalesces concurrent requests and reuses a successful balance for sixty seconds", async () => { + let now = 100_000; + const pending = deferred(); + const fetchImpl = vi.fn().mockImplementationOnce(() => pending.promise) + .mockResolvedValueOnce(json({ credit_balance: 20 })); + const billing = client({ fetchImpl, now: () => now }); + const first = billing.refresh(); + expect(billing.refresh()).toBe(first); + expect(billing.refresh(true)).toBe(first); + expect(billing.state).toEqual({ kind: "loading" }); + pending.resolve(json({ credit_balance: 10 })); + await first; + now += 59_999; + await billing.refresh(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + now++; + await billing.refresh(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(billing.state).toEqual({ kind: "ready", credits: 20, checkedAt: now }); + }); + + it("forces a fresh request after its five-second cooldown, then retains the previous balance on failure", async () => { + let now = 100_000; + const fetchImpl = vi.fn().mockResolvedValueOnce(json({ credit_balance: 10 })) + .mockRejectedValueOnce(new Error(`network failure for ${KEY}`)) + .mockResolvedValueOnce(json({ credit_balance: 8 })); + const billing = client({ fetchImpl, now: () => now }); + await billing.refresh(); + now += 4_999; + await billing.refresh(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + now++; + const forced = billing.refresh(true); + expect(billing.state).toEqual({ kind: "loading", previous: { credits: 10, checkedAt: 100_000 } }); + await forced; + expect(billing.state).toEqual({ + kind: "error", message: "Could not reach the balance service.", previous: { credits: 10, checkedAt: 100_000 }, + }); + expect(JSON.stringify(billing.state)).not.toContain(KEY); + await billing.refresh(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + now += 5_000; + await billing.refresh(true); + expect(billing.state).toEqual({ kind: "ready", credits: 8, checkedAt: now }); + }); + + it("reports a safe HTTP status without exposing the service response", async () => { + const fetchImpl = vi.fn().mockResolvedValue(json({ error: KEY, account_name: "private person" }, { status: 401 })); + const billing = client({ fetchImpl }); + await expect(billing.refresh()).resolves.toBeUndefined(); + expect(billing.state).toEqual({ kind: "error", message: "Balance service returned HTTP 401." }); + }); + + it.each([ + "not JSON", "null", "[]", "{}", '{"credit_balance":"15"}', '{"credit_balance":null}', '{"credit_balance":1e400}', + ])("rejects malformed balances without echoing response content: %s", async (body) => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(body)); + const billing = client({ fetchImpl }); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "error", message: "Balance service returned an invalid response." }); + }); + + it.each([false, true])("bounds streamed response bytes even when the declared length is missing (%s)", async (declared) => { + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(16 * 1024 + 1)); }, + cancel, + }); + const response = new Response(stream, declared ? { headers: { "content-length": "16385" } } : {}); + const billing = client({ fetchImpl: vi.fn().mockResolvedValue(response) }); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "error", message: "Balance service returned an invalid response." }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("rejects redirects and never follows the supplied destination", async () => { + const response = new Response(null, { status: 302, headers: { location: "https://evil.example" } }); + const fetchImpl = vi.fn().mockResolvedValue(response); + const billing = client({ fetchImpl }); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "error", message: "Balance service returned HTTP 302." }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]![1]?.redirect).toBe("error"); + }); + + it("times out even when the fetch implementation ignores abort", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn().mockImplementation(() => new Promise(() => {})); + const billing = client({ fetchImpl }); + const request = billing.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(request).resolves.toBeUndefined(); + expect(billing.state).toEqual({ kind: "error", message: "Balance request timed out." }); + expect(fetchImpl.mock.calls[0]![1]?.signal?.aborted).toBe(true); + }); + + it("times out a stalled response body and cancels its stream", async () => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ cancel })); + const billing = client({ fetchImpl: vi.fn().mockResolvedValue(response) }); + const request = billing.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(request).resolves.toBeUndefined(); + expect(billing.state).toEqual({ kind: "error", message: "Balance request timed out." }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("clears the previous account immediately and ignores a late response after logout", async () => { + let now = 100_000; + const pending = deferred(); + const fetchImpl = vi.fn().mockResolvedValueOnce(json({ credit_balance: 99 })) + .mockImplementationOnce(() => pending.promise); + const billing = client({ fetchImpl, now: () => now }); + await billing.refresh(); + now += 5_000; + const previousRequest = billing.refresh(true); + await Promise.resolve(); + billing.configure(ENDPOINT); + expect(billing.state).toEqual({ kind: "signed-out" }); + expect(fetchImpl.mock.calls[1]![1]?.signal?.aborted).toBe(true); + pending.resolve(json({ credit_balance: 88 })); + await previousRequest; + expect(billing.state).toEqual({ kind: "signed-out" }); + await billing.refresh(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("isolates generations when the key changes while a request is in flight", async () => { + const oldResponse = deferred(); + const fetchImpl = vi.fn().mockImplementationOnce(() => oldResponse.promise) + .mockResolvedValueOnce(json({ credit_balance: 5 })); + const billing = client({ fetchImpl, now: () => 100_000 }); + const oldRequest = billing.refresh(); + await Promise.resolve(); + billing.configure(ENDPOINT, "fixture-new-account-key"); + expect(billing.state).toEqual({ kind: "idle" }); + expect(fetchImpl.mock.calls[0]![1]?.signal?.aborted).toBe(true); + await billing.refresh(); + expect(billing.state).toEqual({ kind: "ready", credits: 5, checkedAt: 100_000 }); + oldResponse.resolve(json({ credit_balance: 999 })); + await oldRequest; + expect(billing.state).toEqual({ kind: "ready", credits: 5, checkedAt: 100_000 }); + expect(fetchImpl.mock.calls[1]![1]?.headers).toMatchObject({ Authorization: "Bearer fixture-new-account-key" }); + }); + + it("aborts on close, ignores late results, and does not issue more requests", async () => { + const pending = deferred(); + const fetchImpl = vi.fn().mockImplementation(() => pending.promise); + const billing = client({ fetchImpl }); + const request = billing.refresh(); + await Promise.resolve(); + billing.close(); + expect(fetchImpl.mock.calls[0]![1]?.signal?.aborted).toBe(true); + pending.resolve(json({ credit_balance: 100 })); + await request; + billing.configure(ENDPOINT, KEY); + await billing.refresh(true); + expect(billing.state).toEqual({ kind: "unsupported" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("notifies every visible transition and tolerates a throwing renderer", async () => { + const states: BalanceState["kind"][] = []; + const fetchImpl = vi.fn().mockResolvedValue(json({ credit_balance: 0 })); + const billing = new InfronBilling({ fetchImpl, onChange: () => { states.push(billing.state.kind); throw new Error("renderer failed"); } }); + clients.push(billing); + billing.configure(ENDPOINT, KEY); + await expect(billing.refresh()).resolves.toBeUndefined(); + billing.configure(ENDPOINT); + expect(states).toEqual(["idle", "loading", "ready", "signed-out"]); + }); +}); diff --git a/packages/cli/test/chat.test.ts b/packages/cli/test/chat.test.ts index 0e991e1..4162a58 100644 --- a/packages/cli/test/chat.test.ts +++ b/packages/cli/test/chat.test.ts @@ -77,8 +77,9 @@ function session(transport: Transport, extra: Partial out.push(s), columns: () => 100, interactive: true, cwd }); const chat = new Chat({ - screen: new Screen({ write: (s) => out.push(s), columns: () => 100, interactive: true, cwd }), + screen: terminal, stdin: stdin as unknown as NodeJS.ReadStream, settings: { model: "motif/motif-3", endpoint: "https://llm.onerouter.pro", channel: "toolcall", maxTurns: 20, cwd, theme: "motif", compactAt: 0.75, permissions: "auto" }, channelPolicy: "fixed", @@ -89,6 +90,7 @@ function session(transport: Transport, extra: Partial transport, + balanceFetch: async () => new Response(JSON.stringify({ credit_balance: 0 })), hero: false, now: () => now, ...extra, @@ -98,7 +100,7 @@ function session(transport: Transport, extra: Partial strip(out.join("")); - return { chat, type, screen, finished, tick: (ms: number) => (now += ms), cwd }; + return { chat, type, screen, terminal, finished, tick: (ms: number) => (now += ms), cwd }; } // In a conversation the first `done` is final: the person is the confirmation. @@ -156,6 +158,36 @@ describe("interactive session", () => { expect(s.chat.transcript[1]!.content).toBe("안녕하세요! 무엇을 도와드릴까요?"); }); + it("shows a subagent that finished as done, and one that ran out of turns as failed", async () => { + const agents = new AgentRegistry(); + agents.registerAll(BUILTIN_AGENTS); + // One turn, and a first `done` is only a proposal: it ends at its turn limit. + agents.register({ name: "hasty", description: "d", toolCount: 3, maxTurns: 1, instructions: "Map it.", source: "project" }); + // Parent and child share this transport, so the bodies are in request + // order: the parent delegates, the child answers, the parent replies. + const t = new GateTransport([ + toolCallBody("task", { agent: "explorer", prompt: "map the repo" }), + doneBody("mapped"), + doneBody("mapped", { confirm: true }), + ...reply("the explorer mapped it"), + toolCallBody("task", { agent: "hasty", prompt: "map the repo" }), + doneBody("partial"), + ...reply("the hasty subagent did not finish"), + ]); + const s = session(t, { agents }); + open.push(s); + + s.type("map it\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.screen()).toContain("Task(explorer) · done"); + + s.type("again, faster\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(2)); + expect(s.screen()).toContain("subagent did not finish (turn_limit)"); + expect(s.screen()).toContain("Task(hasty) · failed"); + expect(s.screen()).not.toContain("Task(hasty) · done"); + }); + it("runs the command the menu has selected", async () => { const t = new GateTransport([]); const s = session(t); @@ -165,11 +197,85 @@ describe("interactive session", () => { await vi.waitFor(() => expect(s.screen()).toContain("❯ /status")); expect(s.screen()).toContain("> /sta"); s.type("\r"); - await vi.waitFor(() => expect(s.screen()).toContain("── /status")); - expect(s.screen()).toContain("model motif/motif-3"); + await vi.waitFor(() => expect(s.screen()).toContain("Current session")); + expect(s.screen()).toContain("motif/motif-3"); + expect(s.screen()).toMatch(/Config\W+Status\W+Stats\W+Usage/); expect(t.seen).toHaveLength(0); }); + it("marks a manually completed command and removes the mark when editing makes it incomplete", () => { + const t = new GateTransport([]); + const s = session(t); + open.push(s); + const compose = vi.spyOn(s.terminal, "setComposer"); + const current = () => compose.mock.calls.at(-1)![0]!; + s.type("/sta"); + expect(current().commandRange).toBeUndefined(); + s.type("tus"); + expect(current().commandRange).toEqual({ start: 0, end: 7 }); + s.type("\x7f"); + expect(current().commandRange).toBeUndefined(); + s.type("sx"); + expect(current().commandRange).toBeUndefined(); + s.type("\x7f argument /help"); + expect(current().commandRange).toEqual({ start: 0, end: 7 }); + expect(current().draft.text).toBe("/status argument /help"); + expect(current().draft.text).not.toContain(ESC); + expect(t.seen).toHaveLength(0); + }); + + it("marks a Tab completion with arguments and submits its original plain text", async () => { + const t = new GateTransport([]); + const s = session(t); + open.push(s); + const compose = vi.spyOn(s.terminal, "setComposer"); + s.type("/mod\t"); + expect(compose.mock.calls.at(-1)![0]).toMatchObject({ + draft: { text: "/model ", cursor: 7 }, + commandRange: { start: 0, end: 6 }, + }); + s.type("other/모델🙂"); + const draft = compose.mock.calls.at(-1)![0]!; + expect(draft.commandRange).toEqual({ start: 0, end: 6 }); + expect(draft.draft).toEqual({ text: "/model other/모델🙂", cursor: [..."/model other/모델🙂"].length }); + s.type("\r"); + await vi.waitFor(() => expect(s.screen()).toContain("model set to other/모델🙂")); + expect(t.seen).toHaveLength(0); + }); + + it("recognizes aliases and complete token boundaries using command execution semantics", () => { + const s = session(new GateTransport([])); + open.push(s); + const compose = vi.spyOn(s.terminal, "setComposer"); + for (const [text, range] of [ + ["/CoSt", { start: 0, end: 5 }], + [" /STATUS verbose", { start: 2, end: 9 }], + ["/status/extra", undefined], + ["/unknown", undefined], + ["text /status", undefined], + ["/", undefined], + ] as const) { + s.type(text); + expect(compose.mock.calls.at(-1)![0]!.commandRange, text).toEqual(range); + s.type(ESC); + } + }); + + it("marks installed skills with their exact registered spelling", () => { + const skills = new SkillRegistry(); + skills.register({ name: "Review🙂", description: "project review", body: "Review changes.", source: "project" }); + const s = session(new GateTransport([]), { skills }); + open.push(s); + const compose = vi.spyOn(s.terminal, "setComposer"); + s.type("/Review🙂 changes 한글"); + const view = compose.mock.calls.at(-1)![0]!; + expect(view.commandRange).toEqual({ start: 0, end: 8 }); + expect(view.draft.text).toBe("/Review🙂 changes 한글"); + s.type(ESC); + s.type("/review🙂"); + expect(compose.mock.calls.at(-1)![0]!.commandRange).toBeUndefined(); + }); + it("applies a setting to the next task", async () => { const seenSettings: string[] = []; const t = new GateTransport([...done("x")]); @@ -203,6 +309,65 @@ describe("interactive session", () => { expect(s.chat.transcript.map((m) => m.role)).toEqual(["user"]); }); + it("keeps the working indicator through partial prose, pauses for approval, and resumes until completion", async () => { + const t = new GateTransport([toolCallBody("bash", { command: "echo pulse-approval" }), ...reply("done")]); + const complete = t.complete.bind(t); + t.complete = (req) => { + req.onDelta?.({ content: "I will create the game now." }); + return complete(req); + }; + t.gated = true; + const s = session(t); + open.push(s); + const working = vi.spyOn(s.terminal, "setWorking"); + s.type(`${ESC}[Z`); // Ask before the tool runs. + s.type("create a game\r"); + await vi.waitFor(() => expect(t.seen).toHaveLength(1)); + expect(working).toHaveBeenLastCalledWith(true); + + t.open(); + await vi.waitFor(() => expect(s.screen()).toContain("Run this command?")); + expect(s.chat.running).toBe(true); + expect(working).toHaveBeenLastCalledWith(false); + + t.gated = true; + s.type("1"); + await vi.waitFor(() => expect(t.seen).toHaveLength(2)); + expect(working).toHaveBeenLastCalledWith(true); + t.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(working).toHaveBeenLastCalledWith(false); + }); + + it.each(["interrupt", "error"])("stops the working indicator after %s and can run again", async (ending) => { + const t = new GateTransport([...reply("first"), ...reply("second")]); + const complete = t.complete.bind(t); + t.complete = async (req) => { + const result = await complete(req); + if (ending === "error" && t.seen.length === 1) throw new Error("fixture transport failure"); + return result; + }; + t.gated = true; + const s = session(t); + open.push(s); + const working = vi.spyOn(s.terminal, "setWorking"); + s.type("first task\r"); + await vi.waitFor(() => expect(t.seen).toHaveLength(1)); + expect(working).toHaveBeenLastCalledWith(true); + if (ending === "interrupt") s.type(ESC); + else t.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(working).toHaveBeenLastCalledWith(false); + + t.gated = true; + s.type("second task\r"); + await vi.waitFor(() => expect(t.seen).toHaveLength(2)); + expect(working).toHaveBeenLastCalledWith(true); + t.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(2)); + expect(working).toHaveBeenLastCalledWith(false); + }); + it("queues messages sent while a task runs, and sends them in order after", async () => { const t = new GateTransport([...done("one"), ...done("two"), ...done("three")]); t.gated = true; @@ -468,17 +633,65 @@ describe("interactive session", () => { expect(t.seen).toHaveLength(1); }); - it("shows tool output in full on ctrl-o and clips it again", async () => { + it("opens and closes full tool output with Ctrl-O without changing the draft, model history or journal", async () => { const long = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\\n"); const t = new GateTransport([toolCallBody("bash", { command: `printf '${long}'` }), ...reply("ok")]); const s = session(t); open.push(s); s.type("go\r"); await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); - expect(s.screen()).toContain("… +24 lines"); - expect(s.chat.transcript.length).toBeGreaterThan(0); + expect(s.screen()).toMatch(/… \+27 (?:lines|rows)/); + const transcript = structuredClone(s.chat.transcript); + const cells = structuredClone(s.terminal.view.cells); + const requestCount = t.seen.length; + const journalDir = join(s.cwd, ".motif", "sessions"); + const journalPath = join(journalDir, readdirSync(journalDir)[0]!); + const journal = readFileSync(journalPath, "utf8"); + const compose = vi.spyOn(s.terminal, "setComposer"); + s.type("next task 한글🙂\x1b[D\x1b[D"); + const draft = compose.mock.calls.at(-1)![0]!.draft; + expect(draft).toEqual({ text: "next task 한글🙂", cursor: [..."next task 한"].length }); s.type("\x0f"); await vi.waitFor(() => expect(s.screen()).toContain("line 29")); + expect(s.terminal.outputViewOpen).toBe(true); + expect(s.terminal.verboseOutput).toBe(false); + expect(compose.mock.calls.at(-1)![0]!.draft).toEqual(draft); + s.type("\x0f"); + expect(s.terminal.outputViewOpen).toBe(false); + expect(compose.mock.calls.at(-1)![0]!.draft).toEqual(draft); + expect(s.chat.transcript).toEqual(transcript); + expect(s.terminal.view.cells).toEqual(cells); + expect(t.seen).toHaveLength(requestCount); + expect(readFileSync(journalPath, "utf8")).toBe(journal); + s.type("\x0f"); + s.type("ignored text\r\x1b[A\x1b[B"); + expect(s.terminal.outputViewOpen).toBe(true); + s.type("q"); + expect(s.terminal.outputViewOpen).toBe(false); + expect(compose.mock.calls.at(-1)![0]!.draft).toEqual(draft); + expect(t.seen).toHaveLength(requestCount); + expect(readFileSync(journalPath, "utf8")).toBe(journal); + }); + + it("keeps Ctrl-C interrupt and Ctrl-D quit available while full output is open", async () => { + const t = new GateTransport([...reply("should be interrupted")]); + t.gated = true; + const s = session(t); + open.push(s); + s.type("go\r"); + await vi.waitFor(() => expect(t.seen).toHaveLength(1)); + s.type("\x0f"); + expect(s.terminal.outputViewOpen).toBe(true); + s.type("\x03"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.terminal.outputViewOpen).toBe(false); + expect(t.seen[0]!.signal?.aborted).toBe(true); + s.type("\x0f"); + expect(s.terminal.outputViewOpen).toBe(true); + s.type("\x04"); + expect(await s.finished).toBe(0); + expect(s.terminal.outputViewOpen).toBe(false); + expect(t.seen).toHaveLength(1); }); it("asks before a command runs, and a yes runs it", async () => { @@ -613,11 +826,14 @@ describe("interactive session", () => { it("asks for the key before the first prompt, masks it, checks it and saves it", async () => { const t = new GateTransport([...done("ok")]); const envPath = freshEnvPath(); + const balanceFetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ credit_balance: 0 }))); + const checkKey = vi.fn(verifyKey); let seenKey: string | undefined; const s = session(t, { requireKey: true, envPath, - verifyKey, + verifyKey: checkKey, + balanceFetch, makeTransport: (_settings, apiKey) => { seenKey = apiKey; return t; @@ -631,8 +847,15 @@ describe("interactive session", () => { s.type("\r"); await vi.waitFor(() => expect(s.screen()).toContain("rejected this key")); expect(existsSync(envPath)).toBe(false); - s.type("sk-good\r"); + expect(balanceFetch).not.toHaveBeenCalled(); + s.type("\x1b[200~export MOTIF_API_KEY=\"sk-good\"\n\x1b[201~\r"); await vi.waitFor(() => expect(s.screen()).toContain("signed in")); + await vi.waitFor(() => expect(balanceFetch).toHaveBeenCalledTimes(1)); + expect(checkKey).toHaveBeenLastCalledWith("sk-good"); + expect(balanceFetch.mock.calls[0]).toEqual(["https://api.onerouter.pro/v1/balance", { + method: "GET", headers: { Authorization: "Bearer sk-good", Accept: "application/json" }, + redirect: "error", signal: expect.any(AbortSignal), + }]); expect(readFileSync(envPath, "utf8")).toBe("MOTIF_API_KEY=sk-good\n"); expect(statSync(envPath).mode & 0o777).toBe(0o600); expect(s.screen()).not.toContain("sk-good"); diff --git a/packages/cli/test/config-controls.test.ts b/packages/cli/test/config-controls.test.ts new file mode 100644 index 0000000..d877c6c --- /dev/null +++ b/packages/cli/test/config-controls.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { configField, parseConfigValue } from "../src/config-controls.js"; + +const parse = (key: string, raw: string) => parseConfigValue(configField(key)!, raw); + +describe("interactive configuration values", () => { + it("accepts false without treating it as missing, and normalizes documented key aliases", () => { + expect(parse("thinking", "false")).toBe(false); + expect(parse("verbose", "true")).toBe(true); + expect(parse("max-tokens", "2048")).toBe(2048); + expect(parse("MAX_OUTPUT_TOKENS", "1024")).toBe(1024); + expect(configField("unknown")).toBeUndefined(); + }); + + it("accepts safe integer boundaries and off only where supported", () => { + expect(parse("seed", "0")).toBe(0); + expect(parse("seed", String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER); + expect(parse("seed", "off")).toBeUndefined(); + expect(parse("max-tokens", "off")).toBeUndefined(); + expect(() => parse("maxTurns", "off")).toThrow(); + for (const value of ["0", "-1", "1.5", "2e3", "0x10", "Infinity", "NaN", "9007199254740992", ""]) { + expect(() => parse("max-tokens", value), value).toThrow(); + } + }); + + it("rejects control sequences and enforces endpoint URL boundaries", () => { + for (const value of ["model\x1b[2J", "foo\nbar", "foo\tbar", "foo\x00bar", "foo\x7fbar"]) { + expect(() => parse("model", value), JSON.stringify(value)).toThrow(); + } + for (const value of ["file:///tmp/socket", "https://name:password@api.example", "https://api.example?key=secret", "https://api.example#path", "not a url"]) { + expect(() => parse("endpoint", value), value).toThrow(); + } + expect(parse("endpoint", " https://api.example/v1/ ")).toBe("https://api.example"); + expect(parse("endpoint", "http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); + expect(() => parse("model", "x".repeat(2049))).toThrow(); + expect(() => parse("model", " ")).toThrow(); + }); + + it("bounds compaction fractions and restricts enum choices", () => { + expect(parse("compactAt", "0.1")).toBe(0.1); + expect(parse("compactAt", "1")).toBe(1); + for (const value of ["", "0", "0.09", "1.01", "NaN", "Infinity"]) { + expect(() => parse("compactAt", value), value).toThrow(); + } + expect(() => parse("thinking", "yes")).toThrow(); + expect(() => parse("permissions", "all")).toThrow(); + expect(() => parse("channel", "chat")).toThrow(); + }); +}); diff --git a/packages/cli/test/dashboard.test.ts b/packages/cli/test/dashboard.test.ts new file mode 100644 index 0000000..92cca75 --- /dev/null +++ b/packages/cli/test/dashboard.test.ts @@ -0,0 +1,411 @@ +/** Exercise the dashboard through the same raw keys and persistence path as an interactive session. */ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentRegistry, BUILTIN_AGENTS } from "@motifcode/agents"; +import { TransportError, type CompletionRequest, type CompletionResponse, type Transport } from "@motifcode/core"; +import { DEFAULT_HOOKS } from "@motifcode/hooks"; +import { doneBody } from "@motifcode/replay"; +import { BUILTIN_SKILLS, SkillRegistry } from "@motifcode/skills"; +import { CORE_TOOLS, toolPrefix } from "@motifcode/tools"; +import { Screen, applyTheme, type ComposerView } from "@motifcode/tui"; +import { Chat, type ChatOptions } from "../src/chat.js"; +import { saveUserSetting, userSettingsPath, type StoredSettings } from "../src/settings.js"; + +const ESC = "\x1b"; +const DOWN = "\x1b[B"; +const RIGHT = "\x1b[C"; +const LEFT = "\x1b[D"; +const SHIFT_TAB = "\x1b[Z"; +const CLEAR = "\x15"; + +class FakeTTY extends PassThrough { + isTTY = true; + setRawMode(): this { return this; } +} + +class DashboardTransport implements Transport { + readonly endpoint = "fake://dashboard"; + readonly model = "fake"; + readonly seen: CompletionRequest[] = []; + gated = false; + response = doneBody("done"); + private release: (() => void) | undefined; + + async complete(req: CompletionRequest): Promise { + this.seen.push(req); + if (this.gated) { + this.gated = false; + await new Promise((resolve, reject) => { + this.release = resolve; + req.signal?.addEventListener("abort", () => reject(new TransportError("aborted", { kind: "aborted" })), { once: true }); + }); + } + const content = this.response; + return { content, rawText: content, ms: 1, usage: { promptTokens: 100, completionTokens: 10, cachedTokens: 80 } }; + } + + open(): void { this.release?.(); } +} + +const sessions: { type: (value: string) => void; finished: Promise; cwd: string }[] = []; +function session(extra: Partial = {}) { + const cwd = mkdtempSync(join(tmpdir(), "motif-dashboard-")); + const out: string[] = []; + const stdin = new FakeTTY(); + const transport = new DashboardTransport(); + const skills = new SkillRegistry(); + skills.registerAll(BUILTIN_SKILLS); + const agents = new AgentRegistry(); + agents.registerAll(BUILTIN_AGENTS); + const terminal = new Screen({ write: (text) => out.push(text), columns: () => 100, rows: () => 30, interactive: true, cwd }); + const persist = vi.fn((key: keyof StoredSettings, value: unknown) => saveUserSetting(key, value as StoredSettings[typeof key], cwd)); + const makeTransport = vi.fn((_settings: ChatOptions["settings"]) => transport); + const chat = new Chat({ + screen: terminal, + stdin: stdin as unknown as NodeJS.ReadStream, + settings: { model: "motif/motif-3", endpoint: "https://llm.onerouter.pro", channel: "toolcall", maxTurns: 20, cwd, theme: "motif", compactAt: 0.75, permissions: "auto" }, + channelPolicy: "fixed", + skills, agents, hooks: DEFAULT_HOOKS, tools: toolPrefix(CORE_TOOLS.length - 1), + journalDir: join(cwd, ".motif", "sessions"), version: "test", hero: false, + envPath: join(cwd, ".motif", ".env"), + balanceFetch: async () => new Response(JSON.stringify({ credit_balance: 0 })), + persist, makeTransport, ...extra, + }); + const compose = vi.spyOn(terminal, "setComposer"); + const finished = chat.run(); + const type = (value: string): void => { stdin.write(value); }; + const current = (): ComposerView => compose.mock.calls.at(-1)![0]!; + const panel = () => current().panel!; + const value = (label: string) => panel().rows.find((row) => row.label === label)?.value; + const saved = (): StoredSettings => JSON.parse(readFileSync(userSettingsPath(cwd), "utf8")) as StoredSettings; + const screen = () => out.join("").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); + sessions.push({ type, finished, cwd }); + return { chat, type, current, panel, value, saved, screen, terminal, transport, persist, makeTransport, cwd }; +} + +afterEach(async () => { + for (const s of sessions) { + for (let i = 0; i < 4; i++) s.type(ESC); + s.type("\x04"); + await s.finished; + rmSync(s.cwd, { recursive: true, force: true }); + } + sessions.length = 0; + applyTheme("motif"); +}); + +describe("interactive settings dashboard", () => { + it("opens /config without a model request, toggles real display settings, and persists both boolean values", () => { + const s = session(); + s.type("/config\r"); + expect(s.panel()).toMatchObject({ activeTab: 0, selected: 0 }); + expect(s.value("Show reasoning")).toBe("false"); + s.type("\r"); + expect(s.terminal.thinkingShown).toBe(true); + expect(s.saved().thinking).toBe(true); + expect(s.panel().rows[0]!.detail).toContain("session + user file"); + s.type(DOWN + RIGHT); + expect(s.terminal.verboseOutput).toBe(true); + expect(s.saved().verbose).toBe(true); + s.type(LEFT); + expect(s.terminal.verboseOutput).toBe(false); + expect(s.saved().verbose).toBe(false); + s.type(ESC); + expect(s.current().panel).toBeUndefined(); + expect(s.transport.seen).toHaveLength(0); + expect(s.makeTransport).not.toHaveBeenCalled(); + }); + + it("keeps an invalid numeric edit open and cancels it without applying or saving", () => { + const s = session(); + s.type("/config\r" + DOWN.repeat(6) + "\r"); + expect(s.panel().editing).toMatchObject({ label: "Max turns / task", draft: { text: "20" } }); + s.type(CLEAR + "0\r"); + expect(s.panel().error).toBe(true); + expect(s.panel().editing?.draft.text).toBe("0"); + expect(s.value("Max turns / task")).toBe("20"); + expect(s.persist).not.toHaveBeenCalled(); + s.type(ESC); + expect(s.panel().editing).toBeUndefined(); + expect(s.value("Max turns / task")).toBe("20"); + s.type("\r" + CLEAR + "40"); + s.type(ESC); + expect(s.value("Max turns / task")).toBe("20"); + expect(s.persist).not.toHaveBeenCalled(); + expect(s.transport.seen).toHaveLength(0); + }); + + it("saves a numeric edit and applies it to the next request", async () => { + const s = session(); + s.type("/config\r" + DOWN.repeat(7) + "\r" + CLEAR + "512\r"); + expect(s.panel().editing).toBeUndefined(); + expect(s.value("Max output tokens")).toBe("512"); + expect(s.saved().maxOutputTokens).toBe(512); + s.type(ESC); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.transport.seen[0]!.maxTokens).toBe(512); + }); + + it("leaves runtime values unchanged when persistence fails", () => { + const persist = vi.fn(() => { throw new Error("settings path is read-only"); }); + const s = session({ persist }); + s.type("/config\r\r"); + expect(s.panel().error).toBe(true); + expect(s.panel().message).toBe("settings path is read-only"); + expect(s.terminal.thinkingShown).toBe(false); + expect(s.value("Show reasoning")).toBe("false"); + expect(s.panel().rows[0]!.detail).toContain("Source: default"); + s.type(DOWN.repeat(3) + "\r" + CLEAR + "other/model\r"); + expect(s.value("Model")).toBe("motif/motif-3"); + expect(s.panel().editing?.draft.text).toBe("other/model"); + expect(s.panel().error).toBe(true); + }); + + it("supports direct values, zero seed, removing output caps, and rejects invalid values before saving", async () => { + const s = session(); + s.type("/config model other/model\r"); + await vi.waitFor(() => expect(s.screen()).toContain("model = other/model")); + s.type("/config seed 0\r"); + await vi.waitFor(() => expect(s.screen()).toContain("seed = 0")); + s.type("/config max-tokens 123\r"); + await vi.waitFor(() => expect(s.saved().maxOutputTokens).toBe(123)); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.makeTransport.mock.calls[0]![0].model).toBe("other/model"); + expect(s.transport.seen[0]).toMatchObject({ seed: 0, maxTokens: 123 }); + s.type("/config seed -1\r"); + await vi.waitFor(() => expect(s.screen()).toContain("Enter an integer >= 0")); + expect(s.saved().seed).toBe(0); + expect(s.persist).not.toHaveBeenCalledWith("seed", -1); + s.type("/config max-tokens off\r"); + await vi.waitFor(() => expect(s.saved().maxOutputTokens).toBeUndefined()); + s.type("again\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(2)); + expect(s.transport.seen[1]!.maxTokens).toBeUndefined(); + expect(s.transport.seen[1]!.seed).toBe(0); + }); + + it("allows display changes while a task is running but blocks changes to its execution settings", async () => { + const s = session(); + s.transport.gated = true; + s.type("go\r"); + await vi.waitFor(() => expect(s.transport.seen).toHaveLength(1)); + s.type("/config\r"); + expect(s.panel().activeTab).toBe(0); + s.type("\r"); + expect(s.terminal.thinkingShown).toBe(true); + s.type(DOWN.repeat(2) + "\r"); + expect(s.panel().error).toBe(true); + expect(s.panel().message).toContain("Wait for the running task"); + expect(s.value("Tool permissions")).toBe("auto"); + expect(s.persist).not.toHaveBeenCalledWith("permissions", expect.anything()); + s.type(ESC); + expect(s.chat.running).toBe(true); + s.transport.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + }); + + it("cycles all tabs and refreshes local statistics without sending model requests", async () => { + const s = session(); + s.type("/config\r\t"); + expect(s.panel().activeTab).toBe(1); + expect(s.value("State")).toBe("Ready"); + s.type("\t"); + expect(s.panel().activeTab).toBe(2); + expect(s.value("Saved runs")).toBe("0"); + s.type("r\t"); + expect(s.panel().activeTab).toBe(3); + expect(s.value("Infron balance")).toBe("Sign in with /login"); + s.type("r\t"); + expect(s.panel().activeTab).toBe(0); + s.type(SHIFT_TAB); + expect(s.panel().activeTab).toBe(3); + s.type(LEFT); + expect(s.panel().activeTab).toBe(2); + s.type(ESC); + expect(s.transport.seen).toHaveLength(0); + expect(s.persist).not.toHaveBeenCalled(); + for (const [command, tab] of [["status", 1], ["stats", 2], ["usage", 3]] as const) { + s.type(`/${command}\r`); + expect(s.panel().activeTab).toBe(tab); + s.type(ESC); + } + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + s.type("/stats\r"); + expect(s.value("Saved runs")).toBe("1"); + expect(s.value("Completed")).toBe("1"); + s.type("\t"); + expect(s.value("Prompt tokens")).toBe("100"); + expect(s.value("Completion tokens")).toBe("10"); + expect(s.value("Cached prompt tokens")).toBe("80"); + expect(s.value("Cache ratio")).toBe("80.0%"); + expect(s.transport.seen).toHaveLength(1); + }); + + it("preserves the conversation when a channel change is attempted during manual compaction", async () => { + const s = session(); + s.type("keep this task\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + const before = [...s.chat.transcript]; + s.transport.gated = true; + s.transport.response = "HANDOFF: the original task is complete"; + s.type("/compact\r"); + try { + await vi.waitFor(() => expect(s.transport.seen).toHaveLength(2)); + expect(String(s.transport.seen[1]!.messages.at(-1)!.content)).toContain("CONTEXT CHECKPOINT COMPACTION"); + s.type("/config channel raw\r"); + await vi.waitFor(() => expect(s.screen()).toContain("Wait for the running task to finish")); + expect(s.persist).not.toHaveBeenCalled(); + expect(s.chat.transcript).toEqual(before); + s.type("/status\r"); + expect(s.value("State")).toBe("Compacting"); + expect(s.value("Channel / policy")).toBe("toolcall / fixed"); + } finally { + s.transport.open(); + } + await vi.waitFor(() => expect(s.screen()).toContain("Context compacted")); + expect(s.chat.transcript[0]!.content).toBe("keep this task"); + expect(s.chat.transcript.at(-1)!.content).toContain("HANDOFF: the original task is complete"); + expect(s.value("State")).toBe("Ready"); + s.type(ESC); + s.type("/config channel raw\r"); + await vi.waitFor(() => expect(s.saved().channel).toBe("raw")); + expect(s.chat.transcript).toEqual([]); + expect(s.transport.seen).toHaveLength(2); + }); + + it("refreshes statistics for an in-flight task after the task completes", async () => { + const s = session(); + s.transport.gated = true; + s.type("go\r"); + await vi.waitFor(() => expect(s.transport.seen).toHaveLength(1)); + s.type("/stats\r"); + expect(s.value("Active")).toBe("1"); + expect(s.value("Completed")).toBe("0"); + s.transport.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + s.type("r"); + expect(s.value("Active")).toBe("0"); + expect(s.value("Completed")).toBe("1"); + expect(s.transport.seen).toHaveLength(1); + }); +}); + +describe("account balance integration", () => { + it.each(["/logout", "/endpoint http://127.0.0.1:9000"])("does not reactivate a key after %s invalidates an in-flight login", async (command) => { + let completeVerification!: (result: { ok: true }) => void; + const verification = new Promise<{ ok: true }>((resolve) => { completeVerification = resolve; }); + const verifyKey = vi.fn(() => verification); + const balanceFetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ credit_balance: 77 }))); + const s = session({ requireKey: true, verifyKey, balanceFetch }); + s.type("fixture-pending-key\r"); + await vi.waitFor(() => expect(verifyKey).toHaveBeenCalledWith("fixture-pending-key")); + s.type(`${command}\r`); + await vi.waitFor(() => expect(s.screen()).toContain(command === "/logout" ? "no key is set" : "endpoint set to http://127.0.0.1:9000")); + completeVerification({ ok: true }); + // Drain the verifier and its awaiting login continuation before checking state. + await new Promise((resolve) => setImmediate(resolve)); + s.type("/status\r"); + expect(s.value("Authentication")).toBe("No API key · /login"); + expect(existsSync(join(s.cwd, ".motif", ".env"))).toBe(false); + expect(balanceFetch).not.toHaveBeenCalled(); + expect(s.screen()).not.toContain("fixture-pending-key"); + s.type("\t\t"); + expect(s.value(command === "/logout" ? "Infron balance" : "Account balance")) + .toBe(command === "/logout" ? "Sign in with /login" : "Not supported for this endpoint"); + }); + + it("automatically queries a loaded API key and shows its account balance separately from local usage", async () => { + const balanceFetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ credit_balance: 12.5 }))); + const verifyKey = vi.fn(async () => ({ ok: true as const })); + const s = session({ apiKey: "fixture-loaded-key", balanceFetch, verifyKey }); + await vi.waitFor(() => expect(balanceFetch).toHaveBeenCalledTimes(1)); + expect(balanceFetch.mock.calls[0]).toEqual(["https://api.onerouter.pro/v1/balance", { + method: "GET", headers: { Authorization: "Bearer fixture-loaded-key", Accept: "application/json" }, + redirect: "error", signal: expect.any(AbortSignal), + }]); + s.type("/usage\r"); + await vi.waitFor(() => expect(s.value("Infron balance")).toBe("12.5 credits")); + expect(s.value("Prompt tokens")).toBe("Not reported"); + expect(s.value("Balance checked")).toBeDefined(); + expect(s.panel().rows.find((row) => row.label === "Infron balance")?.detail).toContain("Account-wide"); + expect(s.panel().rows.find((row) => row.label === "Infron balance")?.detail).toContain("local recorded task costs"); + s.type(SHIFT_TAB + SHIFT_TAB); + expect(s.value("Authentication")).toBe("API key loaded"); + expect(verifyKey).not.toHaveBeenCalled(); + expect(s.transport.seen).toHaveLength(0); + expect(s.screen()).not.toContain("fixture-loaded-key"); + }); + + it("removes the account balance on logout, including an in-flight refresh from the previous key", async () => { + let now = 100_000; + let finishRefresh!: (response: Response) => void; + const pending = new Promise((resolve) => { finishRefresh = resolve; }); + const balanceFetch = vi.fn() + .mockImplementationOnce(async () => new Response(JSON.stringify({ credit_balance: 99 }))) + .mockImplementationOnce(() => pending); + const s = session({ apiKey: "fixture-old-key", balanceFetch, now: () => now }); + s.type("/usage\r"); + await vi.waitFor(() => expect(s.value("Infron balance")).toBe("99 credits")); + now += 5_000; + s.type("r"); + await vi.waitFor(() => expect(balanceFetch).toHaveBeenCalledTimes(2)); + expect(s.value("Infron balance")).toBe("99 credits (last known)"); + s.type(ESC); + s.type("/logout\r"); + await vi.waitFor(() => expect(s.screen()).toContain("the session no longer sends a key")); + expect(balanceFetch.mock.calls[1]![1]?.signal?.aborted).toBe(true); + s.type("/usage\r"); + expect(s.value("Infron balance")).toBe("Sign in with /login"); + finishRefresh(new Response(JSON.stringify({ credit_balance: 88 }))); + await Promise.resolve(); + await Promise.resolve(); + s.type("r"); + expect(s.value("Infron balance")).toBe("Sign in with /login"); + expect(s.value("Balance checked")).toBeUndefined(); + expect(balanceFetch).toHaveBeenCalledTimes(2); + }); + + it("stops account requests and clears the displayed balance when the endpoint changes inside /config", async () => { + const balanceFetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ credit_balance: 15 }))); + const s = session({ apiKey: "fixture-key", balanceFetch }); + s.type("/usage\r"); + await vi.waitFor(() => expect(s.value("Infron balance")).toBe("15 credits")); + s.type(ESC); + s.type("/config\r" + DOWN.repeat(4) + "\r" + CLEAR + "http://127.0.0.1:9000\r"); + expect(s.value("Endpoint")).toBe("http://127.0.0.1:9000"); + expect(s.saved().endpoint).toBe("http://127.0.0.1:9000"); + s.type(SHIFT_TAB); + expect(s.value("Account balance")).toBe("Not supported for this endpoint"); + expect(s.value("Infron balance")).toBeUndefined(); + expect(s.value("Balance checked")).toBeUndefined(); + s.type("r"); + expect(balanceFetch).toHaveBeenCalledTimes(1); + s.type(ESC); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.makeTransport.mock.calls[0]![0].endpoint).toBe("http://127.0.0.1:9000"); + expect(balanceFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps model tasks working when the balance service fails and displays a sanitized error", async () => { + const balanceFetch = vi.fn().mockRejectedValue(new Error("fixture-sensitive-key from server")); + const s = session({ apiKey: "fixture-sensitive-key", balanceFetch }); + s.type("/usage\r"); + await vi.waitFor(() => expect(s.value("Balance status")).toBe("Could not reach the balance service.")); + expect(s.value("Infron balance")).toBe("Could not refresh"); + expect(s.screen()).not.toContain("fixture-sensitive-key"); + s.type(ESC); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.transport.seen).toHaveLength(1); + s.type("/usage\r"); + expect(s.value("Completion tokens")).toBe("10"); + expect(s.value("Balance status")).toBe("Could not reach the balance service."); + }); +}); diff --git a/packages/cli/test/insights.test.ts b/packages/cli/test/insights.test.ts new file mode 100644 index 0000000..564d999 --- /dev/null +++ b/packages/cli/test/insights.test.ts @@ -0,0 +1,259 @@ +import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectInsights, statsRows, usageRows } from "../src/insights.js"; + +const NOW = Date.parse("2026-09-26T12:00:00Z"); +const directories: string[] = []; +function directory(): string { + const dir = mkdtempSync(join(tmpdir(), "motif-insights-")); + directories.push(dir); + return dir; +} +afterEach(() => { for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +function fixture(runId: string, started = NOW - 60_000, model = "motif/motif-3") { + const lines: unknown[] = [{ t: "header", header: { schemaVersion: 2, runId, startedAt: new Date(started).toISOString(), model: { id: model } } }]; + let seq = 0; + return { + record(record: unknown, scope = "root", at = started + (seq + 1) * 1000) { + lines.push({ v: 2, seq: ++seq, at: new Date(at).toISOString(), runId, scopeId: scope, scopeKind: scope === "root" ? "root" : "subagent", record }); + }, + event(event: unknown, scope = "root") { this.record({ t: "event", event }, scope); }, + end(reason: string, scope = "root", at?: number) { this.record({ t: "scope_end", result: { endReason: reason, turns: 999 } }, scope, at); }, + text: () => lines.map((line) => JSON.stringify(line)).join("\n") + "\n", + save(dir: string, name = `${runId}.jsonl`, suffix = "") { + const path = join(dir, name); + writeFileSync(path, this.text() + suffix); + return path; + }, + }; +} + +describe("local journal insights", () => { + it("counts root and child events exactly once while only the root ending determines the outcome", () => { + const dir = directory(); + const run = fixture("parent"); + run.record({ t: "scope_start", task: "task", initialMessages: [] }); + run.event({ type: "session_start", model: "motif/motif-3" }); + run.event({ type: "turn_start", turn: 1 }); + run.event({ type: "usage", promptTokens: 100, completionTokens: 20, cachedTokens: 80, contextTokens: 8000, kvBytes: 999, requestMs: 500 }); + run.event({ type: "tool_start", call: { id: "root-1", name: "task" } }); + run.record({ t: "scope_start", task: "child", initialMessages: [] }, "child"); + run.event({ type: "session_start", model: "child-model" }, "child"); + run.event({ type: "turn_start", turn: 1 }, "child"); + run.event({ type: "usage", promptTokens: 50, completionTokens: 10, cachedTokens: 0, contextTokens: 4000, requestMs: 100 }, "child"); + run.event({ type: "tool_start", call: { id: "child-1", name: "read" } }, "child"); + run.event({ type: "tool_end", id: "child-1", ok: true, ms: 5 }, "child"); + run.event({ type: "session_end", reason: "done" }, "child"); + run.end("done", "child"); + run.record({ t: "checkpoint", state: { turn: 999, toolCalls: 999, usage: { promptTokens: 99999 } } }); + run.event({ type: "session_end", reason: "transport_error" }); + run.end("transport_error", "root", NOW - 20_000); + run.save(dir); + + const data = collectInsights(dir, { now: NOW }); + expect(data).toMatchObject({ sessions: 1, completed: 0, errors: 1, turns: 2, toolCalls: 2, durationMs: 40_000, recentSessions: 1 }); + expect(data.usage).toMatchObject({ events: 2, promptTokens: 150, completionTokens: 30, cachedTokens: 80, pairedPromptTokens: 150 }); + expect(data.models.find((m) => m.model === "child-model")).toMatchObject({ sessions: 1, turns: 1, toolCalls: 1, promptTokens: 50 }); + expect(usageRows(data).find((r) => r.label === "Cache ratio")?.value).toBe("53.3%"); + expect(statsRows(data).find((r) => r.label === "Completed")?.detail).toContain("not a test"); + }); + + it("keeps active, aborted, failed, limited and unended root runs separate", () => { + const dir = directory(); + for (const reason of ["done", "aborted", "transport_error", "turn_limit", "loop_detected", "breakage_limit", "no_action_limit"]) { + const run = fixture(reason); + run.event({ type: "turn_start", turn: 1 }); + run.end(reason); + run.save(dir); + } + const active = fixture("active"); + active.event({ type: "turn_start", turn: 1 }); + const activePath = active.save(dir); + const lost = fixture("lost"); + lost.end("done", "child"); + lost.save(dir); + const data = collectInsights(dir, { now: NOW, activePath }); + expect(data).toMatchObject({ sessions: 9, completed: 1, active: 1, interrupted: 1, errors: 1, stopped: 4, unfinished: 1, partialDurations: 2 }); + }); + + it("distinguishes absent usage from zero counts and uses only paired cache reports", () => { + const dir = directory(); + const run = fixture("partial"); + run.event({ type: "turn_start", turn: 1 }); + run.event({ type: "usage", contextTokens: 999999, requestMs: 1 }); + run.event({ type: "usage", promptTokens: 100, completionTokens: 0, cachedTokens: 80 }); + run.event({ type: "usage", promptTokens: 900, completionTokens: 5 }); + run.event({ type: "usage", cachedTokens: 5 }); + run.end("done"); + run.save(dir); + const data = collectInsights(dir, { now: NOW }); + expect(data.usage).toMatchObject({ events: 4, promptTokens: 1000, completionTokens: 5, cachedTokens: 85, promptReports: 2, completionReports: 2, pairedReports: 1 }); + expect(usageRows(data).find((r) => r.label === "Cache ratio")).toMatchObject({ value: "80.0%", detail: expect.stringContaining("1/4 events") }); + const empty = collectInsights(join(dir, "missing"), { now: NOW }); + expect(usageRows(empty).find((r) => r.label === "Prompt tokens")?.value).toBe("Not reported"); + expect(usageRows(empty).find((r) => r.label === "Reported cost")?.value).toBe("Not reported"); + expect(usageRows(empty).find((r) => r.label === "API quota / billing")).toBeUndefined(); + }); + + it("counts server-reported root and child costs once, preserves zero, and shows local coverage", () => { + const dir = directory(); + const run = fixture("costs"); + run.event({ type: "session_start", model: "motif/motif-3" }); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: 0.1, details: { prompt_cost: 0.1 } } }); + run.event({ type: "session_start", model: "child-model" }, "child"); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: 0.2, details: { completion_cost: 0.2 } } }, "child"); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: 0 } }); + run.event({ type: "usage", promptTokens: 100 }); + run.record({ t: "checkpoint", state: { reportedCost: { provider: "infron", unit: "credits", amount: 999 } } }); + run.end("done"); + run.save(dir); + run.save(dir, "copy.jsonl"); + + const data = collectInsights(dir); + expect(data.files.duplicate).toBe(1); + expect(data.usage.cost).toMatchObject({ provider: "infron", unit: "credits", reports: 3, invalidReports: 0, otherReports: 0, overflow: false }); + expect(data.usage.cost.amount).toBeCloseTo(0.3); + expect(data.models.find((model) => model.model === "child-model")?.cost).toMatchObject({ amount: 0.2, reports: 1 }); + const rows = usageRows(data); + expect(rows.find((row) => row.label === "Reported cost")).toMatchObject({ value: "0.3 credits", detail: expect.stringContaining("not account-wide") }); + expect(rows.find((row) => row.label === "Cost coverage")).toMatchObject({ value: "3/4 usage events", detail: expect.stringContaining("not estimated") }); + expect(rows.find((row) => row.label === "child-model")?.detail).toContain("cost 0.2 credits (1/1 events"); + }); + + it("distinguishes zero cost from historical usage without cost and ignores cost detail subtotals", () => { + const dir = directory(); + const run = fixture("zero"); + run.event({ type: "usage", promptTokens: 10 }); + run.save(dir); + expect(usageRows(collectInsights(dir)).find((row) => row.label === "Reported cost")?.value).toBe("Not reported"); + // The amount is valid even if a historical detail object is not; details are never summed. + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: 0, details: { prompt_cost: "invalid", total_cost: 100 } } }); + run.save(dir); + const data = collectInsights(dir); + expect(data.usage.cost).toMatchObject({ amount: 0, reports: 1, invalidReports: 0 }); + expect(usageRows(data).find((row) => row.label === "Reported cost")?.value).toBe("0 credits"); + expect(usageRows(data).find((row) => row.label === "Cost coverage")?.value).toBe("1/2 usage events"); + }); + + it("excludes malformed costs and separates unrecognized providers and units", () => { + const dir = directory(); + const run = fixture("invalid-costs"); + for (const reportedCost of [ + null, 12, [], {}, + { provider: "infron", unit: "credits", amount: -1 }, + { provider: "infron", unit: "credits", amount: "0.2" }, + { provider: "infron", unit: "credits", amount: null }, + { provider: "infron", unit: "usd", amount: 100 }, + { provider: "other", unit: "credits", amount: 200 }, + { provider: "infron", unit: "credits", amount: 0.125 }, + ]) run.event({ type: "usage", reportedCost }); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: "INFINITE" } }); + // JSON allows a numeric exponent outside the finite JavaScript range. + writeFileSync(join(dir, "invalid-costs.jsonl"), run.text().replace('"INFINITE"', "1e999")); + const data = collectInsights(dir); + expect(data.usage.cost).toMatchObject({ amount: 0.125, reports: 1, invalidReports: 8, otherReports: 2 }); + expect(usageRows(data).find((row) => row.label === "Excluded cost records")).toMatchObject({ value: "10", detail: expect.stringContaining("2 other providers or units") }); + expect(usageRows(data).find((row) => row.label === "Reported cost")?.value).toBe("0.125 credits"); + }); + + it("never presents an overflowing total as Infinity or a tiny positive charge as free", () => { + const dir = directory(); + const run = fixture("large-costs"); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: Number.MAX_VALUE } }); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: Number.MAX_VALUE } }); + run.event({ type: "session_start", model: "tiny-model" }, "child"); + run.event({ type: "usage", reportedCost: { provider: "infron", unit: "credits", amount: 0.000000001 } }, "child"); + run.save(dir); + const data = collectInsights(dir); + expect(data.usage.cost).toMatchObject({ reports: 3, overflow: true }); + expect(Number.isFinite(data.usage.cost.amount)).toBe(true); + const rows = usageRows(data); + expect(rows.find((row) => row.label === "Reported cost")).toMatchObject({ value: "Unavailable (total overflow)", tone: "warn" }); + expect(rows.find((row) => row.label === "tiny-model")?.detail).toContain("cost <0.00000001 credits"); + expect(JSON.stringify(rows)).not.toContain("Infinity"); + }); + + it("ignores invalid token values instead of reporting negative counts or cache rates over 100%", () => { + const dir = directory(); + const run = fixture("invalid-usage"); + run.event({ type: "usage", promptTokens: 10, completionTokens: -3, cachedTokens: 20 }); + run.event({ type: "usage", promptTokens: "50", completionTokens: 1.5, cachedTokens: null }); + run.end("done"); + run.save(dir); + const data = collectInsights(dir); + expect(data.usage).toMatchObject({ promptTokens: 10, promptReports: 1, completionReports: 0, cachedReports: 0, invalidReports: 5 }); + expect(usageRows(data).find((r) => r.label === "Cache ratio")?.value).toBe("Unavailable"); + expect(usageRows(data).find((r) => r.label === "Invalid usage fields")?.value).toBe("5"); + }); + + it("includes a truncated final line, but excludes corrupt middle records and malformed envelopes explicitly", () => { + const dir = directory(); + const truncated = fixture("truncated"); + truncated.event({ type: "usage", promptTokens: 12 }); + truncated.save(dir, undefined, '{"v":2'); + const corrupt = fixture("corrupt"); + corrupt.event({ type: "usage", promptTokens: 9999 }); + corrupt.save(dir, undefined, 'not-json\n{}\n'); + writeFileSync(join(dir, "null.jsonl"), "null\n"); + const invalid = fixture("invalid"); + invalid.record(null); + invalid.save(dir); + const repeated = fixture("repeated"); + repeated.event({ type: "turn_start", turn: 1 }); + const text = repeated.text(); + writeFileSync(join(dir, "repeated.jsonl"), text + text.split("\n")[1] + "\n"); + const data = collectInsights(dir); + expect(data).toMatchObject({ sessions: 1, unfinished: 1, usage: { promptTokens: 12 }, files: { corrupt: 4, truncated: 1 } }); + expect(statsRows(data).find((r) => r.label === "Skipped journals")?.value).toBe("4"); + expect(usageRows(data).find((r) => r.label === "Partial journals")?.value).toBe("1"); + }); + + it("deduplicates copied run IDs, preferring the active journal or newest saved copy", () => { + const dir = directory(); + const run = fixture("same-run"); + run.event({ type: "turn_start", turn: 1 }); + const activePath = run.save(dir, "original.jsonl"); + utimesSync(activePath, new Date(NOW - 10000), new Date(NOW - 10000)); + run.end("done"); + const copy = run.save(dir, "copy.jsonl"); + utimesSync(copy, new Date(NOW), new Date(NOW)); + expect(collectInsights(dir, { now: NOW })).toMatchObject({ sessions: 1, completed: 1, turns: 1, files: { duplicate: 1 } }); + expect(collectInsights(dir, { now: NOW, activePath })).toMatchObject({ sessions: 1, completed: 0, active: 1, turns: 1, files: { duplicate: 1 } }); + }); + + it("uses start timestamps for the seven-day window and exposes unavailable durations", () => { + const dir = directory(); + for (const [name, start] of [["boundary", NOW - 7 * 86400000], ["old", NOW - 7 * 86400000 - 1], ["future", NOW + 1000]] as const) { + const run = fixture(name, start); + run.end("done", "root", start + 1000); + run.save(dir); + } + const invalid = fixture("invalid-time"); + invalid.end("done"); + writeFileSync(join(dir, "invalid-time.jsonl"), invalid.text().replace(/"startedAt":"[^"]+"/, '"startedAt":"invalid"')); + const data = collectInsights(dir, { now: NOW }); + expect(data).toMatchObject({ sessions: 4, recentSessions: 1, timedSessions: 3, durationMs: 3000 }); + expect(statsRows(data).find((r) => r.label === "Observed runtime")?.detail).toContain("3/4 runs"); + }); + + it("bounds files and oversized reads and reports partial totals", () => { + const dir = directory(); + for (let i = 0; i < 201; i++) { + const run = fixture(`run-${i}`); + run.end("done"); + run.save(dir); + } + const oversized = join(dir, "oversized.jsonl"); + const fd = openSync(oversized, "w"); + ftruncateSync(fd, 8 * 1024 * 1024 + 1); + closeSync(fd); + utimesSync(oversized, new Date(NOW + 86400000), new Date(NOW + 86400000)); + const data = collectInsights(dir); + expect(data.sessions).toBe(199); + expect(data.files).toMatchObject({ found: 202, read: 199, oversized: 1, limited: 2 }); + expect(statsRows(data).find((r) => r.label === "Read limits")).toMatchObject({ value: "Partial totals", tone: "warn" }); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7f292fe..ae5a577 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -102,6 +102,13 @@ export function normalizeEndpoint(raw: string): string { return raw.trim().replace(/\/+$/, "").replace(/\/v1$/, ""); } +/** Only the official Infron base URL supports its credits/accounting API. */ +export function isInfronEndpoint(endpoint: string): boolean { + const value = endpoint.trim(); + const origin = /^https:\/\/llm\.onerouter\.pro(?=\/|$)/i.exec(value)?.[0]; + return origin !== undefined && ["", "/", "/v1", "/v1/"].includes(value.slice(origin.length)); +} + export function defaultDotenvPaths(cwd = process.cwd()): string[] { return [join(cwd, ".env"), join(homedir(), ".motif", ".env")]; } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 42cd781..24a862f 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -11,7 +11,7 @@ * the recovery loop that a pruned checkpoint depends on. */ -import type { Action, ChannelId } from "@motifcode/protocol"; +import type { Action, ChannelId, ReportedCost } from "@motifcode/protocol"; export interface ToolInvocation { /** Synthesised by the harness — the model never emits ids. */ @@ -85,7 +85,8 @@ export type LoopEvent = } | { type: "parse_failure"; kind: ParseFailureKind; sample: string } | { type: "channel_downgrade"; from: ChannelId; to: ChannelId; reason: string } - | { type: "queue"; agent: string; state: "queued" | "running" | "done" } + /** `failed` covers a child that ended without calling `done`, not only one that threw. */ + | { type: "queue"; agent: string; state: "queued" | "running" | "done" | "failed" } | { type: "prefix"; sharedChars: number; totalChars: number; invalidatedBy?: string } | { type: "usage"; @@ -101,6 +102,8 @@ export type LoopEvent = * server's own claim, and the only one that is a cache measurement. */ cachedTokens?: number; + /** Provider-reported charge for this request, when available. */ + reportedCost?: ReportedCost; /** * Whole-request wall time, prefill and queueing included. * diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 72222ed..131da4f 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -585,6 +585,7 @@ export async function runLoop(opts: LoopOptions): Promise { promptTokens: response.usage?.promptTokens, completionTokens: response.usage?.completionTokens, ...(response.usage?.cachedTokens !== undefined ? { cachedTokens: response.usage.cachedTokens } : {}), + ...(response.usage?.reportedCost ? { reportedCost: response.usage.reportedCost } : {}), requestMs: response.ms, }); if ( diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index 11c18c5..bf587f5 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -34,10 +34,11 @@ import { type CompletionRequest, type CompletionResponse, type Message, + type ReportedCost, type StreamDelta, type ToolCall, } from "@motifcode/protocol"; -import { normalizeEndpoint } from "./config.js"; +import { isInfronEndpoint, normalizeEndpoint } from "./config.js"; export type { CompletionRequest, CompletionResponse }; @@ -303,12 +304,14 @@ export function requestUrl(req: CompletionRequest, endpoint: string): string { export class HttpTransport implements Transport { readonly endpoint: string; readonly model: string; + private readonly infronAccounting: boolean; private readonly apiKey?: string; private readonly fetchImpl: typeof fetch; private readonly requestTimeoutMs: number; constructor(opts: HttpTransportOptions) { this.endpoint = normalizeEndpoint(opts.endpoint); + this.infronAccounting = isInfronEndpoint(opts.endpoint); this.model = opts.model; this.apiKey = opts.apiKey; this.fetchImpl = opts.fetchImpl ?? globalThis.fetch; @@ -319,6 +322,7 @@ export class HttpTransport implements Transport { const started = Date.now(); const url = requestUrl(req, this.endpoint); const body = requestBody(req, this.model); + if (this.infronAccounting) body["usage"] = { include: true }; const headers: Record = { "content-type": "application/json" }; if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`; @@ -394,6 +398,8 @@ export class HttpTransport implements Transport { let json: { choices?: ChatChoice[]; + cost?: unknown; + cost_details?: unknown; usage?: Record & { prompt_tokens_details?: { cached_tokens?: number } }; }; // A server — or a proxy in front of one — may answer a streaming request @@ -445,6 +451,7 @@ export class HttpTransport implements Transport { // prefix cache. That is the first direct measurement this harness has had // of the thing its frozen tool order exists to protect, so it is kept. const cached = json.usage?.prompt_tokens_details?.cached_tokens; + const cost = this.infronAccounting ? parseCost(json) : undefined; return { content, ...(reasoning !== undefined && reasoning !== null ? { reasoningContent: reasoning } : {}), @@ -455,6 +462,7 @@ export class HttpTransport implements Transport { promptTokens: numberOr(json.usage?.["prompt_tokens"]), completionTokens: numberOr(json.usage?.["completion_tokens"]), ...(typeof cached === "number" ? { cachedTokens: cached } : {}), + ...(cost ? { reportedCost: { provider: "infron" as const, unit: "credits" as const, ...cost } } : {}), }, ms: Date.now() - started, }; @@ -465,7 +473,28 @@ function numberOr(v: unknown): number | undefined { return typeof v === "number" ? v : undefined; } +// Infron documents these top-level fields. Do not keep arbitrary metadata +// (including strings) from a provider response in journals or usage displays. +const COST_DETAIL_KEYS = [ + "audio_cost", "cache_prompt_cost", "cache_write_cost", "generation_cost", + "image_cost", "input_prompt_cost", "output_prompt_cost", "tools_cost", "video_cost", +] as const; + +function parseCost(raw: { cost?: unknown; cost_details?: unknown }): Pick | undefined { + if (typeof raw.cost !== "number" || !Number.isFinite(raw.cost) || raw.cost < 0) return undefined; + const details: Record = {}; + if (typeof raw.cost_details === "object" && raw.cost_details !== null && !Array.isArray(raw.cost_details)) { + for (const key of COST_DETAIL_KEYS) { + const value = (raw.cost_details as Record)[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) details[key] = value; + } + } + return { amount: raw.cost, ...(Object.keys(details).length > 0 ? { details } : {}) }; +} + interface StreamChunk { + cost?: unknown; + cost_details?: unknown; choices?: { delta?: { content?: string | null; @@ -492,7 +521,7 @@ async function readStream( res: Response, onDelta: (d: StreamDelta) => void, signal: AbortSignal | undefined, -): Promise<{ choices?: ChatChoice[]; usage?: Record & { prompt_tokens_details?: { cached_tokens?: number } } }> { +): Promise<{ choices?: ChatChoice[]; cost?: number; cost_details?: Record; usage?: Record & { prompt_tokens_details?: { cached_tokens?: number } } }> { if (!res.body) throw new TransportError("server sent no body to stream", { kind: "protocol" }); const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -502,6 +531,7 @@ async function readStream( let sawReasoning = false; let finish: string | undefined; let usage: Record | undefined; + let cost: Pick | undefined; const calls = new Map(); const handle = (line: string): boolean => { @@ -516,7 +546,11 @@ async function readStream( // for shape by the caller. return false; } + if (typeof chunk !== "object" || chunk === null) return false; if (chunk.usage) usage = chunk.usage; + // Accounting may arrive after finish_reason, in a chunk with no choices. + // Each value is a request total: keep the last valid report, never sum it. + cost = parseCost(chunk) ?? cost; const choice = chunk.choices?.[0]; if (!choice) return false; if (choice.finish_reason) finish = choice.finish_reason; @@ -545,6 +579,7 @@ async function readStream( return false; }; + let finished = false; for (;;) { if (signal?.aborted) { await reader.cancel().catch(() => undefined); @@ -554,16 +589,18 @@ async function readStream( if (done) break; buffer += decoder.decode(value, { stream: true }); let nl = buffer.indexOf("\n"); - let finished = false; while (nl !== -1) { const line = buffer.slice(0, nl).replace(/\r$/, ""); buffer = buffer.slice(nl + 1); - if (handle(line)) finished = true; + if (handle(line)) { + finished = true; + break; + } nl = buffer.indexOf("\n"); } if (finished) break; } - if (buffer.trim() !== "") handle(buffer.trim()); + if (!finished && buffer.trim() !== "") handle(buffer.trim()); const toolCalls = [...calls.entries()] .sort((a, b) => a[0] - b[0]) @@ -579,6 +616,7 @@ async function readStream( ...(finish !== undefined ? { finish_reason: finish } : {}), }, ], + ...(cost ? { cost: cost.amount, ...(cost.details ? { cost_details: cost.details } : {}) } : {}), ...(usage ? { usage: usage as Record & { prompt_tokens_details?: { cached_tokens?: number } } } : {}), }; } diff --git a/packages/core/test/infron-cost.test.ts b/packages/core/test/infron-cost.test.ts new file mode 100644 index 0000000..525db0b --- /dev/null +++ b/packages/core/test/infron-cost.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { HttpTransport, isInfronEndpoint, runLoop, type LoopEvent } from "../src/index.js"; + +const endpoint = "https://llm.onerouter.pro"; +const req = { messages: [{ role: "user" as const, content: "hello" }], tools: [] }; +const answer = { choices: [{ message: { content: "Hello" }, finish_reason: "stop" }] }; +const credits = (amount: number) => ({ provider: "infron", unit: "credits", amount }); + +function json(payload: unknown): Response { + return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } }); +} + +function sse(chunks: (object | string)[], chunkSize = 17): Response { + const bytes = new TextEncoder().encode(chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`).join("")); + return new Response(new ReadableStream({ start(controller) { + // Split inside JSON and SSE lines, as real network reads do. + for (let i = 0; i < bytes.length; i += chunkSize) controller.enqueue(bytes.subarray(i, i + chunkSize)); + controller.close(); + } }), { headers: { "content-type": "text/event-stream" } }); +} + +function transport(response: () => Response, base = endpoint): HttpTransport { + return new HttpTransport({ endpoint: base, model: "m", fetchImpl: async () => response() }); +} + +const contentChunk = { choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] }; + +describe("Infron endpoint recognition", () => { + it("accepts only the official HTTPS origin and supported base paths", () => { + for (const value of [endpoint, `${endpoint}/`, `${endpoint}/v1`, `${endpoint}/v1/`, " HTTPS://LLM.ONEROUTER.PRO/v1 "]) { + expect(isInfronEndpoint(value), value).toBe(true); + } + for (const value of [ + "http://llm.onerouter.pro", "https://llm.onerouter.pro.evil.test", "https://evil.test/llm.onerouter.pro", + "https://user:pass@llm.onerouter.pro", "https://@llm.onerouter.pro", `${endpoint}:8443`, `${endpoint}:443`, + `${endpoint}/v1/chat/completions`, `${endpoint}/V1`, `${endpoint}//`, `${endpoint}/v1/..`, `${endpoint}/%76%31`, + `${endpoint}?`, `${endpoint}#`, `${endpoint}/v1?key=secret`, `${endpoint}/v1#secret`, "http://localhost:8000", + ]) expect(isInfronEndpoint(value), value).toBe(false); + }); + + it("opts into accounting only for Infron, with existing stream options intact", async () => { + for (const base of [`${endpoint}/v1/`, "http://localhost:8000", "https://api.openai.com/v1", `${endpoint}/proxy`]) { + for (const streaming of [false, true]) { + let body: Record = {}; + const t = new HttpTransport({ endpoint: base, model: "m", fetchImpl: async (_url, init) => { + body = JSON.parse(String(init?.body)) as Record; + return json({ ...answer, cost: 3 }); + } }); + const response = await t.complete({ ...req, ...(streaming ? { onDelta: () => {} } : {}) }); + expect(body["usage"]).toEqual(isInfronEndpoint(base) ? { include: true } : undefined); + expect(body["stream_options"]).toEqual(streaming ? { include_usage: true } : undefined); + expect(response.usage?.reportedCost).toEqual(isInfronEndpoint(base) ? credits(3) : undefined); + } + } + }); +}); + +describe("provider-reported request costs", () => { + it("keeps the nonstream total and only documented, finite, nonnegative cost components", async () => { + const response = await transport(() => json({ + ...answer, cost: 0.000051, + usage: { prompt_tokens: 20, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 8 } }, + cost_details: { + audio_cost: 0, cache_prompt_cost: 0.00000805, cache_write_cost: -1, generation_cost: "0.1", + image_cost: null, input_prompt_cost: 0.0000028, output_prompt_cost: 0.00004, tools_cost: {}, video_cost: [], + secret: "must not persist", unexpected_charge: 99, + }, + })).complete(req); + expect(response.usage).toEqual({ promptTokens: 20, completionTokens: 4, cachedTokens: 8, + reportedCost: { ...credits(0.000051), details: { audio_cost: 0, cache_prompt_cost: 0.00000805, input_prompt_cost: 0.0000028, output_prompt_cost: 0.00004 } }, + }); + }); + + it.each([false, true])("preserves valid zero and never turns missing or malformed costs into zero (stream=%s)", async (streaming) => { + for (const cost of ["0", "0.125", undefined, "null", '"0.125"', "-1", "1e999", "{}", "[]"]) { + const raw = cost === undefined ? "{}" : `{"cost":${cost}}`; + const body = JSON.stringify(answer).slice(0, -1) + (cost === undefined ? "" : `,"cost":${cost}`) + "}"; + const response = await transport(() => streaming + ? sse([contentChunk, raw, "[DONE]"]) + : new Response(body, { headers: { "content-type": "application/json" } }), + ).complete({ ...req, ...(streaming ? { onDelta: () => {} } : {}) }); + const valid = cost === "0" || cost === "0.125"; + expect(response.content).toBe("Hello"); + expect(response.usage?.reportedCost, String(cost)).toEqual(valid ? credits(Number(cost)) : undefined); + } + }); + + it("collects the final accounting-only SSE chunk after finish_reason and emits its total once", async () => { + const events: LoopEvent[] = []; + const cost = { choices: [], cost: 0.25, cost_details: { input_prompt_cost: 0.1, output_prompt_cost: 0.15, private_data: "omit" } }; + const t = transport(() => sse([ + { choices: [{ delta: { content: "Hel" } }] }, + { choices: [{ delta: { content: "lo" }, finish_reason: "stop" }] }, + { ...cost, usage: { prompt_tokens: 20, completion_tokens: 4 } }, + cost, // Repeated request totals are not separate charges. + { choices: [], usage: { prompt_tokens: 20, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 8 } } }, + "[DONE]", { choices: [], cost: 999 }, + ], 4096)); // Include bytes after [DONE] in the same network read. + const result = await runLoop({ transport: t, tools: [], system: () => "sys", userTask: "hi", + executor: { run: async () => ({ ok: true, output: "" }) }, emit: (event) => events.push(event), stream: true, replyEnds: true, + }); + expect(result.reason).toBe("done"); + const usage = events.filter((event) => event.type === "usage"); + expect(usage).toHaveLength(1); + expect(usage[0]).toMatchObject({ promptTokens: 20, completionTokens: 4, cachedTokens: 8, + reportedCost: { ...credits(0.25), details: { input_prompt_cost: 0.1, output_prompt_cost: 0.15 } }, + }); + expect(events.filter((event) => event.type === "stream").map((event) => event.content)).toEqual(["Hel", "lo"]); + }); + + it("does not interpret another provider's streaming cost or nested usage.cost as Infron credits", async () => { + const other = await transport(() => sse([contentChunk, { choices: [], cost: 4 }, "[DONE]"]), "https://other.test/v1") + .complete({ ...req, onDelta: () => {} }); + expect(other.usage).not.toHaveProperty("reportedCost"); + const nested = await transport(() => json({ ...answer, usage: { cost: 4 }, cost_details: { input_prompt_cost: 4 } })).complete(req); + expect(nested.usage).not.toHaveProperty("reportedCost"); + }); + + it("omits malformed details while preserving the request total", async () => { + for (const details of [null, "secret", [], { output_prompt_cost: "secret" }]) { + const response = await transport(() => json({ ...answer, cost: 0, cost_details: details })).complete(req); + expect(response.usage?.reportedCost).toEqual(credits(0)); + } + }); +}); diff --git a/packages/protocol/src/wire.ts b/packages/protocol/src/wire.ts index 098ae7f..baf0fde 100644 --- a/packages/protocol/src/wire.ts +++ b/packages/protocol/src/wire.ts @@ -50,6 +50,15 @@ export interface StreamDelta { tool?: string; } +/** Provider-reported charge for one request, never a token-price estimate. */ +export interface ReportedCost { + provider: "infron"; + unit: "credits"; + amount: number; + /** Documented numeric charge components only. */ + details?: Record; +} + export interface CompletionResponse { /** Assistant text with reasoning already separated, when the server did it. */ content: string; @@ -75,6 +84,7 @@ export interface CompletionResponse { completionTokens?: number; /** Prompt tokens the server says it served from its prefix cache, when it says. */ cachedTokens?: number; + reportedCost?: ReportedCost; }; /** Wall-clock for the request, used for the tok/s readout. */ ms: number; diff --git a/packages/tui/src/cells.ts b/packages/tui/src/cells.ts index 006b8a1..ad945e3 100644 --- a/packages/tui/src/cells.ts +++ b/packages/tui/src/cells.ts @@ -35,7 +35,7 @@ export type Cell = | { kind: "repair"; reason: string; attempt: number; max: number } | { kind: "breakage"; kindOf: ParseFailureKind; sample: string } | { kind: "downgrade"; from: string; to: string; reason: string } - | { kind: "queue"; agent: string; state: "queued" | "running" | "done" } + | { kind: "queue"; agent: string; state: "queued" | "running" | "done" | "failed" } | { kind: "notice"; level: "info" | "warn" | "error"; text: string } | { kind: "loop"; signature: string; repeats: number } | { kind: "end"; reason: string; summary?: string } diff --git a/packages/tui/src/composer.ts b/packages/tui/src/composer.ts index 981f05b..0f1135c 100644 --- a/packages/tui/src/composer.ts +++ b/packages/tui/src/composer.ts @@ -257,6 +257,8 @@ export interface ComposerRow { /** The prompt on the first row, blanks of the same width after that. */ prefix: string; body: string; + /** UTF-16 slice offsets of the command fragment within this plain body. */ + commandRange?: { start: number; end: number }; } export interface ComposerRender { @@ -272,6 +274,8 @@ export interface ComposerRenderOptions { width: number; prompt?: string; placeholder?: string; + /** Recognized command token, in code points of the unstyled draft. */ + commandRange?: { start: number; end: number }; } const DEFAULT_PROMPT = "❯ "; @@ -307,11 +311,13 @@ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOpti let cursorCol = prefixWidth; let index = 0; // code-point index of the character about to be placed let body = ""; + let commandRange: ComposerRow["commandRange"]; let used = 0; const flush = (): void => { - rows.push({ prefix: rows.length === 0 ? prompt : continuation, body }); + rows.push({ prefix: rows.length === 0 ? prompt : continuation, body, ...(commandRange ? { commandRange } : {}) }); body = ""; + commandRange = undefined; used = 0; }; @@ -336,6 +342,9 @@ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOpti cursorRow = rows.length; cursorCol = prefixWidth + used; } + if (opts.commandRange && index >= opts.commandRange.start && index < opts.commandRange.end) { + commandRange = { start: commandRange?.start ?? body.length, end: body.length + shown.length }; + } body += shown; used += w; first = false; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 606a9e8..da41639 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -9,5 +9,6 @@ export * from "./theme.js"; export * from "./keys.js"; export * from "./composer.js"; export * from "./menu.js"; +export * from "./panel.js"; export * from "./mention.js"; export * from "./screen.js"; diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 1bfe5d2..e3c3511 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -35,6 +35,8 @@ export type Key = | { type: "right" } | { type: "home" } | { type: "end" } + | { type: "page-up" } + | { type: "page-down" } | { type: "word-left" } | { type: "word-right" } | { type: "delete-word" } @@ -76,6 +78,10 @@ function csiKey(params: string, final: string): Key | null { return { type: "end" }; case "3": return { type: "delete" }; + case "5": + return { type: "page-up" }; + case "6": + return { type: "page-down" }; default: return null; } diff --git a/packages/tui/src/panel.ts b/packages/tui/src/panel.ts new file mode 100644 index 0000000..c7a5ad0 --- /dev/null +++ b/packages/tui/src/panel.ts @@ -0,0 +1,128 @@ +/** A bounded settings/status panel. The controller owns navigation and edits. */ +import { renderComposer, type ComposerSnapshot } from "./composer.js"; +import { sanitize } from "./sanitize.js"; +import { NO_COLOR, paint, style } from "./theme.js"; +import { displayWidth, expandTabs, truncateToWidth, wrapToWidth } from "./width.js"; + +export interface PanelView { + tabs: string[]; + activeTab: number; + subtitle?: string; + rows: { label: string; value?: string; detail?: string; tone?: "normal" | "muted" | "good" | "warn" | "bad" }[]; + selected?: number; + offset?: number; + message?: string; + error?: boolean; + hint: string; + editing?: { label: string; draft: ComposerSnapshot }; +} + +export interface PanelRender { + rows: { text: string; width: number }[]; + cursor: { row: number; col: number } | null; +} + +const singleLine = (text: string): string => expandTabs(sanitize(text).replace(/\n/g, " ")); +const clamp = (value: number, max: number): number => Math.max(0, Math.min(Number.isFinite(value) ? Math.floor(value) : 0, Math.max(0, max))); + +/** Decorate only after measuring plain text, so ANSI never moves the caret. */ +export function renderPanel(view: PanelView, opts: { width: number; height: number }): PanelRender { + const width = Math.max(1, Math.floor(opts.width)); + const height = Math.max(0, Math.floor(opts.height)); + if (height === 0) return { rows: [], cursor: null }; + const boxed = width >= 8 && height >= 6; + const inner = boxed ? width - 4 : width; + const edge = boxed ? 2 : 0; + const wideBox = displayWidth("─") > 1; + const side = wideBox ? "|" : "│"; + const rows: PanelRender["rows"] = []; + const fitted = (text: string): string => truncateToWidth(singleLine(text), inner); + const add = (plain: string, decorated = plain): void => { + const pad = " ".repeat(Math.max(0, inner - displayWidth(plain))); + rows.push({ + text: boxed ? paint(`${side} `, style.faint) + decorated + pad + paint(` ${side}`, style.faint) : decorated, + width: boxed ? width : displayWidth(plain), + }); + }; + const line = (text: string, tone = ""): void => { + const plain = fitted(text); + add(plain, tone ? paint(plain, tone) : plain); + }; + const border = (top: boolean): void => { + const plain = (wideBox ? "+" : top ? "╭" : "╰") + (wideBox ? "-" : "─").repeat(width - 2) + (wideBox ? "+" : top ? "╮" : "╯"); + rows.push({ text: paint(plain, style.faint), width }); + }; + if (boxed) border(true); + + const selected = view.selected === undefined || view.rows.length === 0 ? undefined : clamp(view.selected, view.rows.length - 1); + let budget = height - (boxed ? 2 : 0); + const showHeader = budget >= 3; + const showHint = budget >= 3; + if (showHeader) { + const active = clamp(view.activeTab, view.tabs.length - 1); + const names = view.tabs.map(singleLine); + const label = (name: string, i: number): string => NO_COLOR && i === active ? `[${name}]` : ` ${name} `; + const colour = (i: number): string => style.tabText + (i === active ? style.accentBackground + style.bold : style.tabBackground); + const labels = names.map(label); + const all = labels.join(" "); + if (displayWidth(all) <= inner) add(all, labels.map((name, i) => paint(name, colour(i))).join(" ")); + else { + const compact = inner >= 2 ? label(truncateToWidth(names[active] ?? "", inner - 2), active) : fitted(names[active] ?? ""); + add(compact, paint(compact, colour(active))); + } + budget -= 1; + } + if (showHint) budget -= 1; + if (view.subtitle && budget >= 4) { + line(view.subtitle, style.faint); + budget -= 1; + } + + // Keep one list/editor row before spending space on explanation. On short + // terminals decoration disappears before the selected setting or its caret. + const message = view.message && budget >= 3 ? fitted(view.message) : undefined; + if (message !== undefined) budget -= 1; + const detail = selected === undefined ? undefined : view.rows[selected]!.detail; + const details = detail && budget >= 5 ? wrapToWidth(singleLine(detail), inner).slice(0, Math.min(2, budget - 3)) : []; + budget -= details.length; + + const editing = view.editing; + const editor = editing ? renderComposer({ + text: sanitize(editing.draft.text), + cursor: [...sanitize([...editing.draft.text].slice(0, editing.draft.cursor).join(""))].length, + }, { width: inner, prompt: "> " }) : undefined; + const editRows = editor ? Math.min(editor.rows.length, Math.max(1, Math.min(3, budget - (view.rows.length > 0 ? 1 : 0)))) : 0; + const editLabel = editing && budget - editRows >= 2; + const listRoom = Math.max(0, budget - editRows - Number(Boolean(editLabel))); + let offset = clamp(view.offset ?? 0, view.rows.length - listRoom); + if (selected !== undefined && listRoom > 0) { + if (selected < offset) offset = selected; + if (selected >= offset + listRoom) offset = selected - listRoom + 1; + } + const labelWidth = Math.min(Math.max(0, ...view.rows.map((row) => displayWidth(singleLine(row.label)))), Math.max(1, Math.floor((inner - 4) / 2))); + for (const [j, row] of view.rows.slice(offset, offset + listRoom).entries()) { + const isSelected = selected === offset + j; + const marker = inner >= 4 ? isSelected ? "❯ " : " " : ""; + const room = Math.max(0, inner - displayWidth(marker)); + const label = truncateToWidth(singleLine(row.label), row.value === undefined || room < 8 ? room : labelWidth); + const gap = row.value === undefined || room < 8 ? "" : " ".repeat(Math.max(2, labelWidth - displayWidth(label) + 2)); + const value = gap ? truncateToWidth(singleLine(row.value!), Math.max(0, room - displayWidth(label + gap))) : ""; + const plain = marker + label + gap + value; + const tone = row.tone === "good" ? style.ok : row.tone === "warn" ? style.warn : row.tone === "bad" ? style.bad : row.tone === "muted" ? style.faint : ""; + add(plain, isSelected ? paint(plain, style.accent) : row.tone === "muted" || (tone && value === "") + ? paint(plain, tone) : marker + label + gap + (tone ? paint(value, tone) : value)); + } + if (view.rows.length === 0 && listRoom > 0 && !editing) line("No entries", style.faint); + for (const detailLine of details) line(detailLine, style.faint); + if (message !== undefined) line(message, view.error ? style.bad : style.ok); + let cursor: PanelRender["cursor"] = null; + if (editing && editor) { + if (editLabel) line(editing.label, style.accent); + const start = Math.max(0, Math.min(editor.cursorRow - Math.floor(editRows / 2), editor.rows.length - editRows)); + cursor = { row: rows.length + editor.cursorRow - start, col: Math.min(width - 1, edge + editor.cursorCol) }; + for (const row of editor.rows.slice(start, start + editRows)) add(row.prefix + row.body, paint(row.prefix, style.accent) + row.body); + } + if (showHint) line(view.hint, style.faint); + if (boxed) border(false); + return { rows, cursor }; +} diff --git a/packages/tui/src/render.ts b/packages/tui/src/render.ts index 9bf1524..63d71fb 100644 --- a/packages/tui/src/render.ts +++ b/packages/tui/src/render.ts @@ -15,7 +15,7 @@ import type { Cell, ViewState } from "./cells.js"; import { sanitize } from "./sanitize.js"; -import { displayWidth, truncateEndToWidth, truncateToWidth } from "./width.js"; +import { displayWidth, truncateEndToWidth, truncateToWidth, wrapToWidth } from "./width.js"; export interface RenderOptions { width: number; @@ -28,7 +28,7 @@ export interface RenderOptions { * and dim, for anyone debugging what the model was doing. */ showThinking?: boolean; - /** Cap on tool output lines shown inline. */ + /** Cap on physical tool-output rows shown inline; Infinity expands all output. */ outputLines?: number; /** * The working directory, so a path under it is shown relative to it. @@ -57,7 +57,7 @@ export interface StyledLine { tone: Tone; } -const DEFAULTS = { outputLines: 6 }; +const DEFAULTS = { outputLines: 3 }; /** The bullet before the model's prose and each of its tool calls. */ export const BULLET = "⏺"; @@ -86,10 +86,25 @@ function rule(label: string, width: number, trailing = ""): string { return head + "─".repeat(fill) + tail; } -function clip(text: string, maxLines: number): { lines: string[]; hidden: number } { - const all = text.replace(/\s+$/, "").split("\n"); - if (all.length <= maxLines) return { lines: all, hidden: 0 }; - return { lines: all.slice(0, maxLines), hidden: all.length - maxLines }; +/** A notice must not turn a tiny preview into many terminal rows itself. */ +function compactLine(candidates: string[], width: number): string { + return candidates.find((text) => displayWidth(text) <= width) ?? truncateToWidth(candidates.at(-1) ?? "", width); +} + +/** Budget the safe, decorated physical rows, not the source's newline count. */ +function toolPreview(lines: StyledLine[], maxRows: number, opts: RenderOptions): StyledLine[] { + const width = Math.max(1, opts.width); + const rows = lines.flatMap((l) => wrapToWidth(sanitize(l.text), width).map((text) => ({ text, tone: l.tone }))); + const shown = rows.slice(0, maxRows); + const hidden = rows.length - shown.length; + if (hidden > 0) { + const count = `… +${hidden} lines`; + const choices = opts.showShortcuts + ? [` ${count} (ctrl-o to expand)`, `${count} · ctrl-o`, `+${hidden} · ctrl-o`, `+${hidden}`] + : [` ${count}`, count, `+${hidden}`]; + shown.push(line(compactLine(choices, width), "dim")); + } + return shown; } /** `s` with every mention of the working directory shortened to a relative path. */ @@ -130,7 +145,7 @@ function toolHead(cell: Extract, width: number, cwd?: st const suffix = cell.repaired ? " · repaired" : ""; const room = Math.max(8, width - displayWidth(`${BULLET} ${title}()${suffix}`)); const arg = truncateToWidth(headArg(cell.name, cell.args, cwd), room); - return `${BULLET} ${title}(${arg})${suffix}`; + return truncateToWidth(sanitize(`${BULLET} ${title}(${arg})${suffix}`).replace(/\n/g, " "), Math.max(1, width)); } /** Result lines under a bullet: the first behind `⎿`, the rest aligned to it. */ @@ -178,15 +193,11 @@ export function proseLines(text: string, firstPrefix: string, restPrefix = " ") } /** A unified diff's lines, toned: additions, removals, hunk headers. */ -function diffLines(patch: string, max: number): StyledLine[] { - const all = patch.replace(/\s+$/, "").split("\n"); - const shown = all.slice(0, max); - const out = shown.map((l) => { +function diffLines(patch: string): StyledLine[] { + return sanitize(patch).replace(/[ \t\n]+$/, "").split("\n").map((l) => { const tone: Tone = l.startsWith("+++") || l.startsWith("---") ? "dim" : l.startsWith("+") ? "ok" : l.startsWith("-") ? "bad" : l.startsWith("@@") ? "dim" : "plain"; return line(` ${l}`, tone); }); - if (all.length > max) out.push(line(` … +${all.length - max} lines`, "dim")); - return out; } /** @@ -211,7 +222,8 @@ function line(text: string, tone: Tone = "plain"): StyledLine { function renderCellRaw(cell: Cell, opts: RenderOptions): StyledLine[] { const width = opts.width; - const outputLines = opts.outputLines ?? DEFAULTS.outputLines; + const expanded = opts.outputLines === Infinity; + const outputLines = expanded ? Infinity : Math.max(0, Math.floor(Number.isFinite(opts.outputLines) ? opts.outputLines! : DEFAULTS.outputLines)); const blank = line(""); switch (cell.kind) { @@ -257,25 +269,21 @@ function renderCellRaw(cell: Cell, opts: RenderOptions): StyledLine[] { const out: StyledLine[] = [line(toolHead(cell, width, opts.cwd), "bullet")]; // A patch is worth seeing as a diff, whatever it did. if (cell.name === "apply_patch" && typeof cell.args["patch"] === "string") { - out.push(...diffLines(cell.args["patch"], Math.max(outputLines, 12))); + out.push(...toolPreview(diffLines(cell.args["patch"]), outputLines, opts)); } if (cell.ok === undefined) { - out.push(line(` ${RESULT} Running…${cell.progress ? ` · ${cell.progress}` : ""}`, "dim")); - } else if (!cell.ok) { - const { lines, hidden } = clip(cell.output ?? "", outputLines); - const body = lines.length > 0 && lines[0] !== "" ? lines : ["(failed)"]; - out.push(...results([`Error: ${body[0]}`, ...body.slice(1)]).map((l) => line(l, "bad"))); - if (hidden > 0) out.push(line(` … +${hidden} lines`, "dim")); - } else if (cell.name === "read") { - // The model read it; the person does not need it dumped again. A count - // says the call worked, and the file is a `cat` away. + out.push(...toolPreview([line(` ${RESULT} Running…${cell.progress ? ` · ${cell.progress}` : ""}`, "dim")], outputLines, opts)); + } else if (cell.ok && cell.name === "read" && !expanded) { + // Keep the compact count until the person asks to inspect the content. const count = (cell.output ?? "").replace(/\s+$/, "").split("\n").filter((l) => l !== "").length; - out.push(line(` ${RESULT} Read ${count} line${count === 1 ? "" : "s"}`, "dim")); + const summary = ` ${RESULT} Read ${count} line${count === 1 ? "" : "s"}`; + const choices = opts.showShortcuts ? [`${summary} (ctrl-o to expand)`, `${summary} · ctrl-o`, summary] : [summary]; + out.push(line(compactLine(choices, Math.max(1, width)), "dim")); } else { - const { lines, hidden } = clip(cell.output ?? "", outputLines); - const body = lines.length > 0 && lines[0] !== "" ? lines : ["(no output)"]; - out.push(...results(body).map((l) => line(l, "dim"))); - if (hidden > 0) out.push(line(` … +${hidden} lines${opts.showShortcuts ? " (ctrl-o to expand)" : ""}`, "dim")); + const body = sanitize(cell.output ?? "").replace(/[ \t\n]+$/, "").split("\n"); + if (body.length === 1 && body[0] === "") body[0] = cell.ok ? "(no output)" : "(failed)"; + if (!cell.ok) body[0] = `Error: ${body[0]}`; + out.push(...toolPreview(results(body).map((l) => line(l, cell.ok ? "dim" : "bad")), outputLines, opts)); } // A hook that passed is silent, as in Claude Code; one that failed is // the thing the person needs to see. @@ -300,7 +308,7 @@ function renderCellRaw(cell: Cell, opts: RenderOptions): StyledLine[] { return [line(`⇄ channel ${cell.from} → ${cell.to} · ${cell.reason}`, "warn"), blank]; case "queue": - return [line(`${BULLET} Task(${cell.agent}) · ${cell.state}`, "dim"), blank]; + return [line(`${BULLET} Task(${cell.agent}) · ${cell.state}`, cell.state === "failed" ? "bad" : "dim"), blank]; case "notice": { const mark = cell.level === "error" ? "✗" : cell.level === "warn" ? "!" : "·"; diff --git a/packages/tui/src/screen.ts b/packages/tui/src/screen.ts index 7c70d0f..da5eef2 100644 --- a/packages/tui/src/screen.ts +++ b/packages/tui/src/screen.ts @@ -37,17 +37,17 @@ import { renderComposer, type ComposerSnapshot } from "./composer.js"; import { heroLines, pickHero, welcomeLines, type HeroContext, type WelcomeContext } from "./hero.js"; import { BRACKETED_PASTE, KeyDecoder, type Key } from "./keys.js"; import { renderMenu, type MenuItem } from "./menu.js"; +import { renderPanel, type PanelView } from "./panel.js"; import { BULLET, - renderPendingStyled, - renderSettledStyled, + renderCellStyled, renderTailStyled, + settledCount, type RenderOptions, type StyledLine, type Tone, } from "./render.js"; import { compactReadings, readings, statusLine } from "./statusline.js"; -import { CommitTracker } from "./stream.js"; import { NO_COLOR, paint, severityColor, style, term } from "./theme.js"; import { displayWidth, truncateToWidth, wrapToWidth } from "./width.js"; import type { LoopEvent } from "@motifcode/core"; @@ -84,6 +84,8 @@ export interface ScreenOptions { */ export interface ComposerView { draft: ComposerSnapshot; + /** Recognized slash-command token, in code points of the draft. */ + commandRange?: { start: number; end: number }; placeholder?: string; /** * A question in place of the input: what is about to run, and the @@ -98,6 +100,8 @@ export interface ComposerView { secret?: { title: string; lines: string[]; prompt: string }; /** The menu under the input: the matching items, which is selected, and their prefix (`/` or `@`). */ menu?: { items: MenuItem[]; selected: number; prefix?: string }; + /** Settings and session readings, temporarily replacing the draft. */ + panel?: PanelView; } export type KeyHandler = (key: Key) => void; @@ -106,6 +110,8 @@ export type KeyHandler = (key: Key) => void; interface Row { text: string; width: number; + /** A mutable leading bullet can pulse without recolouring its body. */ + bulletTail?: string; } const SPINNER = ["✻", "✼", "✽", "✾"]; @@ -113,7 +119,7 @@ const SPINNER = ["✻", "✼", "✽", "✾"]; /** The keys, for the panel `?` opens. */ const SHORTCUTS = [ "enter send · \\ + enter newline · esc interrupt or clear · ctrl-c twice quit · ctrl-d quit", - "↑ ↓ history · tab show or hide reasoning · ctrl-o full tool output · ctrl-l redraw · shift-tab permissions", + "↑ ↓ history · tab show or hide reasoning · ctrl-o output viewer · ctrl-l redraw · shift-tab permissions", "@ attach a file · ! run a shell line · # add a project note · / commands · ? hide this", ]; @@ -134,9 +140,8 @@ function inlineMarkup(text: string): string { export class Screen { private state: ViewState = initialState(); - private readonly commits = new CommitTracker(); - /** The settled lines for the cells and options they were rendered from; a keystroke changes neither. */ - private settledMemo: { cells: readonly unknown[]; key: string; lines: StyledLine[] } | null = null; + /** Cells, not rendered rows: wrapping changes on resize without new output. */ + private committedCells = 0; /** The rows of the last painted footer, top to bottom. */ private footer: Row[] = []; /** Where the cursor was left: the footer row index and column, or null when below the footer. */ @@ -149,6 +154,7 @@ export class Screen { private hint = ""; private label = ""; private activity: { text: string; since: number } | null = null; + private working = false; private ticker: NodeJS.Timeout | null = null; private tick = 0; private shortcutsOpen = false; @@ -159,6 +165,7 @@ export class Screen { private streamTimer: NodeJS.Timeout | null = null; private showThinking: boolean; private verbose = false; + private outputView: { top: number; follow: boolean; painted: string; cache?: { state: ViewState; key: string; lines: StyledLine[] } } | null = null; private cwd: string | undefined; private readonly shadedHero: boolean; private readonly interactive: boolean; @@ -197,7 +204,7 @@ export class Screen { return { width: this.columns(), showThinking: this.showThinking, - ...(this.verbose ? { outputLines: 1000 } : {}), + ...(this.verbose ? { outputLines: Infinity } : {}), ...(this.cwd !== undefined ? { cwd: this.cwd } : {}), // Only advertise the key when something is listening for it. showShortcuts: this.detachInput !== null, @@ -249,6 +256,7 @@ export class Screen { /** Show, replace or remove the prompt at the bottom of the footer. */ setComposer(view: ComposerView | null): void { this.composer = view; + if (this.outputView && (view?.confirm || view?.secret)) this.closeOutputView(); this.paint(); } @@ -277,31 +285,36 @@ export class Screen { * and the seconds elapsed. Null when it is waiting for the person. */ setActivity(text: string | null): void { - if (text === null) { - if (this.activity === null && this.ticker === null) return; - this.activity = null; - if (this.ticker) { - clearInterval(this.ticker); - this.ticker = null; - } - } else { - const same = this.activity?.text === text; - if (!same) this.activity = { text, since: this.now() }; - if (!this.ticker && this.interactive) { - this.ticker = setInterval(() => { - this.tick += 1; - this.paint(); - }, 1000); - // A ticker must not hold the process open once the loop is done. - this.ticker.unref?.(); - } - // The spinner and the elapsed second are the ticker's to paint. Reporting - // the same activity again is not a visible change. - if (same) return; - } + if (this.activity?.text === text || (text === null && this.activity === null)) return; + this.activity = text === null ? null : { text, since: this.now() }; + this.updateTicker(); + this.paint(); + } + + /** Work can continue after the activity label is replaced by streamed prose. */ + setWorking(working: boolean): void { + if (this.working === working) return; + this.working = working; + this.tick = 0; + this.updateTicker(); this.paint(); } + private updateTicker(): void { + const needed = this.interactive && (this.activity !== null || (this.working && !NO_COLOR)); + if (!needed && this.ticker) { + clearInterval(this.ticker); + this.ticker = null; + } else if (needed && !this.ticker) { + this.ticker = setInterval(() => { + this.tick += 1; + this.paint(this.working); + }, 1000); + // A ticker must not hold the process open once the loop is done. + this.ticker.unref?.(); + } + } + toggleShortcuts(): void { this.shortcutsOpen = !this.shortcutsOpen; this.paint(); @@ -311,9 +324,8 @@ export class Screen { * Listen for keys. * * With a handler, every decoded key goes to it and nothing is interpreted - * here. Without one — the one-shot run — the two keys the status line - * advertises are handled: Tab folds reasoning, and Ctrl-C, which raw mode - * would otherwise swallow, is forwarded as the interrupt it means. + * here. Without one — the one-shot run — Tab folds reasoning, Ctrl-O opens + * the output viewer, and Ctrl-C is forwarded as the interrupt it means. * * Raw mode is entered here and left in `finish()` and on every fatal signal. * A terminal left in raw mode after a crash needs `reset` to type in again, @@ -326,6 +338,8 @@ export class Screen { const onData = (chunk: Buffer): void => { for (const key of decoder.feed(chunk)) { if (handler) handler(key); + else if (this.handleOutputViewKey(key)) continue; + else if (key.type === "ctrl" && key.key === "o") this.toggleOutputView(); else if (key.type === "tab") this.toggleThinking(); else if (key.type === "ctrl" && key.key === "c") process.kill(process.pid, "SIGINT"); } @@ -388,7 +402,7 @@ export class Screen { this.showThinking = !this.showThinking; // Showing rewrites lines already committed to scrollback, so the stable // region has to be rebuilt rather than appended to. - this.commits.reset(); + this.committedCells = 0; this.paint(); } @@ -433,14 +447,14 @@ export class Screen { /** Paths under this directory are shown relative to it from now on. */ setCwd(cwd: string): void { this.cwd = cwd; - this.commits.reset(); + this.committedCells = 0; this.paint(); } - /** Show tool output whole, or clipped to a few lines. Ctrl-O in the session. */ + /** Change the inline output setting; Ctrl-O opens the independent viewer. */ toggleVerbose(): void { this.verbose = !this.verbose; - this.commits.reset(); + this.committedCells = 0; this.paint(); } @@ -448,12 +462,82 @@ export class Screen { return this.verbose; } + get outputViewOpen(): boolean { return this.outputView !== null; } + + /** Read-only details live in a separate buffer; normal scrollback stays intact. */ + toggleOutputView(): void { + if (!this.interactive) return; + if (this.outputView) { this.closeOutputView(); return; } + this.outputView = { top: 0, follow: true, painted: "" }; + this.write(term.enterAlternate); + this.paintOutputView(); + } + + private closeOutputView(): void { + if (!this.outputView) return; + this.outputView = null; + this.write(term.leaveAlternate + (this.cursorAt || !this.composer ? term.showCursor : term.hideCursor)); + this.paint(); + } + + /** Consume navigation/pastes without editing the draft or submitting a task. */ + handleOutputViewKey(key: Key): boolean { + const view = this.outputView; + if (!view) return false; + if (key.type === "ctrl" && (key.key === "c" || key.key === "d")) { + this.closeOutputView(); + return false; // The controller still owns interrupt and quit. + } + if (key.type === "escape" || (key.type === "ctrl" && key.key === "o") || (key.type === "text" && key.text === "q")) { + this.closeOutputView(); + return true; + } + const page = Math.max(1, this.rowCount() - 2); + if (key.type === "home") { view.top = 0; view.follow = false; } + else if (key.type === "end") view.follow = true; + else if (["up", "down", "page-up", "page-down"].includes(key.type)) { + view.top += key.type === "up" ? -1 : key.type === "down" ? 1 : key.type === "page-up" ? -page : page; + view.follow = false; + } + this.paintOutputView(); + return true; + } + + private paintOutputView(): void { + const view = this.outputView; + if (!view) return; + const width = Math.max(1, this.columns()); + const height = Math.max(1, this.rowCount()); + const page = Math.max(0, height - 2); + const opts = { ...this.renderOptions, width, outputLines: Infinity, showShortcuts: false }; + const key = `${width}|${opts.showThinking}|${opts.cwd}`; + if (!view.cache || view.cache.state !== this.state || view.cache.key !== key) { + const lines = this.state.cells.flatMap((cell) => renderCellStyled(cell, opts)).concat(renderTailStyled(this.state, opts)); + view.cache = { state: this.state, key, lines: lines.flatMap((line) => wrapToWidth(line.text, width).map((text) => ({ ...line, text }))) }; + } + const lines = view.cache.lines; + const last = Math.max(0, lines.length - page); + view.top = view.follow ? last : Math.min(last, Math.max(0, view.top)); + const range = `${Math.min(lines.length, view.top + 1)}–${Math.min(lines.length, view.top + page)}/${lines.length}`; + const rows = [paint(truncateToWidth(`Transcript · full output · ${range}${this.working ? " · working" : ""}`, width), style.accent)]; + for (let i = 0; i < page; i++) rows.push(lines[view.top + i] ? this.colour(lines[view.top + i]!) : ""); + if (height > 1) rows.push(paint(truncateToWidth("↑↓ scroll · PgUp/PgDn · Home/End · Esc/Ctrl-O close", width), style.faint)); + const frame = `${width}|${height}|${rows.join("\n")}`; + if (view.painted === frame) return; + view.painted = frame; + this.withSync(() => { + this.write(term.hideCursor + term.home + term.clearScreen); + rows.forEach((row, i) => this.write(term.home + term.down(i) + row)); + }); + } + /** Erase and rebuild the visible screen. Ctrl-L in the session. */ redraw(): void { this.repaintAll(); } - private paint(): void { + private paint(tickOnly = false): void { + if (this.outputView) { this.paintOutputView(); return; } if ( this.interactive && this.footer.length > 0 && (this.columns() < this.paintedWidth || this.rowCount() !== this.paintedHeight) @@ -465,23 +549,48 @@ export class Screen { } const width = this.columns(); const next = this.interactive ? this.buildFooter(width) : null; - const settled = this.settledLines(); - const pending = Math.max(0, settled.length - this.commits.count); + const settledEnd = settledCount(this.state); + const pending = Math.max(0, settledEnd - this.committedCells); // Compare before clearing. A hidden reasoning token changes no row, and // erasing the footer to draw it back is the flash. if (pending === 0 && this.sameFooter(next, width)) return; + if (tickOnly && pending === 0 && next && this.paintTick(next, width)) return; this.withSync(() => { this.clearFooter(); // Only settled cells go to scrollback. A tool cell between `tool_start` and // `tool_end` is still growing, and committing it there would print a tool // that appears to have produced nothing. - const fresh = this.commits.take(settled.map((l) => l.text)); - for (const line of settled.slice(settled.length - fresh.length)) this.write(this.colour(line) + "\n"); + for (const cell of this.state.cells.slice(this.committedCells, settledEnd)) { + for (const line of renderCellStyled(cell, this.renderOptions)) this.write(this.colour(line) + "\n"); + } + this.committedCells = settledEnd; if (next) this.writeFooter(next, width); else this.paintFooter(); }); } + /** A pulse must not append footer copies to scrollback or disturb the editor. */ + private paintTick(next: { rows: Row[]; cursor: { row: number; col: number } | null }, width: number): boolean { + if (this.paintedWidth !== width || this.footer.length !== next.rows.length) return false; + if (this.cursorAt?.row !== next.cursor?.row || this.cursorAt?.col !== next.cursor?.col) return false; + // A long live reply can extend above the viewport. Never clamp a move to + // an off-screen row onto the terminal's first visible line. + const firstVisible = Math.max(0, next.rows.length - this.rowCount() + 1); + const changed = next.rows.flatMap((row, i) => i >= firstVisible && row.text !== this.footer[i]!.text ? [i] : []); + if (changed.length > 0) this.withSync(() => { + const anchor = this.cursorAt?.row ?? next.rows.length; + let at = anchor; + this.write(term.hideCursor); + for (const i of changed) { + this.write(term.up(at - i) + term.down(i - at) + term.lineStart + term.clearLine + next.rows[i]!.text); + at = i; + } + this.write(term.up(at - anchor) + term.down(anchor - at) + term.column(this.cursorAt?.col ?? 0) + (this.cursorAt || !this.composer ? term.showCursor : term.hideCursor)); + }); + this.footer = next.rows; + return true; + } + /** True when the footer about to be drawn is the one already on screen. */ private sameFooter(next: { rows: Row[]; cursor: { row: number; col: number } | null } | null, width: number): boolean { if (!this.interactive || next === null) return true; @@ -507,22 +616,6 @@ export class Screen { } } - /** - * The settled transcript, rendered once per change. - * - * Every keystroke repaints the footer, and the footer's arithmetic starts - * from the settled lines; re-rendering a long transcript for each - * character typed is work whose result is already known. - */ - private settledLines(): StyledLine[] { - const opts = this.renderOptions; - const key = `${opts.width}|${opts.showThinking ? 1 : 0}|${opts.outputLines ?? ""}|${opts.showShortcuts ? 1 : 0}|${opts.cwd ?? ""}`; - if (this.settledMemo && this.settledMemo.cells === this.state.cells && this.settledMemo.key === key) return this.settledMemo.lines; - const lines = renderSettledStyled(this.state, opts); - this.settledMemo = { cells: this.state.cells, key, lines }; - return lines; - } - /** * Rebuild the visible screen after a resize. * @@ -534,10 +627,12 @@ export class Screen { */ private repaintAll(): void { if (!this.interactive) return; + if (this.outputView) { this.outputView.painted = ""; this.paintOutputView(); return; } const width = this.columns(); - const settled = this.settledLines(); - const fresh = this.commits.take(settled.map((l) => l.text)); - const previous = settled.slice(0, settled.length - fresh.length); + const settledEnd = settledCount(this.state); + const previous = this.state.cells.slice(0, this.committedCells).flatMap((cell) => renderCellStyled(cell, this.renderOptions)); + const fresh = this.state.cells.slice(this.committedCells, settledEnd).flatMap((cell) => renderCellStyled(cell, this.renderOptions)); + this.committedCells = settledEnd; const footer = this.buildFooter(width); const physical: string[] = []; for (const l of previous) for (const t of wrapToWidth(l.text, width)) physical.push(this.colour({ ...l, text: t })); @@ -548,7 +643,7 @@ export class Screen { this.write(term.clearScreen + term.home); for (const line of physical.slice(Math.max(0, physical.length - keep))) this.write(line + "\n"); // Commit newly settled text even when completion raced the resize. - for (const line of settled.slice(previous.length)) this.write(this.colour(line) + "\n"); + for (const line of fresh) this.write(this.colour(line) + "\n"); this.writeFooter(footer, width); }); } @@ -569,21 +664,31 @@ export class Screen { private buildFooter(width: number): { rows: Row[]; cursor: { row: number; col: number } | null } { const opts = { ...this.renderOptions, width }; const rows: Row[] = []; - - for (const l of renderPendingStyled(this.state, opts)) { - rows.push(...this.rows(l.text, (t) => this.colour({ ...l, text: t }))); + const liveRows = (l: StyledLine, mutable = true): Row[] => wrapToWidth(l.text, width).map((text, i) => ({ + text: this.colour({ ...l, text }), + width: displayWidth(text), + ...(mutable && i === 0 && text.startsWith(BULLET) ? { bulletTail: this.colour({ ...l, text: text.slice(BULLET.length) }) } : {}), + })); + + for (const cell of this.state.cells.slice(settledCount(this.state))) { + for (const l of renderCellStyled(cell, opts)) rows.push(...liveRows(l, cell.kind === "tool" && cell.ok === undefined)); } - for (const l of renderTailStyled(this.state, opts)) rows.push(...this.rows(l.text, (t) => this.colour({ ...l, text: t }))); + for (const l of renderTailStyled(this.state, opts)) rows.push(...liveRows(l)); if (this.activity) { - const glyph = SPINNER[this.tick % SPINNER.length]!; + const glyph = this.working ? BULLET : SPINNER[this.tick % SPINNER.length]!; const seconds = Math.max(0, Math.floor((this.now() - this.activity.since) / 1000)); const text = `${glyph} ${this.activity.text} (esc to interrupt · ${seconds}s)`; - rows.push(...this.rows(truncateToWidth(text, width), (t) => paint(t, style.faint))); + rows.push(...liveRows({ text: truncateToWidth(text, width), tone: "dim" })); } + const beforeComposer = rows.length; let cursor: { row: number; col: number } | null = null; - if (this.composer) { + if (this.composer?.panel && !this.composer.confirm && !this.composer.secret) { + const block = renderPanel(this.composer.panel, { width, height: Math.max(0, this.rowCount() - 1 - Number(this.working)) }); + if (block.cursor) cursor = { row: rows.length + block.cursor.row, col: block.cursor.col }; + rows.push(...block.rows); + } else if (this.composer) { const block = this.composerRows(this.composer, width); cursor = { row: rows.length + block.cursorRow, col: block.cursorCol }; rows.push(...block.rows); @@ -596,6 +701,25 @@ export class Screen { } else { rows.push(...this.rows(this.statusOnly(), (t) => t)); } + if (this.working) { + const firstVisible = Math.max(0, rows.length - this.rowCount() + 1); + let target = rows.findLastIndex((row, i) => i >= firstVisible && row.bulletTail !== undefined); + // Hidden reasoning, settled prose and a long live reply all need an + // indicator near the composer when their own bullet is not visible. + if (target < 0) { + const fallback = liveRows({ text: truncateToWidth(`${BULLET} Working…`, width), tone: "bullet" }); + rows.splice(beforeComposer, 0, ...fallback); + if (cursor) cursor.row += fallback.length; + target = beforeComposer; + } + const row = rows[target]!; + if (row.bulletTail !== undefined) { + // Explicitly hide the glyph: terminal dim styling is not reliably + // visible, and ANSI blink depends on the terminal's preferences. + const dot = !NO_COLOR && this.tick % 2 === 1 ? " ".repeat(displayWidth(BULLET)) : paint(BULLET, style.accent); + row.text = dot + row.bulletTail; + } + } // Leave one row below the footer for the parked cursor. Only the newest // live rows are visible; omitted rows remain live and are never committed. const limit = Math.max(0, this.rowCount() - 1); @@ -653,6 +777,7 @@ export class Screen { width: inner, prompt: view.secret ? view.secret.prompt : "> ", ...(view.placeholder !== undefined && !view.secret ? { placeholder: view.placeholder } : {}), + ...(view.commandRange && !view.secret ? { commandRange: view.commandRange } : {}), }); const border = (l: string, r: string): Row => ({ text: paint(`${wideBox ? "+" : l}${(wideBox ? "-" : "─").repeat(inner + 2)}${wideBox ? "+" : r}`, style.faint), @@ -700,7 +825,10 @@ export class Screen { header = body.length; } for (const row of render.rows) { - const body = render.placeholder ? paint(row.body, style.faint) : row.body; + const range = row.commandRange; + const body = render.placeholder ? paint(row.body, style.faint) : range + ? row.body.slice(0, range.start) + paint(row.body.slice(range.start, range.end), style.accent) + row.body.slice(range.end) + : row.body; const plainWidth = displayWidth(row.prefix) + displayWidth(row.body); const pad = " ".repeat(Math.max(0, inner - plainWidth)); rows.push({ @@ -791,18 +919,25 @@ export class Screen { /** Release the footer so the shell prompt lands cleanly. */ finish(): void { + this.closeOutputView(); this.detachInput?.(); this.detachInput = null; - this.setActivity(null); + this.working = false; + this.activity = null; + this.updateTicker(); + if (this.streamTimer) { + clearTimeout(this.streamTimer); + this.streamTimer = null; + } this.composer = null; this.hint = ""; this.clearFooter(); // Flush anything still pending — a session that ended mid-tool should still // show what that tool did. - const rest = renderPendingStyled(this.state, this.renderOptions); - const all = renderSettledStyled(this.state, this.renderOptions).concat(rest); - const fresh = this.commits.take(all.map((l) => l.text)); - for (const line of all.slice(all.length - fresh.length)) this.write(this.colour(line) + "\n"); + for (const cell of this.state.cells.slice(this.committedCells)) { + for (const line of renderCellStyled(cell, this.renderOptions)) this.write(this.colour(line) + "\n"); + } + this.committedCells = this.state.cells.length; if (this.interactive) this.write(term.showCursor); } } diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index a9ad55e..6cdfb03 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -17,10 +17,12 @@ const ESC = "\x1b["; export const NO_COLOR = process.env["NO_COLOR"] !== undefined || process.env["TERM"] === "dumb"; const rgb = (r: number, g: number, b: number): string => `${ESC}38;2;${r};${g};${b}m`; +const bg = (r: number, g: number, b: number): string => `${ESC}48;2;${r};${g};${b}m`; const idx = (n: number): string => `${ESC}38;5;${n}m`; export interface Palette { accent: string; + accentBackground: string; ok: string; warn: string; bad: string; @@ -31,23 +33,23 @@ export interface Palette { export const THEMES: Record = { motif: { description: "Motif's brand blue, sampled from the logo", - palette: { accent: rgb(96, 122, 213), ok: rgb(80, 170, 120), warn: rgb(200, 150, 60), bad: rgb(200, 90, 80), faint: idx(245) }, + palette: { accent: rgb(96, 122, 213), accentBackground: bg(96, 122, 213), ok: rgb(80, 170, 120), warn: rgb(200, 150, 60), bad: rgb(200, 90, 80), faint: idx(245) }, }, claude: { description: "Claude's terracotta accent with GitHub's status colours", - palette: { accent: rgb(217, 119, 87), ok: rgb(63, 185, 80), warn: rgb(210, 153, 34), bad: rgb(248, 81, 73), faint: idx(245) }, + palette: { accent: rgb(217, 119, 87), accentBackground: bg(217, 119, 87), ok: rgb(63, 185, 80), warn: rgb(210, 153, 34), bad: rgb(248, 81, 73), faint: idx(245) }, }, mono: { description: "no colour at all — weight and dimness only", - palette: { accent: `${ESC}1m`, ok: idx(250), warn: `${ESC}1m`, bad: `${ESC}1m${ESC}4m`, faint: idx(245) }, + palette: { accent: `${ESC}1m`, accentBackground: bg(90, 90, 90), ok: idx(250), warn: `${ESC}1m`, bad: `${ESC}1m${ESC}4m`, faint: idx(245) }, }, solarized: { description: "Solarized's blue, green, yellow and red", - palette: { accent: rgb(38, 139, 210), ok: rgb(133, 153, 0), warn: rgb(181, 137, 0), bad: rgb(220, 50, 47), faint: rgb(147, 161, 161) }, + palette: { accent: rgb(38, 139, 210), accentBackground: bg(38, 139, 210), ok: rgb(133, 153, 0), warn: rgb(181, 137, 0), bad: rgb(220, 50, 47), faint: rgb(147, 161, 161) }, }, dracula: { description: "Dracula's purple, green, yellow and red", - palette: { accent: rgb(189, 147, 249), ok: rgb(80, 250, 123), warn: rgb(241, 250, 140), bad: rgb(255, 85, 85), faint: rgb(98, 114, 164) }, + palette: { accent: rgb(189, 147, 249), accentBackground: bg(189, 147, 249), ok: rgb(80, 250, 123), warn: rgb(241, 250, 140), bad: rgb(255, 85, 85), faint: rgb(98, 114, 164) }, }, }; @@ -64,6 +66,8 @@ export const style = { bold: `${ESC}1m`, /** Swapped foreground and background, for the selected menu row. */ inverse: `${ESC}7m`, + tabText: rgb(255, 255, 255), + tabBackground: bg(48, 53, 65), ...THEMES[DEFAULT_THEME]!.palette, }; @@ -93,6 +97,8 @@ export function severityColor(sev: "ok" | "warn" | "bad"): string { /** Cursor and line control, kept in one place so the writer stays readable. */ export const term = { + enterAlternate: `${ESC}?1049h`, + leaveAlternate: `${ESC}?1049l`, hideCursor: `${ESC}?25l`, showCursor: `${ESC}?25h`, clearLine: `${ESC}2K`, diff --git a/packages/tui/test/__snapshots__/render.test.ts.snap b/packages/tui/test/__snapshots__/render.test.ts.snap index ad22fb7..e4de40e 100644 --- a/packages/tui/test/__snapshots__/render.test.ts.snap +++ b/packages/tui/test/__snapshots__/render.test.ts.snap @@ -19,7 +19,8 @@ exports[`transcript > renders a full session 1`] = ` · Motif-3-Coder-A13B · http://zgx-1c3b:8080 · toolcall · tools[8] #9f3c1a20 ⏺ Bash(rg -n 'ohe_subtract' hallbar/backend/) - ⎿ hallbar/backend/ohe_subtraction.py:41 def ohe_subtract(rxy, field, ...) + ⎿ hallbar/backend/ohe_subtraction.py:41 def ohe_subtract(rxy, field, + ...) ⏺ Patch(--- a/ohe_subtraction.py…) · repaired --- a/ohe_subtraction.py diff --git a/packages/tui/test/composer.test.ts b/packages/tui/test/composer.test.ts index 97c4cc3..4ef0b03 100644 --- a/packages/tui/test/composer.test.ts +++ b/packages/tui/test/composer.test.ts @@ -146,6 +146,26 @@ describe("rendering", () => { }); }); +describe("command highlighting layout", () => { + it.each([5, 8, 20])("preserves text, wrapping and every cursor position at width %i", (width) => { + const text = "\t\n/model 한글🙂\n/help"; + for (let cursor = 0; cursor <= [...text].length; cursor++) { + const plain = renderComposer({ text, cursor }, { width }); + const marked = renderComposer({ text, cursor }, { width, commandRange: { start: 2, end: 8 } }); + expect({ ...marked, rows: marked.rows.map(({ commandRange, ...row }) => row) }).toEqual(plain); + expect(marked.rows.map((r) => r.commandRange ? r.body.slice(r.commandRange.start, r.commandRange.end) : "").join("")).toBe("/model"); + } + }); + + it("converts Unicode command offsets to complete row-local slices", () => { + const r = renderComposer({ text: "/한🙂 args", cursor: 3 }, { width: 6, commandRange: { start: 0, end: 3 } }); + expect(r.rows.map((row) => row.commandRange ? row.body.slice(row.commandRange.start, row.commandRange.end) : "").join("")).toBe("/한🙂"); + const tiny = renderComposer({ text: "/한🙂 args", cursor: 3 }, { width: 1, commandRange: { start: 0, end: 3 } }); + expect(tiny.rows.filter((row) => row.commandRange).map((row) => row.body)).toEqual(["/", "?", "?"]); + expect(tiny.rows.filter((row) => row.commandRange).every((row) => row.commandRange!.end === row.body.length)).toBe(true); + }); +}); + describe("narrow composer rendering", () => { afterEach(() => vi.unstubAllEnvs()); diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 9edbfe6..dd7e410 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -66,6 +66,11 @@ describe("key decoding", () => { ]); }); + it("decodes transcript paging keys even across input chunks", () => { + expect(decode(`${ESC}[5~${ESC}[6~`)).toEqual([{ type: "page-up" }, { type: "page-down" }]); + expect(decode(`${ESC}[`, "5", "~")).toEqual([{ type: "page-up" }]); + }); + it("treats a lone escape as the Escape key", () => { expect(decode(ESC)).toEqual([{ type: "escape" }]); expect(decode(`${ESC}x`)).toEqual([{ type: "escape" }, { type: "text", text: "x" }]); diff --git a/packages/tui/test/panel.test.ts b/packages/tui/test/panel.test.ts new file mode 100644 index 0000000..5d924eb --- /dev/null +++ b/packages/tui/test/panel.test.ts @@ -0,0 +1,162 @@ +import xterm from "@xterm/headless"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderPanel, type PanelView } from "../src/panel.js"; +import { Screen } from "../src/screen.js"; +import { term } from "../src/theme.js"; +import { displayWidth } from "../src/width.js"; + +const strip = (text: string): string => text.replace(/\x1b\[[\d;]*m/g, ""); +const panel = (extra: Partial = {}): PanelView => ({ + tabs: ["Config", "Status", "Stats", "Usage"], activeTab: 0, + subtitle: "Settings for this session", + rows: Array.from({ length: 30 }, (_, i) => ({ label: `Setting ${i}`, value: `value ${i}`, detail: `Detail for setting ${i}` })), + selected: 0, hint: "↑↓ select · enter edit · esc close", ...extra, +}); +const plainRows = (view: PanelView, width = 60, height = 12): string[] => renderPanel(view, { width, height }).rows.map((r) => strip(r.text)); + +afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); + +describe("session panel layout", () => { + it("keeps a selected setting and its description visible instead of showing the list tail", () => { + const initial = plainRows(panel()); + expect(initial.join("\n")).toContain("❯ Setting 0"); + expect(initial.join("\n")).not.toContain("Setting 29"); + const selected = plainRows(panel({ selected: 25 })); + expect(selected.join("\n")).toContain("❯ Setting 25"); + expect(selected.join("\n")).toContain("Detail for setting 25"); + expect(selected.join("\n")).toMatch(/Config\W+Status\W+Stats\W+Usage/); + expect(selected.join("\n")).toContain("esc close"); + }); + + it("scrolls read-only readings from the requested offset", () => { + const view = panel({ selected: undefined, activeTab: 1 }); + expect(plainRows(view, 40, 8).join("\n")).toContain("Setting 0"); + const scrolled = plainRows({ ...view, offset: 10 }, 40, 8).join("\n"); + expect(scrolled).toContain("Setting 10"); + expect(scrolled).not.toContain("Setting 0"); + expect(scrolled).not.toContain("Setting 29"); + }); + + it("keeps editing text and its caret visible across wraps and multiline input", () => { + const text = "very-long-model/한글🙂\nother/model"; + const view = panel({ editing: { label: "Model", draft: { text, cursor: [...text].length } } }); + const result = renderPanel(view, { width: 20, height: 9 }); + expect(result.cursor).not.toBeNull(); + expect(result.cursor!.row).toBeLessThan(result.rows.length); + expect(result.cursor!.col).toBeLessThan(20); + expect(result.rows.map((row) => strip(row.text)).join("\n")).toContain("other/model"); + expect(view.editing!.draft.text).toBe(text); + }); + + it("bounds every physical row and editing caret in small viewports", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "2"); + for (const width of [1, 2, 3, 7, 8, 20, 80]) for (const height of [0, 1, 2, 3, 6, 12]) { + const result = renderPanel(panel({ selected: 20, message: "Saved Ω·한", editing: { label: "Model", draft: { text: "Ω·한\tmodel\nnext", cursor: 7 } } }), { width, height }); + expect(result.rows.length, `${width}x${height}`).toBeLessThanOrEqual(height); + for (const row of result.rows) { + expect(displayWidth(strip(row.text)), `${width}x${height}: ${strip(row.text)}`).toBeLessThanOrEqual(width); + expect(strip(row.text)).not.toMatch(/[\t\r\n]/); + } + if (result.cursor) { + expect(result.cursor.row).toBeGreaterThanOrEqual(0); + expect(result.cursor.row).toBeLessThan(result.rows.length); + expect(result.cursor.col).toBeGreaterThanOrEqual(0); + expect(result.cursor.col).toBeLessThan(width); + } + } + }); + + it("renders external labels and values as inert text", () => { + const result = renderPanel(panel({ rows: [{ label: "label\nline", value: "\x1b[2J\tvalue", detail: "detail\rhidden" }], message: "\x1b]0;title\x07", error: true }), { width: 80, height: 12 }); + const plain = result.rows.map((r) => strip(r.text)).join("\n"); + expect(plain).toContain("label line"); + expect(plain).toContain("^[[2J"); + expect(plain).toContain("^M"); + expect(plain).not.toContain("\x1b"); + }); + + it("uses no colour when NO_COLOR is set", async () => { + vi.stubEnv("NO_COLOR", "1"); + vi.resetModules(); + const { renderPanel: renderPlain } = await import("../src/panel.js"); + const result = renderPlain(panel({ message: "Invalid value", error: true }), { width: 80, height: 12 }); + expect(result.rows.map((row) => row.text).join("\n")).not.toContain("\x1b"); + }); +}); + +describe("panel on the physical screen", () => { + it("keeps the caret hidden on read-only panels while the work dot blinks", async () => { + vi.stubEnv("NO_COLOR", undefined); + vi.stubEnv("TERM", "xterm-256color"); + vi.resetModules(); + const { Screen: ColourScreen } = await import("../src/screen.js"); + const chunks: string[] = []; + const screen = new ColourScreen({ write: (text) => chunks.push(text), columns: () => 60, rows: () => 16, interactive: true }); + vi.useFakeTimers(); + try { + screen.setComposer({ draft: { text: "saved", cursor: 0 }, panel: panel({ activeTab: 1, selected: undefined }) }); + screen.setWorking(true); + chunks.length = 0; + vi.advanceTimersByTime(1000); + expect(chunks.join("")).toContain("Working…"); + expect(chunks.join("")).toContain(term.hideCursor); + expect(chunks.join("")).not.toContain(term.showCursor); + expect(chunks.join("")).not.toContain("\n"); + } finally { screen.finish(); } + }); + + it("replaces the draft, restores it on close, and defers to confirmation and secrets", async () => { + const terminal = new xterm.Terminal({ cols: 60, rows: 16, allowProposedApi: true }); + const chunks: string[] = []; + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => 60, rows: () => 16, interactive: true }); + const flush = async (): Promise => { + const output = chunks.splice(0).join(""); + await new Promise((resolve) => terminal.write(output, resolve)); + return Array.from({ length: terminal.rows }, (_, i) => terminal.buffer.active.getLine(terminal.buffer.active.baseY + i)!.translateToString(true)).join("\n"); + }; + const draft = { text: "UNSENT_DRAFT", cursor: 4 }; + try { + screen.setComposer({ draft, panel: panel() }); + expect(chunks.join("")).toContain(term.hideCursor); + expect(chunks.join("")).not.toContain(term.showCursor); + let visible = await flush(); + expect(visible).toContain("Config"); + expect(visible).not.toContain("UNSENT_DRAFT"); + screen.setComposer({ draft, panel: panel(), confirm: { title: "Permission", lines: ["Run?"], choices: ["❯ Yes", "No"] } }); + visible = await flush(); + expect(visible).toContain("Permission"); + expect(visible).not.toContain("Setting 0"); + screen.setComposer({ draft, panel: panel(), secret: { title: "Login", lines: [], prompt: "key: " } }); + visible = await flush(); + expect(visible).toContain("Login"); + expect(visible).not.toContain("UNSENT_DRAFT"); + expect(visible).not.toContain("Setting 0"); + screen.setComposer({ draft }); + visible = await flush(); + expect(visible).toContain("UNSENT_DRAFT"); + expect(terminal.buffer.active.cursorX).toBe(8); + } finally { screen.finish(); terminal.dispose(); } + }); + + it("follows the editing caret and selected setting after resize", async () => { + let cols = 60; + let rows = 16; + const terminal = new xterm.Terminal({ cols, rows, allowProposedApi: true }); + const chunks: string[] = []; + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => cols, rows: () => rows, interactive: true }); + const view = { draft: { text: "saved draft", cursor: 0 }, panel: panel({ selected: 25, editing: { label: "Model", draft: { text: "한글model", cursor: 2 } } }) }; + try { + for (const size of [[60, 16], [20, 8], [8, 6], [60, 16]]) { + [cols, rows] = size as [number, number]; + terminal.resize(cols, rows); + screen.setComposer(view); + await new Promise((resolve) => terminal.write(chunks.splice(0).join(""), resolve)); + const buffer = terminal.buffer.active; + expect(buffer.cursorX).toBeLessThan(cols); + expect(buffer.cursorY).toBeLessThan(rows); + expect(buffer.getLine(buffer.baseY + buffer.cursorY)!.getCell(buffer.cursorX)!.getChars()).toBe("m"); + for (let i = buffer.baseY; i < buffer.length; i++) expect(buffer.getLine(i)!.isWrapped).toBe(false); + } + } finally { screen.finish(); terminal.dispose(); } + }); +}); diff --git a/packages/tui/test/render.test.ts b/packages/tui/test/render.test.ts index 8fe52de..e0e33f0 100644 --- a/packages/tui/test/render.test.ts +++ b/packages/tui/test/render.test.ts @@ -18,6 +18,10 @@ import { pushUser, reduce, renderTail, + renderCell, + renderCellStyled, + sanitize, + wrapToWidth, renderTranscript, statusLine, readings, @@ -133,7 +137,7 @@ describe("transcript", () => { { type: "tool_start", call: { id: "p", name: "apply_patch", arguments: { patch }, repaired: false, validated: true } }, { type: "tool_end", id: "p", ok: true, output: "applied", ms: 3 }, ]); - const out = renderTranscript(state, OPTS); + const out = renderTranscript(state, { ...OPTS, outputLines: Infinity }); expect(out[0]).toBe("⏺ Patch(--- a/x.py…)"); expect(out).toContain(" -old"); expect(out).toContain(" +new"); @@ -202,6 +206,16 @@ describe("transcript", () => { expect(out).toContain("2 consecutive parse failures"); }); + it("shows a subagent that ended without done as failed, in the error tone", () => { + // It used to read "done" whatever happened, with the failure only in the + // tool output underneath. + const out = renderTranscript(fold([{ type: "queue", agent: "explorer", state: "failed" }]), OPTS); + expect(out[0]).toBe("⏺ Task(explorer) · failed"); + const tone = (state: "done" | "failed") => renderCellStyled({ kind: "queue", agent: "explorer", state }, OPTS)[0]?.tone; + expect(tone("failed")).toBe("bad"); + expect(tone("done")).toBe("dim"); + }); + it("clips long tool output rather than flooding the screen", () => { const long = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n"); const state = fold([ @@ -214,6 +228,102 @@ describe("transcript", () => { }); }); +describe("tool output previews", () => { + function tool(output: string, name = "mcp", ok = true, args: Record = { server: "fixture" }): Extract { + return fold([ + { type: "tool_start", call: { id: "t", name, arguments: args, repaired: false, validated: true } }, + { type: "tool_end", id: "t", ok, output, ms: 1 }, + ]).cells[0] as Extract; + } + + it("bounds one huge JSON line to three decorated physical rows without changing the original", () => { + const output = JSON.stringify({ content: "한글🙂".repeat(1200), tail: "ORIGINAL_END" }); + const cell = deepFreeze(tool(output)) as Extract; + const rows = wrapToWidth(` ⎿ ${output}`, 40); + const collapsed = renderCell(cell, { width: 40, showShortcuts: true }); + expect(collapsed).toHaveLength(6); // head, 3 preview rows, notice, blank + expect(collapsed.slice(1, 4)).toEqual(rows.slice(0, 3)); + expect(collapsed[4]).toContain(`+${rows.length - 3} lines`); + expect(collapsed[4]).toContain("ctrl-o"); + expect(collapsed.every((row) => displayWidth(row) <= 40)).toBe(true); + const expanded = renderCell(cell, { width: 40, outputLines: Infinity }); + expect(expanded.slice(1, -1)).toEqual(rows); + expect(expanded.join("")).toContain("ORIGINAL_END"); + expect(cell.output).toBe(output); + }); + + it("counts error decoration and multiline wrapping before clipping, and preserves error tones", () => { + const output = "12345678901234567890\nsecond line\nthird line\nlast"; + const cell = tool(output, "bash", false); + const expected = [" ⎿ Error: 12345678901234567890", " second line", " third line", " last"] + .flatMap((row) => wrapToWidth(row, 24)); + const shown = renderCellStyled(cell, { width: 24, showShortcuts: true }); + expect(shown.slice(1, 4)).toEqual(expected.slice(0, 3).map((text) => ({ text, tone: "bad" }))); + expect(shown[4]?.text).toContain(`+${expected.length - 3} lines`); + expect(shown[4]?.text).toContain("ctrl-o"); + expect(renderCell(cell, { width: 24, outputLines: Infinity }).slice(1, -1)).toEqual(expected); + }); + + it("budgets visible control escapes, tabs and wide glyphs even at one column", () => { + const output = "\x1b[31m\t한글🙂\rTAIL\n".repeat(8) + "END\r"; + const cell = tool(output, "한글도구", false, { command: "\x1b[2J\t한글" }); + for (const width of [1, 2, 5, 12, 40]) { + const preview = renderCell(cell, { width, showShortcuts: true }); + expect(preview).toHaveLength(6); + expect(preview.every((row) => displayWidth(row) <= width), String(width)).toBe(true); + const full = renderCell(cell, { width, outputLines: Infinity }); + expect(full.every((row) => displayWidth(row) <= width), String(width)).toBe(true); + expect(full.join("")).not.toMatch(/[\x1b\r\t]/); + expect(full.join("")).toContain("^[[31m"); + expect(full.join("")).toContain("END^M"); + } + expect(cell.output).toBe(output); + expect(sanitize(output)).toContain("^MTAIL"); + }); + + it.each(["read", "mcp"])("expands every %s output line beyond the former 1000-line cap", (name) => { + const output = Array.from({ length: 1205 }, (_, i) => `ROW_${i}`).join("\n"); + const cell = tool(output, name); + const compact = renderCell(cell, { width: 72, showShortcuts: true }); + expect(compact.join("\n")).not.toContain("ROW_1204"); + expect(compact.join("\n")).toContain("ctrl-o"); + if (name === "read") expect(compact.join("\n")).toContain("Read 1205 lines"); + const full = renderCell(cell, { width: 72, outputLines: Infinity, showShortcuts: true }); + expect(full).toHaveLength(1207); + expect(full[1]).toBe(" ⎿ ROW_0"); + expect(full.at(-2)).toBe(" ROW_1204"); + expect(full.join("\n")).not.toContain("ctrl-o"); + expect(cell.output).toBe(output); + }); + + it("bounds a wrapped diff separately from its failure, and expands all colored diff rows", () => { + const patch = "+" + "가".repeat(100) + "\n-removed\n+FINAL_PATCH_LINE"; + const cell = deepFreeze(tool("patch failed", "apply_patch", false, { patch })) as Extract; + const preview = renderCell(cell, { width: 40, showShortcuts: true }); + expect(preview).toHaveLength(7); // head, 3 diff rows, notice, error, blank + expect(preview[4]).toContain("ctrl-o"); + expect(preview).toContain(" ⎿ Error: patch failed"); + expect(preview.join("\n")).not.toContain("FINAL_PATCH_LINE"); + const full = renderCellStyled(cell, { width: 40, outputLines: Infinity }); + expect(full.find((row) => row.text.includes("-removed"))?.tone).toBe("bad"); + expect(full.find((row) => row.text.includes("+FINAL_PATCH_LINE"))?.tone).toBe("ok"); + expect(full.slice(1, 4).every((row) => row.tone === "ok")).toBe(true); + expect(cell.args["patch"]).toBe(patch); + }); + + it("honors a finite preview override and never advertises inactive keyboard shortcuts", () => { + const cell = tool(Array.from({ length: 10 }, (_, i) => `line ${i}`).join("\n")); + const preview = renderCell(cell, { width: 72, outputLines: 6 }); + expect(preview).toHaveLength(9); + expect(preview[7]).toContain("+4 lines"); + expect(preview.join("\n")).not.toContain("ctrl-o"); + expect(renderCell(tool("one\ntwo", "read"), { width: 72 }).join("\n")).not.toContain("ctrl-o"); + expect(renderCell(tool("one\ntwo"), { width: 72, showShortcuts: true })).toEqual([ + "⏺ MCP(fixture)", " ⎿ one", " two", "", + ]); + }); +}); + describe("live tail", () => { it("shows only the newest reasoning line, and only when reasoning is shown", () => { const state = fold([ diff --git a/packages/tui/test/screen-output.test.ts b/packages/tui/test/screen-output.test.ts new file mode 100644 index 0000000..8d7137b --- /dev/null +++ b/packages/tui/test/screen-output.test.ts @@ -0,0 +1,219 @@ +import { PassThrough } from "node:stream"; +import xterm from "@xterm/headless"; +import { describe, expect, it } from "vitest"; +import { Screen } from "../src/screen.js"; + +const { Terminal } = xterm; +type TerminalInstance = InstanceType; +class FakeTTY extends PassThrough { + isTTY = true; + setRawMode(): this { return this; } +} +const allLines = (terminal: TerminalInstance): string[] => Array.from({ length: terminal.buffer.active.length }, + (_, index) => terminal.buffer.active.getLine(index)!.translateToString(true)); +const occurrences = (lines: string[], text: string): number => lines.join("\n").split(text).length - 1; +const output = JSON.stringify({ + first: "BEGIN_JSON", values: Array.from({ length: 12 }, (_, index) => `value-${index}-${"x".repeat(20)}`), last: "FINAL_JSON", +}); + +function terminalSession(initialCols: number, initialRows: number, toolOutput = output) { + let cols = initialCols; + let rows = initialRows; + const chunks: string[] = []; + const stdin = new FakeTTY(); + const terminal = new Terminal({ cols, rows, scrollback: 3000, convertEol: true, allowProposedApi: true }); + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => cols, rows: () => rows, interactive: true }); + screen.attachInput(stdin as unknown as NodeJS.ReadStream, (key) => { + if (screen.handleOutputViewKey(key)) return; + if (key.type === "ctrl" && key.key === "o") screen.toggleOutputView(); + }); + const flush = (): Promise => new Promise((resolve) => terminal.write(chunks.splice(0).join(""), resolve)); + const key = async (input: string): Promise => { stdin.write(input); await flush(); }; + const toggle = (): Promise => key("\x0f"); + const resize = async (nextCols: number, nextRows: number): Promise => { + cols = nextCols; rows = nextRows; + terminal.resize(cols, rows); + screen.redraw(); + await flush(); + }; + const visible = () => allLines(terminal).slice(terminal.buffer.active.baseY); + const assertEditor = (): void => { + const buffer = terminal.buffer.active; + const caretLine = buffer.getLine(buffer.baseY + buffer.cursorY)!; + expect(caretLine.translateToString(true)).toContain("stable draft"); + expect(buffer.cursorX).toBe(7); + expect(caretLine.getCell(buffer.cursorX)!.getChars()).toBe("b"); + expect(occurrences(visible(), "stable draft")).toBe(1); + }; + screen.setComposer({ draft: { text: "stable draft", cursor: 3 } }); + screen.append({ kind: "user", text: "REQUEST_UNIQUE" }); + screen.append({ kind: "tool", id: "json", name: "bash", args: { command: "fixture-json" }, repaired: false, hooks: [], output: toolOutput, ok: true, ms: 1 }); + return { screen, terminal, flush, key, toggle, resize, visible, assertEditor, close: () => { screen.finish(); terminal.dispose(); } }; +} + +describe("physical tool output folding", () => { + it("folds a wrapped single-line JSON result to three body rows and restores the normal screen exactly after Ctrl-O", async () => { + const s = terminalSession(80, 24); + try { + await s.flush(); + const compact = allLines(s.terminal); + const header = compact.findIndex((line) => line.includes("Bash(fixture-json)")); + const hidden = compact.findIndex((line) => /… \+\d+ (?:lines|rows)/.test(line)); + expect(header).toBeGreaterThanOrEqual(0); + expect(hidden - header - 1).toBe(3); + expect(compact.join("\n")).toContain("BEGIN_JSON"); + expect(compact.join("\n")).not.toContain("FINAL_JSON"); + expect(compact.join("\n")).toContain("ctrl-o"); + const cells = structuredClone(s.screen.view.cells); + const cursor = { x: s.terminal.buffer.active.cursorX, y: s.terminal.buffer.active.cursorY, base: s.terminal.buffer.active.baseY }; + s.assertEditor(); + for (const closeKey of ["\x0f", "\x1b", "q"]) { + await s.toggle(); + expect(s.screen.outputViewOpen).toBe(true); + expect(s.terminal.buffer.active.type).toBe("alternate"); + const expanded = allLines(s.terminal); + expect(expanded.join("\n")).toContain("FINAL_JSON"); + expect(expanded.join("\n")).not.toMatch(/… \+\d+ (?:lines|rows)/); + expect(occurrences(expanded, "REQUEST_UNIQUE")).toBe(1); + expect(occurrences(expanded, "Bash(fixture-json)")).toBe(1); + await s.key(closeKey); + expect(s.screen.outputViewOpen).toBe(false); + expect(s.terminal.buffer.active.type).toBe("normal"); + expect(allLines(s.terminal)).toEqual(compact); + expect({ x: s.terminal.buffer.active.cursorX, y: s.terminal.buffer.active.cursorY, base: s.terminal.buffer.active.baseY }).toEqual(cursor); + s.assertEditor(); + } + expect(s.screen.view.cells).toEqual(cells); + expect(s.screen.verboseOutput).toBe(false); + } finally { s.close(); } + }); + + it("makes all full output reachable in a short viewer and restores the draft after a resize", async () => { + const s = terminalSession(40, 8); + try { + await s.flush(); + s.assertEditor(); + expect(allLines(s.terminal).join("\n")).not.toContain("FINAL_JSON"); + await s.toggle(); + expect(s.terminal.buffer.active.type).toBe("alternate"); + await s.key("\x1b[H"); + expect(s.visible().join("\n")).toContain("BEGIN_JSON"); + await s.key("\x1b[F"); + expect(s.visible().join("\n")).toContain("FINAL_JSON"); + await s.resize(80, 24); + expect(s.terminal.buffer.active.type).toBe("alternate"); + expect(occurrences(s.visible(), "Bash(fixture-json)")).toBeLessThanOrEqual(1); + await s.toggle(); + expect(s.terminal.buffer.active.type).toBe("normal"); + expect(s.visible().join("\n")).not.toContain("FINAL_JSON"); + s.assertEditor(); + await s.toggle(); + await s.resize(40, 8); + expect(s.terminal.buffer.active.type).toBe("alternate"); + await s.key("\x1b[F"); + expect(s.visible().join("\n")).toContain("FINAL_JSON"); + await s.key("q"); + expect(s.terminal.buffer.active.type).toBe("normal"); + expect(s.visible().join("\n")).not.toContain("FINAL_JSON"); + s.assertEditor(); + expect(s.screen.view.cells.find((cell) => cell.kind === "tool")?.output).toBe(output); + } finally { s.close(); } + }); + + it("commits a task result that arrived while the viewer was open once after returning to the prompt", async () => { + const s = terminalSession(80, 24); + try { + await s.flush(); + await s.toggle(); + s.screen.setWorking(true); + s.screen.apply({ type: "content_delta", text: "RESULT_WHILE_VIEWING" }); + s.screen.setWorking(false); + await s.flush(); + expect(s.terminal.buffer.active.type).toBe("alternate"); + await s.key("q"); + expect(s.terminal.buffer.active.type).toBe("normal"); + const transcript = allLines(s.terminal); + expect(occurrences(transcript, "RESULT_WHILE_VIEWING")).toBe(1); + expect(occurrences(transcript, "REQUEST_UNIQUE")).toBe(1); + expect(occurrences(transcript, "Bash(fixture-json)")).toBe(1); + expect(transcript.join("\n")).not.toContain("FINAL_JSON"); + s.assertEditor(); + } finally { s.close(); } + }); + + it.each(["approval", "secret"])("returns from the viewer automatically when a %s prompt needs attention", async (prompt) => { + const s = terminalSession(80, 24); + try { + await s.flush(); + await s.toggle(); + if (prompt === "approval") s.screen.setComposer({ + draft: { text: "stable draft", cursor: 3 }, + confirm: { title: "Approve this command?", lines: ["Run a guarded command"], choices: ["❯ Yes", "No"] }, + }); + else s.screen.setComposer({ + draft: { text: "secret-fixture", cursor: 14 }, + secret: { title: "Enter account credential", lines: [], prompt: "key › " }, + }); + await s.flush(); + expect(s.screen.outputViewOpen).toBe(false); + expect(s.terminal.buffer.active.type).toBe("normal"); + expect(s.visible().join("\n")).toContain(prompt === "approval" ? "Approve this command?" : "Enter account credential"); + expect(s.visible().join("\n")).not.toContain("secret-fixture"); + s.screen.setComposer({ draft: { text: "stable draft", cursor: 3 } }); + await s.flush(); + s.assertEditor(); + } finally { s.close(); } + }); + + it("leaves the alternate screen and flushes newly completed cells when the session finishes", async () => { + const s = terminalSession(80, 24); + try { + await s.flush(); + await s.toggle(); + s.screen.append({ kind: "assistant", text: "FINISH_UNIQUE" }); + s.screen.finish(); + await s.flush(); + expect(s.screen.outputViewOpen).toBe(false); + expect(s.terminal.buffer.active.type).toBe("normal"); + const transcript = allLines(s.terminal); + expect(occurrences(transcript, "FINISH_UNIQUE")).toBe(1); + expect(occurrences(transcript, "REQUEST_UNIQUE")).toBe(1); + expect(occurrences(transcript, "Bash(fixture-json)")).toBe(1); + expect(transcript.join("\n")).not.toContain("stable draft"); + expect(transcript.join("\n")).not.toContain("FINAL_JSON"); + } finally { s.close(); } + }); + + it("commits new results once after widening reduces the physical row count of an earlier tool", async () => { + const s = terminalSession(20, 24, "x".repeat(45)); + try { + await s.flush(); + await s.resize(80, 24); + s.screen.append({ kind: "assistant", text: "AFTER_WIDENING" }); + await s.flush(); + expect(occurrences(allLines(s.terminal), "AFTER_WIDENING")).toBe(1); + s.screen.setHint("ordinary refresh after the resize"); + await s.flush(); + expect(occurrences(allLines(s.terminal), "AFTER_WIDENING")).toBe(1); + s.assertEditor(); + } finally { s.close(); } + }); + + it("finishes cleanly after the full-output viewer was narrowed and shortened", async () => { + const s = terminalSession(80, 24, "RESULT_OK"); + try { + await s.flush(); + await s.toggle(); + await s.resize(20, 8); + s.screen.finish(); + await s.flush(); + await new Promise((resolve) => s.terminal.write("SHELL_PROMPT", resolve)); + expect(s.screen.outputViewOpen).toBe(false); + expect(s.terminal.buffer.active.type).toBe("normal"); + const visible = s.visible().join("\n"); + expect(visible).toContain("SHELL_PROMPT"); + expect(visible).not.toMatch(/stable draft|for shortcuts|[╭╮╰╯│]/); + expect(allLines(s.terminal).join("\n")).toContain("RESULT_OK"); + } finally { s.close(); } + }); +}); diff --git a/packages/tui/test/screen-viewport.test.ts b/packages/tui/test/screen-viewport.test.ts index af4b65c..29a96d4 100644 --- a/packages/tui/test/screen-viewport.test.ts +++ b/packages/tui/test/screen-viewport.test.ts @@ -37,6 +37,60 @@ function wideAmbiguous(terminal: TerminalInstance): void { afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); describe("physical footer viewport", () => { + it.each([1, 45])("blinks a %i-line live reply inside the viewport without moving the editor or scrollback", async (count) => { + vi.stubEnv("NO_COLOR", undefined); + vi.stubEnv("TERM", "xterm-256color"); + vi.resetModules(); + const { Screen: ColourScreen } = await import("../src/screen.js"); + const answer = Array.from({ length: count }, (_, i) => `PULSE_${String(i).padStart(2, "0")}`).join("\n"); + const chunks: string[] = []; + const frames: string[] = []; + const frame = (): void => { frames.push(chunks.splice(0).join("")); }; + const screen = new ColourScreen({ write: (text) => chunks.push(text), columns: () => 40, rows: () => 8, interactive: true }); + vi.useFakeTimers(); + try { + screen.setComposer({ draft: { text: "draft", cursor: 3 } }); + screen.append({ kind: "user", text: "SETTLED_USER" }); + screen.setWorking(true); + screen.apply({ type: "stream", content: answer }); + vi.advanceTimersByTime(40); + frame(); + vi.advanceTimersByTime(960); + frame(); + vi.advanceTimersByTime(1000); + frame(); + screen.apply({ type: "content_delta", text: answer }); + screen.setWorking(false); + expect(vi.getTimerCount()).toBe(0); + frame(); + } finally { screen.finish(); vi.useRealTimers(); } + + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols: 40, rows: 8, scrollback: 2000 }); + try { + let baseY = 0; + for (const [i, frame] of frames.slice(0, 3).entries()) { + await write(terminal, frame); + const buffer = terminal.buffer.active; + const visible = lines(terminal).slice(buffer.baseY); + const indicator = visible.find((line) => line.includes(count === 1 ? "PULSE_00" : "Working…")); + expect(indicator).toBeDefined(); + expect(indicator![0]).toBe(i === 1 ? " " : "⏺"); + expect(buffer.getLine(buffer.baseY + buffer.cursorY)!.translateToString(true)).toContain("draft"); + expect(buffer.cursorX).toBe(7); + if (i === 0) baseY = buffer.baseY; + else { + expect(buffer.baseY).toBe(baseY); + expect(frame).not.toContain("\n"); + } + expect(lines(terminal, buffer.baseY).join("\n")).not.toMatch(/PULSE_|Working…|draft|╭|╰/); + } + await write(terminal, frames[3]!); + const all = lines(terminal).join("\n"); + for (const line of answer.split("\n")) expect(all.split(line), line).toHaveLength(2); + expect(all).not.toContain("Working…"); + } finally { terminal.dispose(); } + }); + it("keeps repeated live frames out of scrollback and commits the answer once", async () => { const answer = Array.from({ length: 45 }, (_, i) => `LIVE_${String(i).padStart(2, "0")}`).join("\n"); const frames = recording(40, 8, (screen, frame) => { diff --git a/packages/tui/test/screen.test.ts b/packages/tui/test/screen.test.ts index 60766e4..5091dd5 100644 --- a/packages/tui/test/screen.test.ts +++ b/packages/tui/test/screen.test.ts @@ -6,9 +6,10 @@ */ import { PassThrough } from "node:stream"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Screen } from "../src/screen.js"; -import { term } from "../src/theme.js"; +import { BULLET } from "../src/render.js"; +import { style, term } from "../src/theme.js"; function screen(write: (s: string) => void): Screen { return new Screen({ @@ -149,3 +150,232 @@ describe("unchanged footer redraws", () => { expect(painted.indexOf(term.clearLine)).toBeLessThan(painted.lastIndexOf(term.endSync)); }); }); + +describe("working indicator", () => { + let WorkingScreen: typeof Screen; + const screen = (write: (s: string) => void): Screen => new WorkingScreen({ + write, columns: () => 80, rows: () => 24, interactive: true, now: () => 1_000, + }); + beforeEach(async () => { + vi.stubEnv("NO_COLOR", undefined); + vi.stubEnv("TERM", "xterm-256color"); + vi.resetModules(); + WorkingScreen = (await import("../src/screen.js")).Screen; + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("keeps one slow pulse after streamed prose replaces the activity label", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "draft", cursor: 3 } }); + s.setWorking(true); + s.setActivity("Thinking…"); + s.apply({ type: "stream", content: "Now writing the game." }); + vi.advanceTimersByTime(40); + s.setActivity(null); + out.length = 0; + s.setWorking(true); + expect(out).toEqual([]); + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(960); + const hidden = out.join(""); + expect(hidden).toContain(term.clearLine + " Now writing the game."); + expect(hidden).not.toContain(BULLET); + expect(hidden).not.toContain("Working…"); + expect(hidden).not.toContain("draft"); + expect(hidden).not.toContain("\n"); + expect(hidden).toContain(term.column(7) + term.showCursor); + expect(hidden.startsWith(term.beginSync)).toBe(true); + expect(hidden.endsWith(term.endSync)).toBe(true); + + out.length = 0; + vi.advanceTimersByTime(1000); + expect(out.join("")).toContain(style.accent + BULLET + style.reset + " Now writing the game."); + + vi.advanceTimersByTime(1000); + out.length = 0; + s.setWorking(false); + expect(out.join("")).toContain(style.accent + BULLET + style.reset + " Now writing the game."); + expect(vi.getTimerCount()).toBe(0); + s.finish(); + }); + + it("uses the activity row for work, then stops and resumes with its task state", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setWorking(true); + out.length = 0; + s.setActivity("Calling write…"); + expect(out.join("")).toContain("Calling write…"); + expect(out.join("")).not.toContain("Working…"); + s.setActivity(null); + s.setWorking(false); + out.length = 0; + vi.advanceTimersByTime(5000); + expect(out).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + + s.setWorking(true); + expect(out.join("")).toContain("Working…"); + expect(out.join("")).toContain(BULLET); + expect(vi.getTimerCount()).toBe(1); + s.finish(); + }); + + it("does not animate or re-emit completed transcript while hidden reasoning continues", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.append({ kind: "assistant", text: "Completed step" }); + s.setWorking(true); + out.length = 0; + s.apply({ type: "stream", reasoning: "hidden" }); + vi.advanceTimersByTime(40); + expect(out).toEqual([]); + vi.advanceTimersByTime(960); + expect(out.join("")).toContain("Working…"); + expect(out.join("")).not.toContain(BULLET); + expect(out.join("")).not.toContain("Completed step"); + expect(out.join("")).not.toContain("hidden"); + expect(out.join("")).not.toContain("\n"); + s.finish(); + }); + + it("pulses the latest running tool rather than a completed tool behind it", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setWorking(true); + s.apply({ type: "tool_start", call: { id: "a", name: "bash", arguments: { command: "still-running" }, repaired: false, validated: true } }); + s.apply({ type: "tool_start", call: { id: "b", name: "bash", arguments: { command: "finished" }, repaired: false, validated: true } }); + s.apply({ type: "tool_end", id: "b", ok: true, output: "done", ms: 1 }); + out.length = 0; + vi.advanceTimersByTime(1000); + expect(out.join("")).toContain("still-running"); + expect(out.join("")).not.toContain(BULLET); + expect(out.join("")).not.toContain("finished"); + expect(out.join("")).not.toContain("Working…"); + expect(out.join("")).not.toContain("\n"); + s.finish(); + }); + + it("keeps a visible indicator for tall live replies without adding scrollback on ticks", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "", cursor: 0 } }); + s.setWorking(true); + s.apply({ type: "stream", content: Array.from({ length: 45 }, (_, i) => `line ${i}`).join("\n") }); + vi.advanceTimersByTime(40); + expect(out.join("")).toContain("Working…"); + out.length = 0; + vi.advanceTimersByTime(3000); + const ticks = out.join(""); + expect(ticks.match(/Working…/g)).toHaveLength(3); + expect(ticks).not.toContain("line 0"); + expect(ticks).not.toContain("line 44"); + expect(ticks).not.toContain("\n"); + expect(ticks).not.toContain(term.clearScreen); + s.finish(); + }); + + it("rebuilds at a changed terminal height before using pulse row positions", () => { + vi.useFakeTimers(); + let rows = 24; + const out: string[] = []; + const s = new WorkingScreen({ write: (chunk) => out.push(chunk), columns: () => 80, rows: () => rows, interactive: true }); + s.setComposer({ draft: { text: "draft", cursor: 5 } }); + s.setWorking(true); + out.length = 0; + rows = 12; + vi.advanceTimersByTime(1000); + expect(out.join("")).toContain(term.clearScreen + term.home); + expect(out.join("")).toContain("draft"); + out.length = 0; + vi.advanceTimersByTime(1000); + expect(out.join("")).not.toContain("\n"); + s.finish(); + }); + + it("clears both the pulse and a pending stream flush on finish", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setWorking(true); + s.apply({ type: "stream", content: "pending" }); + expect(vi.getTimerCount()).toBe(2); + s.finish(); + expect(vi.getTimerCount()).toBe(0); + out.length = 0; + vi.advanceTimersByTime(5000); + expect(out).toEqual([]); + }); + + it("adds no output or ticker to piped runs", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = new WorkingScreen({ write: (chunk) => out.push(chunk), interactive: false }); + s.setWorking(true); + s.setActivity("Thinking…"); + vi.advanceTimersByTime(5000); + expect(out).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + s.finish(); + }); + + it("uses a static, uncoloured indicator with NO_COLOR", async () => { + vi.useFakeTimers(); + vi.stubEnv("NO_COLOR", "1"); + vi.resetModules(); + const { Screen: PlainScreen } = await import("../src/screen.js"); + const out: string[] = []; + const s = new PlainScreen({ write: (chunk) => out.push(chunk), interactive: true, columns: () => 80 }); + s.setWorking(true); + expect(out.join("")).toContain(`${BULLET} Working…`); + expect(out.join("")).not.toMatch(/\x1b\[[\d;]*m/); + out.length = 0; + vi.advanceTimersByTime(5000); + expect(out).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + s.setActivity("Thinking…"); + out.length = 0; + vi.advanceTimersByTime(1000); + expect(out.join("")).toContain(`${BULLET} Thinking…`); + expect(out.join("")).not.toMatch(/\x1b\[[\d;]*m/); + s.finish(); + }); +}); + +describe("composer command colour", () => { + afterEach(() => { vi.unstubAllEnvs(); }); + + it.each([false, true])("colours only the token and respects NO_COLOR=%s", async (plain) => { + vi.stubEnv("NO_COLOR", plain ? "1" : undefined); + vi.stubEnv("TERM", "xterm-256color"); + vi.resetModules(); + const { Screen: CommandScreen } = await import("../src/screen.js"); + const { style: palette } = await import("../src/theme.js"); + const out: string[] = []; + const s = new CommandScreen({ write: (chunk) => out.push(chunk), interactive: true, columns: () => 30 }); + try { + s.setComposer({ draft: { text: "/model 한글🙂", cursor: 10 }, commandRange: { start: 0, end: 6 } }); + const output = out.join(""); + if (plain) { + expect(output).not.toMatch(/\x1b\[[\d;]*m/); + expect(output).toContain("/model 한글🙂"); + } else { + expect(output).toContain(`${palette.accent}/model${palette.reset} 한글🙂`); + } + out.length = 0; + s.setComposer({ draft: { text: "/model", cursor: 6 }, commandRange: { start: 0, end: 6 }, secret: { title: "Key", lines: [], prompt: "> " } }); + expect(out.join("")).not.toContain("/model"); + expect(out.join("")).not.toContain(`${palette.accent}••••••`); + } finally { s.finish(); } + }); +});