diff --git a/.gitignore b/.gitignore index fcc93ca..4586aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Thumbs.db # stray compiled binary (v1 Go artifact name) /cli + +# local changelog notes +/CHANGELOG.md diff --git a/CLAUDE.md b/CLAUDE.md index 199a601..c232e4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,9 @@ SEARXNG_URL self-hosted SearXNG instance ``` CODEBASE_FRESH=1 skip auto-resume of prior session (same as --new) CODEBASE_REACT_TUI=1 legacy ink renderer (pi-tui is the default) +CODEBASE_NO_FULLSCREEN=1 inline scrollback layout instead of the full-screen + view (pinned input, wheel/PgUp/PgDn scrolls the chat, + drag selects, Ctrl+C copies the selection) CODEBASE_NO_SUGGESTIONS=1 disable ghost-text prompt suggestions CODEBASE_NO_NOTIFY=1 silence the turn-completion bell + OS notification CODEBASE_NO_AUTO_MEMORY=1 disable background memory extraction diff --git a/package-lock.json b/package-lock.json index e25a15d..f9d9fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/proper-lockfile": "^4.1.4", "diff": "^9.0.0", "glob": "^13.0.1", + "highlight.js": "^11.12.0", "ignore": "^7.0.0", "ink": "^5.2.1", "openai": "6.26.0", @@ -3730,6 +3731,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", diff --git a/package.json b/package.json index 969d77e..3534f56 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/proper-lockfile": "^4.1.4", "diff": "^9.0.0", "glob": "^13.0.1", + "highlight.js": "^11.12.0", "ignore": "^7.0.0", "ink": "^5.2.1", "openai": "6.26.0", diff --git a/src/agent/__test__/agent-e2e.test.ts b/src/agent/__test__/agent-e2e.test.ts index 17fe891..dfecd00 100644 --- a/src/agent/__test__/agent-e2e.test.ts +++ b/src/agent/__test__/agent-e2e.test.ts @@ -135,6 +135,159 @@ describe("agent bundle end-to-end", () => { expect(stored.messages[1].role).toBe("assistant"); }); + it("session keeps every turn, not just the latest run", async () => { + faux.setResponses([fauxAssistantMessage("first reply"), fauxAssistantMessage("second reply")]); + const opts = { + cwd, + configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const }, + autoApprove: true, + }; + const bundle = createAgent(opts); + await bundle.agent.prompt("one"); + await bundle.agent.prompt("two"); + expect(bundle.sessions.load(faux.getModel().id)?.messages).toHaveLength(4); + + // A resumed session keeps its history when the next turn saves. + faux.setResponses([fauxAssistantMessage("third reply")]); + const resumed = createAgent({ ...opts, resume: true }); + expect(resumed.resumedMessages).toHaveLength(4); + await resumed.agent.prompt("three"); + expect(resumed.sessions.load(faux.getModel().id)?.messages).toHaveLength(6); + }); + + it("compacts once per threshold crossing, not on every later request", async () => { + faux.unregister(); + faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }], + }); + const firstMessages: string[] = []; + faux.setResponses( + Array.from({ length: 60 }, () => (context: Context) => { + const first = context.messages[0]; + firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content)); + return fauxAssistantMessage("ok"); + }), + ); + const opts = { + cwd, + configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const }, + autoApprove: true, + }; + const bundle = createAgent(opts); + let compactions = 0; + bundle.compactionMonitor.subscribe((s) => { + if (s.active) compactions++; + }); + + for (let i = 0; i < 10; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`); + const afterCrossing = compactions; + for (let i = 0; i < 4; i++) await bundle.agent.prompt(`small ${i}`); + + expect(afterCrossing).toBe(1); + expect(compactions).toBe(afterCrossing); + + // After a restart the saved summary is reused: no new compaction, and + // the first request already starts from the summary, not the full history. + const resumed = createAgent({ ...opts, resume: true }); + let resumedCompactions = 0; + resumed.compactionMonitor.subscribe((s) => { + if (s.active) resumedCompactions++; + }); + firstMessages.length = 0; + await resumed.agent.prompt("after restart"); + expect(resumedCompactions).toBe(0); + expect(firstMessages[0]).toContain("[Conversation compacted"); + }); + + it("never caches a failed summary: keeps full context, retries, and resumes clean", async () => { + faux.unregister(); + faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }], + }); + const firstMessages: string[] = []; + let summaryCalls = 0; + faux.setResponses( + Array.from({ length: 80 }, () => (context: Context) => { + if (context.systemPrompt?.startsWith("Summarize the prior conversation")) { + summaryCalls++; + throw new Error("summarizer down"); + } + const first = context.messages[0]; + firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content)); + return fauxAssistantMessage("ok"); + }), + ); + const opts = { + cwd, + configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const }, + autoApprove: true, + }; + const bundle = createAgent(opts); + for (let i = 0; i < 10; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`); + const failedAttempts = summaryCalls; + await bundle.agent.prompt("small after failure"); + + expect(failedAttempts).toBeGreaterThan(0); + // Not memoized, so the next request tries again instead of trusting the failure. + expect(summaryCalls).toBeGreaterThan(failedAttempts); + // The full context (earliest user constraint included) was still sent. + expect(firstMessages.at(-1)).toContain("big 0"); + expect(firstMessages.some((m) => m.includes("summarization failed"))).toBe(false); + + const saved = bundle.sessions.load(faux.getModel().id); + expect(saved?.compaction).toBeUndefined(); + + firstMessages.length = 0; + const resumed = createAgent({ ...opts, resume: true }); + await resumed.agent.prompt("after restart"); + expect(firstMessages[0]).toContain("big 0"); + }); + + it("never caches a summary from an aborted run, and resumes with the full history", async () => { + faux.unregister(); + faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }], + }); + const firstMessages: string[] = []; + let abortDuringSummary = false; + let summaries = 0; + const opts = { + cwd, + configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const }, + autoApprove: true, + }; + const bundle = createAgent(opts); + faux.setResponses( + Array.from({ length: 80 }, () => (context: Context) => { + if (context.systemPrompt?.startsWith("Summarize the prior conversation")) { + summaries++; + // The user hits Ctrl+C while the summary is being written. + if (abortDuringSummary) bundle.agent.abort(); + return fauxAssistantMessage(`summary #${summaries}`); + } + const first = context.messages[0]; + firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content)); + return fauxAssistantMessage("ok"); + }), + ); + for (let i = 0; i < 9; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`); + abortDuringSummary = true; + await bundle.agent.prompt(`big 9 ${"x ".repeat(300)}`).catch(() => undefined); + + expect(summaries).toBe(1); + expect(bundle.sessions.load(faux.getModel().id)?.compaction).toBeUndefined(); + + abortDuringSummary = false; + firstMessages.length = 0; + const resumed = createAgent({ ...opts, resume: true }); + await resumed.agent.prompt("after restart"); + // A fresh compaction may run after resume, but the aborted summary (#1) is never reused. + expect(firstMessages[0]).not.toContain("summary #1"); + }); + it("compaction monitor stays inactive when transcript is short", async () => { faux.setResponses([fauxAssistantMessage("short reply")]); const bundle = createAgent({ diff --git a/src/agent/agent.ts b/src/agent/agent.ts index a8d7286..d9a8bf8 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -7,6 +7,7 @@ import { CredentialsStore } from "../auth/credentials.js"; import { TokenManager } from "../auth/token-manager.js"; import { CheckpointStore } from "../checkpoint/store.js"; import { CompactionEngine } from "../compaction/engine.js"; +import { CompactionMemo } from "../compaction/memo.js"; import { CompactionMonitor } from "../compaction/monitor.js"; import { getOutputStyle } from "../config/output-styles.js"; import { ConfigStore } from "../config/store.js"; @@ -238,8 +239,10 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { // them at 128k and trigger compaction at ~96k on a 200k-context route. const compaction = new CompactionEngine({ glue, modelId: model.id, contextWindow: model.contextWindow }); const compactionMonitor = new CompactionMonitor(); + const compactionMemo = new CompactionMemo(); const sessions = new SessionStore({ cwd }); const resumed = opts.sessionId ? sessions.loadById(opts.sessionId) : opts.resume ? sessions.load(model.id) : null; + if (!opts.initialMessages) compactionMemo.restore(resumed?.compaction, resumed?.messages ?? []); const memory = new MemoryStore({ cwd, sourceSessionId: sessions.id }); // Background memory extraction: a cheap-model pass mines settled turns @@ -424,13 +427,16 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { getApiKey, streamFn, transformContext: async (messages, signal) => { - if (!compaction.needsCompaction(messages)) return withRelevantMemoryReminder(memory, messages); + // Reuse the last summary for history it already covers; only the + // newer messages count toward the threshold. + const view = compactionMemo.apply(messages); + if (!compaction.needsCompaction(view)) return withRelevantMemoryReminder(memory, view); // Stage 1 — microcompaction: clear stale tool-result content // (old reads, grep dumps, command output) without a summary // round-trip. Cheap. If that alone drops us back under the // threshold, we're done and skip the expensive summarize path. - const micro = compaction.microcompact(messages); + const micro = compaction.microcompact(view); if (micro.clearedCount > 0 && !compaction.needsCompaction(micro.messages)) { return withRelevantMemoryReminder(memory, micro.messages); } @@ -447,6 +453,16 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { signal, ); const result = await compaction.compact(working, signal); + if (result.details.summaryFailed || signal?.aborted) { + // Never cache or persist a failed summary. Keep the full context + // while it still fits (the next request retries); truncate only + // when it can't be sent at all. + return withRelevantMemoryReminder( + memory, + compaction.fitsContextWindow(working) ? working : result.messages, + ); + } + compactionMemo.record(messages, view, result.messages, result.details.collapsedMessageCount); await hooks.dispatch( "PostCompact", { @@ -522,12 +538,15 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { if (event.type !== "agent_end") return; if (opts.persistSession === false) return; try { - const messages = event.messages.length > 0 ? event.messages : (resumed?.messages ?? []); + // event.messages holds only this run's new messages; saving those + // overwrote the session with just the latest exchange. + const messages = agent.state.messages; if (messages.length === 0) return; sessions.save({ modelId: model.id, title: resumed?.title ?? null, messages, + compaction: compactionMemo.snapshot(), usage: resumed?.usage ?? { input: 0, output: 0, diff --git a/src/agent/visible-messages.test.ts b/src/agent/visible-messages.test.ts index 3b26be9..0a534b8 100644 --- a/src/agent/visible-messages.test.ts +++ b/src/agent/visible-messages.test.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { stripRuntimeMarkup, visibleMessages } from "./visible-messages.js"; +import { recentMessages, stripRuntimeMarkup, visibleMessages } from "./visible-messages.js"; describe("stripRuntimeMarkup", () => { it("removes system reminders and DSML framing", () => { @@ -31,3 +31,30 @@ describe("visibleMessages", () => { expect(visible[1]).toMatchObject({ role: "toolResult", toolCallId: "a" }); }); }); + +describe("recentMessages", () => { + const user = (n: number) => ({ role: "user", content: `u${n}`, timestamp: n }) as AgentMessage; + const tool = (n: number): AgentMessage => ({ + role: "toolResult", + toolCallId: `t${n}`, + toolName: "read_file", + content: [], + isError: false, + timestamp: n, + }); + + it("returns everything under the limit", () => { + const msgs = [user(1), tool(2)]; + expect(recentMessages(msgs, 5)).toBe(msgs); + }); + + it("starts the cut at a user turn so it never opens on a tool result", () => { + const msgs = [user(1), tool(2), tool(3), user(4), tool(5)]; + expect(recentMessages(msgs, 3).map((m) => m.timestamp)).toEqual([4, 5]); + }); + + it("falls back to the plain cut when one turn exceeds the limit", () => { + const msgs = [user(1), tool(2), tool(3), tool(4)]; + expect(recentMessages(msgs, 2).map((m) => m.timestamp)).toEqual([3, 4]); + }); +}); diff --git a/src/agent/visible-messages.ts b/src/agent/visible-messages.ts index 91c8d56..26bb7f6 100644 --- a/src/agent/visible-messages.ts +++ b/src/agent/visible-messages.ts @@ -65,6 +65,20 @@ export function visibleMessages(messages: AgentMessage[]): AgentMessage[] { return out; } +/** + * The last `limit` messages for display, widened back to the start of + * that user turn so the transcript never opens on a stray tool result. + * Falls back to the plain cut when that turn alone exceeds the limit. + */ +export function recentMessages(messages: AgentMessage[], limit: number): AgentMessage[] { + if (messages.length <= limit) return messages; + const cut = messages.length - limit; + for (let i = cut; i < messages.length; i++) { + if (messages[i].role === "user") return messages.slice(i); + } + return messages.slice(cut); +} + function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { diff --git a/src/commands/builtins/session.ts b/src/commands/builtins/session.ts index 1969e44..5fe990a 100644 --- a/src/commands/builtins/session.ts +++ b/src/commands/builtins/session.ts @@ -40,6 +40,10 @@ export const compact: Command = { ctx.emit("nothing to compact yet — transcript is short."); return { handled: true }; } + if (result.details.summaryFailed) { + ctx.emit(`compaction failed, transcript left unchanged: ${result.details.summary}`); + return { handled: true }; + } ctx.bundle.agent.state.messages = result.messages; ctx.emit( `compacted ${result.details.collapsedMessageCount} messages (~${result.details.truncatedTokens} tokens). ` + diff --git a/src/commands/builtins/usage.ts b/src/commands/builtins/usage.ts index 485ecda..29b2f29 100644 --- a/src/commands/builtins/usage.ts +++ b/src/commands/builtins/usage.ts @@ -4,7 +4,7 @@ import type { Command } from "../types.js"; const API_BASE = (process.env.CODEBASE_AUTH_BASE_URL ?? "https://codebase.design").replace(/\/+$/, ""); -interface Balance { +export interface Balance { creditsRemaining?: number; anyBuildsRemaining?: number; cheapBuildsRemaining?: number; @@ -31,14 +31,17 @@ export const usage: Command = { }, }; -export async function fetchUsageReport(): Promise { +export type BalanceResult = { ok: true; balance: Balance } | { ok: false; message: string }; + +/** GET the signed-in Codebase account's credit balance. Never throws. */ +export async function fetchBalance(): Promise { const store = new CredentialsStore(); let creds = store.load(); if (!creds) { - return "Not signed in. Run `codebase auth login` first."; + return { ok: false, message: "Not signed in. Run `codebase auth login` first." }; } if (creds.source !== "codebase") { - return "Usage is only tracked for Codebase accounts (you're using a BYOK/manual key)."; + return { ok: false, message: "Usage is only tracked for Codebase accounts (you're using a BYOK/manual key)." }; } try { await ensureFreshCredentials(); @@ -52,26 +55,32 @@ export async function fetchUsageReport(): Promise { headers: { Authorization: `Bearer ${creds.accessToken}` }, }); if (res.status === 401) { - return "Session expired — run `codebase auth login` again."; + return { ok: false, message: "Session expired — run `codebase auth login` again." }; } if (!res.ok) { - return `Couldn't fetch usage (HTTP ${res.status}).`; + return { ok: false, message: `Couldn't fetch usage (HTTP ${res.status}).` }; } - const b = (await res.json()) as Balance; - const formatted = formatUsageBalance(b); - const lines = [`Plan: ${formatted.planName}`, formatted.creditLine]; - if (formatted.pct != null) { - lines.push( - `${bar(formatted.pct)} ${formatted.pct}%${formatted.days != null ? ` · resets in ${formatted.days}d` : ""}`, - ); - } else { - lines.push("Monthly allowance was not returned yet; showing remaining credits only."); - } - lines.push(...formatTurnQuotas(b)); - return lines.join("\n"); + return { ok: true, balance: (await res.json()) as Balance }; } catch (err) { - return `Couldn't fetch usage: ${(err as Error).message}`; + return { ok: false, message: `Couldn't fetch usage: ${(err as Error).message}` }; + } +} + +export async function fetchUsageReport(): Promise { + const result = await fetchBalance(); + if (!result.ok) return result.message; + const b = result.balance; + const formatted = formatUsageBalance(b); + const lines = [`Plan: ${formatted.planName}`, formatted.creditLine]; + if (formatted.pct != null) { + lines.push( + `${bar(formatted.pct)} ${formatted.pct}%${formatted.days != null ? ` · resets in ${formatted.days}d` : ""}`, + ); + } else { + lines.push("Monthly allowance was not returned yet; showing remaining credits only."); } + lines.push(...formatTurnQuotas(b)); + return lines.join("\n"); } export function formatTurnQuotas(b: Balance): string[] { @@ -93,6 +102,7 @@ export function formatUsageBalance(b: Balance): { days: number | null; pct: number | null; planName: string; + remaining: number; } { const plan = typeof b.plan === "object" && b.plan !== null ? b.plan : undefined; const allowance = firstNumber( @@ -112,6 +122,7 @@ export function formatUsageBalance(b: Balance): { days, pct: null, planName, + remaining, }; } const used = Math.max(0, allowance - remaining); @@ -121,6 +132,7 @@ export function formatUsageBalance(b: Balance): { days, pct, planName, + remaining, }; } diff --git a/src/compaction/engine.test.ts b/src/compaction/engine.test.ts index e9d6005..fddfcbe 100644 --- a/src/compaction/engine.test.ts +++ b/src/compaction/engine.test.ts @@ -221,6 +221,54 @@ describe("CompactionEngine", () => { ]; const result = await engine.compact(messages); expect(result.details.summary).toMatch(/summarization failed/); + expect(result.details.summaryFailed).toBe(true); + }); + + it("treats an empty summary (how pi-ai reports a failed request) as failed", async () => { + const engine = new CompactionEngine({ glue: fakeGlue(""), modelId: "claude-sonnet-4-6", keepRecent: 1 }); + const messages = [ + userMessage("a"), + assistantMessage("b"), + userMessage("c"), + assistantMessage("d"), + userMessage("e"), + ]; + const result = await engine.compact(messages); + expect(result.details.summaryFailed).toBe(true); + }); + + it("treats a summary from an aborted request as failed", async () => { + const controller = new AbortController(); + const glue = { + fast: vi.fn(async () => ""), + smart: vi.fn(async () => { + controller.abort(); + return "partial summary"; + }), + } as unknown as GlueClient; + const engine = new CompactionEngine({ glue, modelId: "claude-sonnet-4-6", keepRecent: 1 }); + const messages = [ + userMessage("a"), + assistantMessage("b"), + userMessage("c"), + assistantMessage("d"), + userMessage("e"), + ]; + const result = await engine.compact(messages, controller.signal); + expect(result.details.summaryFailed).toBe(true); + }); + + it("marks a successful summary as not failed", async () => { + const engine = new CompactionEngine({ glue: fakeGlue("summary"), modelId: "claude-sonnet-4-6", keepRecent: 1 }); + const messages = [ + userMessage("a"), + assistantMessage("b"), + userMessage("c"), + assistantMessage("d"), + userMessage("e"), + ]; + const result = await engine.compact(messages); + expect(result.details.summaryFailed).toBe(false); }); it("microcompact clears stale tool-result content without a glue call", () => { diff --git a/src/compaction/engine.ts b/src/compaction/engine.ts index e296453..7202c5e 100644 --- a/src/compaction/engine.ts +++ b/src/compaction/engine.ts @@ -61,6 +61,11 @@ export class CompactionEngine { return Math.floor(window * this.thresholdRatio); } + /** Whether `messages` fit the model's whole context window (not just the compaction threshold). */ + fitsContextWindow(messages: AgentMessage[]): boolean { + return estimateTotalTokens(messages) < (this.explicitContextWindow ?? contextWindow(this.modelId)); + } + needsCompaction(messages: AgentMessage[]): boolean { return estimateTotalTokens(messages) >= this.threshold(); } @@ -84,7 +89,14 @@ export class CompactionEngine { if (messages.length <= this.keepRecent) { return { messages, - details: { readFiles: [], modifiedFiles: [], summary: "", truncatedTokens: 0, collapsedMessageCount: 0 }, + details: { + readFiles: [], + modifiedFiles: [], + summary: "", + truncatedTokens: 0, + collapsedMessageCount: 0, + summaryFailed: false, + }, }; } @@ -92,7 +104,14 @@ export class CompactionEngine { if (splitIdx <= 0) { return { messages, - details: { readFiles: [], modifiedFiles: [], summary: "", truncatedTokens: 0, collapsedMessageCount: 0 }, + details: { + readFiles: [], + modifiedFiles: [], + summary: "", + truncatedTokens: 0, + collapsedMessageCount: 0, + summaryFailed: false, + }, }; } @@ -104,7 +123,9 @@ export class CompactionEngine { details.truncatedTokens = truncatedTokens; details.collapsedMessageCount = older.length; - details.summary = await this.summarize(older, signal); + const summarized = await this.summarize(older, signal); + details.summary = summarized.text; + details.summaryFailed = summarized.failed; const summaryMessage: AgentMessage = { role: "user", @@ -139,14 +160,20 @@ export class CompactionEngine { return { messages: [summaryMessage, ackMessage, ...recent], details }; } - private async summarize(older: AgentMessage[], signal?: AbortSignal): Promise { + private async summarize(older: AgentMessage[], signal?: AbortSignal): Promise<{ text: string; failed: boolean }> { const transcript = older.map(serializeForSummary).join("\n\n"); try { - const out = await this.glue.smart(transcript, SUMMARY_SYSTEM_PROMPT, signal); - return out.trim(); + const out = (await this.glue.smart(transcript, SUMMARY_SYSTEM_PROMPT, signal)).trim(); + // pi-ai reports a failed request as an empty reply rather than throwing. + if (!out) + return { + text: "(summarization failed: empty response; transcript truncated without a summary)", + failed: true, + }; + return { text: out, failed: signal?.aborted === true }; } catch (err) { const reason = err instanceof Error ? err.message : String(err); - return `(summarization failed: ${reason}; transcript truncated without a summary)`; + return { text: `(summarization failed: ${reason}; transcript truncated without a summary)`, failed: true }; } } } @@ -204,6 +231,7 @@ export function extractFileOps(messages: AgentMessage[]): CompactionDetails { summary: "", truncatedTokens: 0, collapsedMessageCount: 0, + summaryFailed: false, }; } diff --git a/src/compaction/memo.test.ts b/src/compaction/memo.test.ts new file mode 100644 index 0000000..6cf3a66 --- /dev/null +++ b/src/compaction/memo.test.ts @@ -0,0 +1,74 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { CompactionMemo } from "./memo.js"; + +let clock = 0; +const msg = (text: string): AgentMessage => ({ role: "user", content: text, timestamp: ++clock }); +const texts = (ms: AgentMessage[]) => ms.map((m) => m.content); + +describe("CompactionMemo", () => { + it("passes messages through until a compaction is recorded", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b")]; + expect(memo.apply(ms)).toBe(ms); + }); + + it("reuses the summary for the covered head on later requests", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b"), msg("c"), msg("d")]; + const view = memo.apply(ms); + memo.record(ms, view, [msg("S"), msg("ack"), ms[2], ms[3]], 2); + + ms.push(msg("e")); + expect(texts(memo.apply(ms))).toEqual(["S", "ack", "c", "d", "e"]); + }); + + it("maps a second compaction back onto the original transcript", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b"), msg("c"), msg("d")]; + memo.record(ms, memo.apply(ms), [msg("S1"), msg("ack"), ms[2], ms[3]], 2); + + ms.push(msg("e"), msg("f")); + const view = memo.apply(ms); // S1 ack c d e f + memo.record(ms, view, [msg("S2"), msg("ack"), ms[4], ms[5]], 4); + + ms.push(msg("g")); + expect(texts(memo.apply(ms))).toEqual(["S2", "ack", "e", "f", "g"]); + }); + + it("drops the memo when the covered history changes (rewind / session swap)", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b"), msg("c")]; + memo.record(ms, memo.apply(ms), [msg("S"), msg("ack"), ms[2]], 2); + + const rewound = [ms[0]]; + expect(memo.apply(rewound)).toBe(rewound); + const other = [msg("x"), msg("y"), msg("z")]; + expect(memo.apply(other)).toBe(other); + }); + + it("survives a save/load round trip through the session file", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b"), msg("c")]; + memo.record(ms, memo.apply(ms), [msg("S"), msg("ack"), ms[2]], 2); + const saved = JSON.parse(JSON.stringify({ messages: ms, compaction: memo.snapshot() })); + + const restored = new CompactionMemo(); + restored.restore(saved.compaction, saved.messages); + expect(texts(restored.apply(saved.messages))).toEqual(["S", "ack", "c"]); + }); + + it("ignores a snapshot that no longer matches the transcript", () => { + const memo = new CompactionMemo(); + const ms = [msg("a"), msg("b"), msg("c")]; + memo.record(ms, memo.apply(ms), [msg("S"), msg("ack"), ms[2]], 2); + const snapshot = memo.snapshot(); + + const other = [msg("x"), msg("y"), msg("z")]; + const restored = new CompactionMemo(); + restored.restore(snapshot, other); + expect(restored.apply(other)).toBe(other); + restored.restore(snapshot, [ms[0]]); + expect(restored.snapshot()).toBeUndefined(); + }); +}); diff --git a/src/compaction/memo.ts b/src/compaction/memo.ts new file mode 100644 index 0000000..e64ffba --- /dev/null +++ b/src/compaction/memo.ts @@ -0,0 +1,74 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +/** What a session file stores so a restart reuses the summary instead of re-summarizing. */ +export interface CompactionSnapshot { + count: number; + /** Timestamp + role of the last covered message; identity doesn't survive a reload. */ + anchorTimestamp: number; + anchorRole: AgentMessage["role"]; + prefix: AgentMessage[]; +} + +interface Covered { + /** How many messages from the head of the transcript the summary replaces. */ + count: number; + /** The last covered message, by identity — a rewind or session swap invalidates the memo. */ + anchor: AgentMessage; + /** Summary + ack messages that stand in for the covered head. */ + prefix: AgentMessage[]; +} + +/** + * Remembers the last compaction so later requests reuse its summary. + * + * transformContext runs before every model request but never rewrites the + * agent's transcript, so without this each request re-summarized the same + * history once it crossed the threshold — a "Compacting context" banner + * (and a summarize call) on nearly every step. + */ +export class CompactionMemo { + private covered: Covered | undefined; + + /** `messages` with the already-summarized head swapped for the stored summary. */ + apply(messages: AgentMessage[]): AgentMessage[] { + const c = this.covered; + if (!c) return messages; + if (messages.length < c.count || messages[c.count - 1] !== c.anchor) { + this.covered = undefined; + return messages; + } + return [...c.prefix, ...messages.slice(c.count)]; + } + + /** + * Record a compaction of `view` (what `apply(messages)` returned) into + * `compacted`, where the first `collapsed` view messages were summarized. + */ + record(messages: AgentMessage[], view: AgentMessage[], compacted: AgentMessage[], collapsed: number): void { + if (collapsed <= 0) return; + const kept = view.length - collapsed; + const prefix = compacted.slice(0, compacted.length - kept); + // View indices past the old prefix line up with the original transcript. + const old = view === messages ? undefined : this.covered; + if (old && collapsed < old.prefix.length) return; + const offset = old ? old.count - old.prefix.length : 0; + const count = collapsed + offset; + const anchor = messages[count - 1]; + if (!anchor) return; + this.covered = { count, anchor, prefix }; + } + + snapshot(): CompactionSnapshot | undefined { + const c = this.covered; + if (!c) return undefined; + return { count: c.count, anchorTimestamp: c.anchor.timestamp, anchorRole: c.anchor.role, prefix: c.prefix }; + } + + /** Re-adopt a saved snapshot when `messages` still has the covered head it describes. */ + restore(snapshot: CompactionSnapshot | undefined, messages: AgentMessage[]): void { + if (!snapshot || snapshot.count <= 0 || !Array.isArray(snapshot.prefix)) return; + const anchor = messages[snapshot.count - 1]; + if (!anchor || anchor.timestamp !== snapshot.anchorTimestamp || anchor.role !== snapshot.anchorRole) return; + this.covered = { count: snapshot.count, anchor, prefix: snapshot.prefix }; + } +} diff --git a/src/compaction/tokens.test.ts b/src/compaction/tokens.test.ts index de1ba96..04847be 100644 --- a/src/compaction/tokens.test.ts +++ b/src/compaction/tokens.test.ts @@ -21,54 +21,60 @@ describe("estimateMessageTokens", () => { expect(tokens).toBeLessThanOrEqual(15); }); - it("trusts provider-reported totalTokens over the heuristic when present", () => { - const message: AgentMessage = { - role: "assistant", - content: [{ type: "text", text: "x" }], - api: "x", - provider: "x", - model: "x", - usage: { ...ZERO_USAGE, totalTokens: 12345 }, - stopReason: "stop", - timestamp: 0, - }; - expect(estimateMessageTokens(message)).toBe(12345); + it("ignores usage — it measures the whole request, not the message", () => { + expect(estimateMessageTokens(assistant("x", 12345))).toBeLessThan(15); + }); +}); + +describe("estimateTotalTokens", () => { + it("anchors on the newest assistant usage and adds only later messages", () => { + const messages: AgentMessage[] = [ + { role: "user", content: "a".repeat(38), timestamp: 0 }, + assistant("x", 100), + { role: "user", content: "a".repeat(38), timestamp: 0 }, + ]; + expect(estimateTotalTokens(messages)).toBe(100 + estimateMessageTokens(messages[2])); + }); + + it("does not add up usage across assistant turns", () => { + const messages: AgentMessage[] = [ + { role: "user", content: "hi", timestamp: 0 }, + assistant("a", 14_000), + { role: "user", content: "again", timestamp: 0 }, + assistant("b", 14_100), + ]; + expect(estimateTotalTokens(messages)).toBe(14_100); }); - it("falls through to the heuristic when usage.totalTokens is 0", () => { - const message: AgentMessage = { - role: "assistant", - content: [{ type: "text", text: "hello world" }], - api: "x", - provider: "x", - model: "x", - usage: ZERO_USAGE, - stopReason: "stop", - timestamp: 0, - }; - // Heuristic: 11 chars / 3.8 = ~3 + 4 overhead = 7 - expect(estimateMessageTokens(message)).toBeGreaterThan(0); - expect(estimateMessageTokens(message)).toBeLessThan(15); + it("never drops below the chars-based sum (resumed, uncompacted history)", () => { + const messages: AgentMessage[] = [{ role: "user", content: "a".repeat(3800), timestamp: 0 }, assistant("x", 50)]; + expect(estimateTotalTokens(messages)).toBeGreaterThan(1000); }); - it("estimateTotalTokens sums across messages", () => { + it("falls back to the chars-based sum without usage", () => { const messages: AgentMessage[] = [ { role: "user", content: "a".repeat(38), timestamp: 0 }, - { - role: "assistant", - content: [{ type: "text", text: "x" }], - api: "x", - provider: "x", - model: "x", - usage: { ...ZERO_USAGE, totalTokens: 100 }, - stopReason: "stop", - timestamp: 0, - }, + assistant("hello world", 0), ]; - expect(estimateTotalTokens(messages)).toBeGreaterThanOrEqual(110); + expect(estimateTotalTokens(messages)).toBe( + estimateMessageTokens(messages[0]) + estimateMessageTokens(messages[1]), + ); }); }); +function assistant(text: string, totalTokens: number): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "x", + provider: "x", + model: "x", + usage: { ...ZERO_USAGE, totalTokens }, + stopReason: "stop", + timestamp: 0, + }; +} + describe("contextWindow", () => { it("returns 200K for Claude families", () => { expect(contextWindow("claude-sonnet-4-6")).toBe(200_000); diff --git a/src/compaction/tokens.ts b/src/compaction/tokens.ts index b6c10ef..51ad5f8 100644 --- a/src/compaction/tokens.ts +++ b/src/compaction/tokens.ts @@ -5,31 +5,42 @@ const CHARS_PER_TOKEN = 3.8; const PER_MESSAGE_OVERHEAD = 4; /** - * Rough token estimate from message length. Used to decide whether to - * compact. We deliberately don't reach for a real tokenizer here: + * Rough token estimate from message length. We deliberately don't reach + * for a real tokenizer here: * - Real tokenizers are slow on every turn. * - The agent doesn't need exact counts to make a binary trigger * decision; the 75% threshold has plenty of slack. * - * Assistant messages emitted by pi-agent-core carry a `usage` block - * with the provider-reported `totalTokens`. When that's present we - * trust it over the chars/3.8 heuristic — this gives accurate - * accounting on assistant turns and keeps the heuristic only for the - * user/tool messages we have no real token count for. + * Never uses an assistant message's `usage`: that is the size of the + * whole request which produced it, not of the message itself. */ export function estimateMessageTokens(message: AgentMessage): number { - const usage = (message as { usage?: Usage }).usage; - if (usage && typeof usage.totalTokens === "number" && usage.totalTokens > 0) { - return usage.totalTokens; - } const body = serialize(message); return Math.ceil(body.length / CHARS_PER_TOKEN) + PER_MESSAGE_OVERHEAD; } +/** + * Context size of a transcript. The newest assistant message's + * provider-reported `totalTokens` covers everything sent before it + * (system prompt and tools included), so it anchors the estimate and only + * later messages are approximated. Summing every assistant's `usage` + * instead counted the context once per turn, so long sessions looked full + * and compacted on every request. + * + * Never below the plain chars-based sum: a resumed history whose last + * `usage` came from a smaller, compacted request must still compact. + */ export function estimateTotalTokens(messages: AgentMessage[]): number { - let total = 0; - for (const m of messages) total += estimateMessageTokens(m); - return total; + let heuristic = 0; + for (const m of messages) heuristic += estimateMessageTokens(m); + for (let i = messages.length - 1; i >= 0; i--) { + const usage = (messages[i] as { usage?: Usage }).usage; + if (messages[i].role !== "assistant" || !usage || !(usage.totalTokens > 0)) continue; + let after = 0; + for (const m of messages.slice(i + 1)) after += estimateMessageTokens(m); + return Math.max(heuristic, usage.totalTokens + after); + } + return heuristic; } /** diff --git a/src/compaction/types.ts b/src/compaction/types.ts index e28836b..ada4a41 100644 --- a/src/compaction/types.ts +++ b/src/compaction/types.ts @@ -11,6 +11,8 @@ export interface CompactionDetails { truncatedTokens: number; /** How many messages were collapsed into the summary. */ collapsedMessageCount: number; + /** True when summarizing threw or was aborted; `summary` is then a placeholder, not real context. */ + summaryFailed: boolean; } export interface CompactionResult { diff --git a/src/headless/run.test.ts b/src/headless/run.test.ts index 73ec8f1..199cddf 100644 --- a/src/headless/run.test.ts +++ b/src/headless/run.test.ts @@ -445,7 +445,8 @@ describe("runHeadless", () => { stopReason: "toolUse", }), fauxAssistantMessage("Done."), - fauxAssistantMessage("Reliable receipt repaired. Verified with npm test."), + // The repair turn answers without naming the verification command. + fauxAssistantMessage("Reliable receipt repaired."), ]); const { capture, write } = makeCapture(); const exitCode = await runHeadless({ diff --git a/src/sessions/store.ts b/src/sessions/store.ts index d516738..3ba95ed 100644 --- a/src/sessions/store.ts +++ b/src/sessions/store.ts @@ -13,6 +13,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; +import type { CompactionSnapshot } from "../compaction/memo.js"; export const SESSION_MAX_AGE_DAYS = 30; export const SESSION_FORMAT_VERSION = 1; @@ -26,6 +27,8 @@ export interface SessionData { messages: AgentMessage[]; usage: Usage; updatedAt: number; + /** Latest compaction summary over the head of `messages`, reused on resume. */ + compaction?: CompactionSnapshot; } export interface SessionSummary { diff --git a/src/tools/read-file.ts b/src/tools/read-file.ts index 9205c66..47a06db 100644 --- a/src/tools/read-file.ts +++ b/src/tools/read-file.ts @@ -32,6 +32,9 @@ export interface ReadFileDetails { bytes: number; totalLines: number; returnedLines: number; + /** 1-based inclusive range returned; 0/0 for images and empty reads. */ + startLine: number; + endLine: number; hasBOM: boolean; eol: "\n" | "\r\n" | ""; isPartialView: boolean; @@ -99,6 +102,8 @@ export function createReadFile(ctx: ToolContext): AgentTool 0 ? startIdx + 1 : 0, + endLine: slice.length > 0 ? startIdx + slice.length : 0, hasBOM, eol, isPartialView, diff --git a/src/ui-pi/app.ts b/src/ui-pi/app.ts index df072fa..8a9d5eb 100644 --- a/src/ui-pi/app.ts +++ b/src/ui-pi/app.ts @@ -4,6 +4,7 @@ import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core"; import { type AutocompleteItem, CombinedAutocompleteProvider, + type Component, Container, Editor, type OverlayHandle, @@ -27,10 +28,11 @@ import { type TournamentProgress, } from "../agent/tournament.js"; import { createContestantRunner, defaultContestantPrompt } from "../agent/tournament-runner.js"; -import { visibleMessages } from "../agent/visible-messages.js"; +import { recentMessages, visibleMessages } from "../agent/visible-messages.js"; import { snapshotWorkingTree } from "../agent/wip-snapshot.js"; import { copyToClipboard } from "../clipboard/copy.js"; import { BUILTIN_COMMANDS } from "../commands/builtins/index.js"; +import { fetchBalance, formatUsageBalance } from "../commands/builtins/usage.js"; import { buildMcpPromptCommands } from "../commands/mcp-prompt-commands.js"; import { CommandRegistry } from "../commands/registry.js"; import { buildSkillCommands } from "../commands/skill-commands.js"; @@ -54,10 +56,18 @@ import { CompactionBanner } from "./compaction-banner.js"; import { CopyPickerOverlay } from "./copy-picker-overlay.js"; import { CopyRegistry } from "./copy-targets.js"; import { HistorySearchOverlay } from "./history-search-overlay.js"; -import { buildMessageBlocks, type CopyBoxOptions, type MessageKind, MessageView } from "./message-view.js"; +import { + attachesToPrevious, + buildMessageBlocks, + type CopyBoxOptions, + type MessageKind, + MessageView, + seedToolExecutions, +} from "./message-view.js"; import { type ModelOption, ModelPickerOverlay } from "./model-picker-overlay.js"; import { PermissionOverlay } from "./permission-overlay.js"; import { RewindOverlay } from "./rewind-overlay.js"; +import { fullscreenEnabled, ScrollViewport } from "./scroll-viewport.js"; import { SuggestionLine } from "./suggestion-line.js"; import { TaskPanel } from "./task-panel.js"; import { ansi, editorTheme } from "./theme.js"; @@ -76,6 +86,18 @@ import { WelcomeBanner } from "./welcome.js"; export class App extends Container { private bundle: AgentBundle; private readonly transcript: TranscriptView; + private readonly welcome: WelcomeBanner; + private readonly fullscreen = fullscreenEnabled(); + private readonly viewport = new ScrollViewport({ + onCopy: (text) => { + copyToClipboard(text).catch((err: unknown) => { + this.statusBar.note(`copy failed: ${err instanceof Error ? err.message : String(err)}`); + this.tui?.requestRender(); + }); + }, + onChange: () => this.tui?.requestRender(), + }); + private accountUsageFetchedAt = 0; private readonly statusBar: StatusBar; private inputBar: Editor | undefined; private readonly bgShellPanel: BackgroundShellPanel; @@ -150,6 +172,7 @@ export class App extends Container { if (resumedMessages.length > 0) this.envInjected = true; this.messages.push(...resumedMessages); + seedToolExecutions(this.tools, resumedMessages); this.transcript = new TranscriptView(resumedMessages, this.tools, this.copyRegistry); // Async store subscribers need to schedule a TUI render after every // state change — pi-tui only paints on input events or explicit @@ -172,14 +195,13 @@ export class App extends Container { this.registry = new CommandRegistry(); this.registry.registerAll(BUILTIN_COMMANDS); - this.addChild( - new WelcomeBanner({ - modelName: this.bundle.model.name, - source: this.bundle.source, - cwd: this.bundle.toolContext.cwd, - resumedFrom: this.bundle.resumedFrom, - }), - ); + this.welcome = new WelcomeBanner({ + modelName: this.bundle.model.name, + source: this.bundle.source, + cwd: this.bundle.toolContext.cwd, + resumedFrom: this.bundle.resumedFrom, + }); + this.addChild(this.welcome); this.addChild(this.transcript); this.addChild(this.compactionBanner); this.addChild(this.taskPanel); @@ -236,6 +258,7 @@ export class App extends Container { this.inputBar = editor; this.addChild(editor); tui.setFocus(editor); + this.refreshAccountUsage(true); this.removeInputListener = tui.addInputListener((data) => this.handleGlobalInput(data)); this.setTitle(false); // Permission + UserQuery requests arrive asynchronously from tool @@ -484,7 +507,34 @@ export class App extends Container { } } + /** Re-fetch account credit usage for the status bar; throttled unless forced. */ + private refreshAccountUsage(force = false): void { + const now = Date.now(); + if (!force && now - this.accountUsageFetchedAt < ACCOUNT_USAGE_MIN_INTERVAL_MS) return; + this.accountUsageFetchedAt = now; + void fetchBalance().then((result) => { + if (!result.ok) { + this.statusBar.setAccountUsage(undefined); + } else { + const { pct, remaining } = formatUsageBalance(result.balance); + this.statusBar.setAccountUsage({ pct, remaining }); + } + this.tui?.requestRender(); + }); + } + + override render(width: number): string[] { + if (!this.fullscreen || !this.tui) return super.render(width); + // Everything after the welcome banner + transcript is the pinned footer. + const footer = this.children.filter((c) => c !== this.welcome && c !== this.transcript); + return this.viewport.render(width, this.tui.terminal.rows, [this.welcome, ...this.transcript.blocks()], footer); + } + private handleGlobalInput(data: string): { consume?: boolean } | undefined { + if (this.fullscreen && this.viewport.handleInput(data, !this.tui?.hasOverlay())) { + this.tui?.requestRender(); + return { consume: true }; + } // Ctrl-O opens the copy picker when no other modal owns input. if ( data === "\x0f" && @@ -655,8 +705,7 @@ export class App extends Container { this.messages.length = 0; this.messages.push(...truncated); this.bundle.agent.state.messages = [...truncated]; - this.transcript.clear(); - for (const m of truncated) this.transcript.appendMessage(m); + this.transcript.load(truncated); // Restore files mutated at or after the rewound prompt. let fileNote = ""; @@ -873,6 +922,7 @@ export class App extends Container { } private async handleSubmit(text: string): Promise { + this.viewport.scrollToBottom(); // Both call sites fire this as a floating promise (`void`). Any // throw in the early branches — slash dispatch, @path attachment // resolution, !shell escape — would otherwise reject silently and @@ -1204,11 +1254,11 @@ export class App extends Container { const next = createAgent({ cwd: this.bundle.toolContext.cwd, resume: true, sessionId }); this.adoptBundle(next); // Replace the on-screen transcript with the resumed session's. - this.transcript.clear(); this.messages.length = 0; const resumedMessages = visibleMessages(next.resumedMessages); this.messages.push(...resumedMessages); - for (const m of resumedMessages) this.transcript.appendMessage(m); + seedToolExecutions(this.tools, resumedMessages); + this.transcript.load(resumedMessages); const when = next.resumedFrom ? new Date(next.resumedFrom.updatedAt).toLocaleString() : "unknown time"; this.statusBar.note(`Resumed session from ${when} (${resumedMessages.length} messages).`); this.tui?.requestRender(); @@ -1480,6 +1530,7 @@ export class App extends Container { break; case "agent_end": this.busy = false; + this.refreshAccountUsage(); this.scheduleSuggestion(); this.status = "idle"; this.statusBar.setStatus("idle"); @@ -1582,6 +1633,7 @@ class StatusBar extends Container { private ctxPercent = 0; private cost = 0; private billingText: string | undefined; + private accountUsage: { pct: number | null; remaining: number } | undefined; private tokRate: number | undefined; private throbberTick = 0; /** Active cycling verb shown while currentStatus is "thinking". */ @@ -1645,6 +1697,12 @@ class StatusBar extends Container { this.line.setText(this.format()); this.line.invalidate(); } + /** Account-wide credit usage from the Codebase balance API; undefined hides it. */ + setAccountUsage(usage: { pct: number | null; remaining: number } | undefined): void { + this.accountUsage = usage; + this.line.setText(this.format()); + this.line.invalidate(); + } /** Advance the throbber to the next frame. Called by App on the spinner timer while busy. */ tickThrobber(): void { this.throbberTick = (this.throbberTick + 1) % THROBBER_FRAMES.length; @@ -1677,38 +1735,44 @@ class StatusBar extends Container { const throb = isBusy ? `${ansi.cyan(THROBBER_FRAMES[this.throbberTick])} ` : ""; const label = this.currentStatus === "thinking" ? this.verb : this.currentStatus; const statusLabel = isBusy ? ansi.cyan(label) : ansi.dim(label); - const bar = ctxBar(this.ctxPercent); - const ctxText = colorByThreshold(`${bar} ${this.ctxPercent}%`, this.ctxPercent); const tokPart = this.tokRate !== undefined ? ` · ${this.tokRate} tok/s` : ""; const costPart = this.billingText ? ` · ${this.billingText}` : this.showCost ? ` · $${formatCost(this.cost)}` : ""; - // Model name reads at default brightness as the anchor; the rest of - // the meta recedes to dim so the line is glanceable, not a wall. - const meta = ansi.dim(`${this.cwdLabel} · ctx ${ctxText}${tokPart}${costPart}`); + // Model name reads at default brightness as the anchor; labels and the + // rest of the meta recede to dim, the two meters keep their own colors. + const sep = ansi.dim(" · "); + const parts = [ansi.dim(this.cwdLabel), meter("Context", this.ctxPercent, ansi.green)]; + const usage = this.accountUsage; + if (usage) { + parts.push( + usage.pct === null + ? ansi.dim(`${usage.remaining.toLocaleString()} credits left`) + : meter("Usage", usage.pct, ansi.blue), + ); + } + const meta = parts.join(sep) + ansi.dim(`${tokPart}${costPart}`); return `${throb}${statusLabel} ${this.modelName} ${meta}`; } } -/** 6-cell eighth-block meter used for the ctx % bar. Mirrors the ink-era version exactly. */ -function ctxBar(pct: number): string { - const cells = 6; - const totalEighths = Math.round((pct / 100) * cells * 8); - const full = Math.floor(totalEighths / 8); - const remainder = totalEighths - full * 8; - const partials = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; - let out = "█".repeat(Math.min(full, cells)); - if (full < cells && remainder > 0) out += partials[remainder] ?? ""; - while (out.length < cells) out += "░"; - return out; +/** + * 10-cell meter for the status bar. Whole blocks only: partial eighth + * blocks are left-aligned in their cell and read as a gap before the + * empty cells. Any non-zero value shows at least one filled cell. + */ +function meterBar(pct: number): string { + const cells = 10; + const filled = pct <= 0 ? 0 : Math.min(cells, Math.max(1, Math.round((pct / 100) * cells))); + return "█".repeat(filled) + "░".repeat(cells - filled); } -function colorByThreshold(text: string, pct: number): string { - if (pct >= 90) return ansi.red(text); - if (pct >= 75) return ansi.yellow(text); - return text; +/** "Label ▊░░░░░ 12%": dim label, bar + percent in `color`, turning yellow at 75% and red at 90%. */ +function meter(label: string, pct: number, color: (text: string) => string): string { + const paint = pct >= 90 ? ansi.red : pct >= 75 ? ansi.yellow : color; + return `${ansi.dim(label)} ${paint(`${meterBar(pct)} ${pct}%`)}`; } function formatCost(value: number): string { @@ -1724,6 +1788,12 @@ function formatCost(value: number): string { * rebuilt per agent event so tool-call blocks stay in sync with the * shared tools Map. */ +/** Balance fetches after turns are spaced at least this far apart. */ +const ACCOUNT_USAGE_MIN_INTERVAL_MS = 30_000; + +/** How many resumed messages the transcript renders; the agent still gets the full history. */ +const RESUME_DISPLAY_LIMIT = 100; + class TranscriptView extends Container { private readonly history: Container; private streamingView: MessageView | undefined; @@ -1739,9 +1809,31 @@ class TranscriptView extends Container { this.registry = registry; this.history = new Container(); this.addChild(this.history); - for (const m of initialMessages) { - this.history.addChild(buildMessageView(m, this.tools, false, this.copyOpts())); + this.load(initialMessages); + } + + /** Show a resumed conversation: the last RESUME_DISPLAY_LIMIT messages, with a note for the rest. */ + load(messages: AgentMessage[]): void { + this.history.clear(); + const shown = recentMessages(messages, RESUME_DISPLAY_LIMIT); + const hidden = messages.length - shown.length; + if (hidden > 0) { + this.history.addChild( + new Text(ansi.dim(`↑ ${hidden} earlier message${hidden === 1 ? "" : "s"} not shown`), 2, 0), + ); + } + for (const m of shown) this.add(m); + this.history.invalidate(); + } + + /** Add a finalized message; one-line results (read summaries) join the previous message with no gap. */ + private add(message: AgentMessage): void { + const prev = this.history.children[this.history.children.length - 1]; + if (attachesToPrevious(message) && prev instanceof MessageView) { + prev.appendBlocks(buildMessageBlocks(message, this.tools, message.role, this.copyOpts())); + return; } + this.history.addChild(buildMessageView(message, this.tools, false, this.copyOpts())); } private copyOpts(): CopyBoxOptions { @@ -1755,7 +1847,7 @@ class TranscriptView extends Container { } appendMessage(message: AgentMessage): void { - this.history.addChild(buildMessageView(message, this.tools, false, this.copyOpts())); + this.add(message); this.history.invalidate(); } @@ -1764,6 +1856,11 @@ class TranscriptView extends Container { this.history.invalidate(); } + /** Finalized messages then the in-flight one, oldest first — the scroll viewport renders these individually. */ + blocks(): Component[] { + return this.streamingView ? [...this.history.children, this.streamingView] : this.history.children; + } + setStreaming(message: AgentMessage | undefined): void { if (message && this.streamingView && this.streamingMessage?.role === message.role) { // Same streaming turn — rebuild content blocks in place so the diff --git a/src/ui-pi/copy-box.ts b/src/ui-pi/copy-box.ts index 289e79b..49298db 100644 --- a/src/ui-pi/copy-box.ts +++ b/src/ui-pi/copy-box.ts @@ -1,5 +1,6 @@ -import { type Component, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { CopyRegistry } from "./copy-targets.js"; +import { highlightCode } from "./syntax.js"; import { ansi } from "./theme.js"; /** @@ -12,6 +13,8 @@ import { ansi } from "./theme.js"; * text is always the full registered `content`, never what's painted. */ export class CopyBox implements Component { + private highlighted: string[] | undefined; + constructor( private readonly registry: CopyRegistry, /** Stable dedupe key so the entry survives re-renders (e.g. "msg-3:block-1"). */ @@ -32,22 +35,17 @@ export class CopyBox implements Component { ansi.dim("╭─ ") + ansi.bold(this.label) + ansi.dim(` ${"─".repeat(fillLen)} `) + hint + ansi.dim(" ╮"); const lines: string[] = [top]; - const inner = Math.max(10, w - 2); - const bar = ansi.dim("│ "); - for (const raw of this.content.split("\n")) { - const shown = visibleWidth(raw) > inner ? `${sliceToWidth(raw, inner - 1)}…` : raw; - lines.push(`${bar}${shown}`); + const inner = Math.max(10, w - 4); + const bar = ansi.dim("│"); + this.highlighted ??= highlightCode(this.content.replace(/\t/g, " "), this.label); + for (const raw of this.highlighted) { + const shown = truncateToWidth(raw, inner, "…"); + const pad = " ".repeat(Math.max(0, inner - visibleWidth(shown))); + lines.push(`${bar} ${shown}\x1b[0m${pad} ${bar}`); } - lines.push(ansi.dim(`╰${"─".repeat(Math.max(0, w - 1))}╯`)); + lines.push(ansi.dim(`╰${"─".repeat(Math.max(0, w - 2))}╯`)); return lines; } invalidate(): void {} } - -/** Truncate a plain string to a visible width (no ANSI in code-box content). */ -function sliceToWidth(text: string, maxWidth: number): string { - if (visibleWidth(text) <= maxWidth) return text; - const rows = wrapTextWithAnsi(text, maxWidth); - return rows[0] ?? text.slice(0, maxWidth); -} diff --git a/src/ui-pi/copy-picker-overlay.ts b/src/ui-pi/copy-picker-overlay.ts index a776a98..28fbf81 100644 --- a/src/ui-pi/copy-picker-overlay.ts +++ b/src/ui-pi/copy-picker-overlay.ts @@ -1,5 +1,6 @@ import { type Component, Container, SelectList, Text } from "@earendil-works/pi-tui"; import type { CopyEntry } from "./copy-targets.js"; +import { frame, frameInnerWidth } from "./overlay-frame.js"; import { ansi, selectListTheme } from "./theme.js"; /** Most recent boxes shown in the picker; older ones drop off the list. */ @@ -17,7 +18,7 @@ export class CopyPickerOverlay extends Container { constructor(entries: readonly CopyEntry[], onPick: (entry: CopyEntry) => void, onCancel: () => void) { super(); const recent = [...entries].reverse().slice(0, MAX_SHOWN); - this.addChild(new Text(ansi.bold(ansi.cyan("Copy which?")), 1, 0)); + this.addChild(new Text(ansi.bold(ansi.cyan("Copy which?")), 0, 0)); const items = recent.map((e) => ({ value: String(e.id), @@ -32,12 +33,16 @@ export class CopyPickerOverlay extends Container { }; this.list.onCancel = () => onCancel(); this.addChild(this.list); - this.addChild(new Text(ansi.dim("↑↓ Enter to copy · Esc to cancel"), 1, 1)); + this.addChild(new Text(ansi.dim("↑↓ Enter to copy · Esc to cancel"), 0, 1)); } getFocusTarget(): Component { return this.list; } + + override render(width: number): string[] { + return frame(super.render(frameInnerWidth(width)), width); + } } /** One-line preview of a box's content for the picker row. */ diff --git a/src/ui-pi/edit-diff-view.test.ts b/src/ui-pi/edit-diff-view.test.ts new file mode 100644 index 0000000..c917427 --- /dev/null +++ b/src/ui-pi/edit-diff-view.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { renderFileEdit } from "./edit-diff-view.js"; + +const strip = (s: string) => + s + .replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "") + .replace(/\x1b\[[0-9;]*m/g, "") + .trimEnd(); + +describe("renderFileEdit", () => { + it("numbers written lines and clips long files", () => { + const out = renderFileEdit("w1", "write_file", { path: "a.txt", content: "x\n".repeat(14) }, 80)?.map(strip); + expect(out?.[0]).toBe("● Write(a.txt)"); + expect(out?.[1]).toBe(" ⎿ Wrote 14 lines"); + expect(out?.[2]).toBe(" 1 + x"); + expect(out?.at(-1)).toBe(" … +4 lines"); + }); + + it("shows edits at their file line numbers with surrounding context", () => { + // A long path wraps the `Update()` header (as macOS temp paths do), + // so the body is located by its summary row, not by a fixed header height. + const dir = join(mkdtempSync(join(tmpdir(), "edit-diff-")), "a-long-directory-name-that-makes-the-header-wrap"); + mkdirSync(dir); + const file = join(dir, "f.txt"); + writeFileSync(file, "one\ntwo\nTHREE\nfour\nfive\n"); + const out = + renderFileEdit("e1", "edit_file", { path: file, old_string: "three", new_string: "THREE" }, 80)?.map(strip) ?? + []; + const summary = out.findIndex((line) => line.trimStart().startsWith("⎿")); + expect(summary).toBeGreaterThan(1); + expect(out.slice(summary)).toEqual([ + " ⎿ Added 1 line, removed 1 line", + " 1 one", + " 2 two", + " 3 - three", + " 3 + THREE", + " 4 four", + " 5 five", + ]); + }); + + it("falls back to an unnumbered diff when the file can't be read", () => { + const out = renderFileEdit( + "e2", + "edit_file", + { path: "/nonexistent/x", old_string: "a", new_string: "b" }, + 80, + )?.map(strip); + expect(out?.slice(2)).toEqual([" - a", " + b"]); + }); + + it("colors an edit by the whole file, so CSS inside \n\n"); + const args = { path: file, old_string: "color: blue;", new_string: "color: red;" }; + const added = renderFileEdit("e3", "edit_file", args, 80)?.find((l) => strip(l).includes("3 +")); + // "color" is a CSS property: it only gets a color when the + + +

HTML Test Page

+

This is a minimal page for manual testing in testing/.

+ + +
+ + + +

+ + + +