diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index aaa7121a..26556235 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -755,6 +755,41 @@ describe("createAgentRuntime", () => { } }); + it("persists the turn's context usage onto the stored session", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: backend, + skipLlamaHealthCheck: true, + llamaComplete: async () => ({ + content: JSON.stringify({ + tool: "reply", + args: { text: "hi back" }, + }), + timing: { promptTokens: 777, predictedTokens: 3 }, + slotId: 0, + cacheReused: false, + }), + }, + }); + try { + const session = runtime.createSession(); + const result = await runtime.runTurn(session, "hello", { maxSteps: 5 }); + // `prompt_built` seeded the snapshot; `llm_completed`'s tokenizer + // count (777) replaced the estimate before the stamp. + expect(result.session.contextUsage).toBeDefined(); + expect(result.session.contextUsage?.tokens).toBe(777); + expect(result.session.contextUsage?.sections.length).toBeGreaterThan(0); + // The stored row carries the same snapshot, so a later process — + // the TUI reopening this session — can restore the gauge. + const reloaded = runtime.sessionStore.load(session.id)!; + expect(reloaded.contextUsage).toEqual(result.session.contextUsage); + } finally { + await runtime.shutdown(); + } + }); + it("refreshSkills rebuilds the catalog and notifies listeners", async () => { let notified: Array<{ name: string }> = []; const runtime = await createAgentRuntime({ diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 143bcc05..5a36bf68 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -157,6 +157,8 @@ import type { AgentLoopEvent, RunTurnResult } from "../agent/agent-loop.js"; import { SessionStore, createEmptySessionState, + contextUsageFromPrompt, + type ContextUsageState, type SessionState, } from "../session/index.js"; @@ -827,6 +829,15 @@ export async function createAgentRuntime( * pointer. */ const turnContext = new AsyncLocalStorage<{ sessionId: string }>(); + /** + * The running turn's window occupancy, per session. Written by + * `emitAgentLoopEvent` (`prompt_built`, refined by `llm_completed`), + * consumed once by `executeTurn` when it stamps the finished session, + * and always cleared in its `finally` so an aborted turn cannot leak + * an entry — or bleed one turn's gauge into a session that never + * built a prompt of its own. + */ + const lastTurnContextUsage = new Map(); const steeringInbox = new SteeringInbox(); const turnController = new TurnController({ onHookError: (err, ctxInfo) => { @@ -852,6 +863,28 @@ export async function createAgentRuntime( const recorder = touchRecorder(ctx.sessionId); recorder?.onAgentEvent(event); turnController.emit(ctx.sessionId, event); + // Track the turn's window occupancy so `executeTurn` can stamp it + // onto the session before the post-turn save. Mirrors the TUI's own + // reduction: the `prompt_built` estimate, refined by the provider's + // real tokenizer count when the completion reports one. + if (event.type === "llm_event") { + const step = event.event; + if (step.type === "prompt_built") { + lastTurnContextUsage.set( + ctx.sessionId, + contextUsageFromPrompt(step.prompt), + ); + } else if (step.type === "llm_completed") { + const counted = step.completion.timing?.promptTokens ?? 0; + const usage = lastTurnContextUsage.get(ctx.sessionId); + if (counted > 0 && usage) { + lastTurnContextUsage.set(ctx.sessionId, { + ...usage, + tokens: counted, + }); + } + } + } } if (event.type === "loop_failed") { captureError(errorReporter, event.error, { @@ -2310,9 +2343,19 @@ export async function createAgentRuntime( maxSteps: runOptions.maxSteps ?? config.agent.maxSteps, signal: runOptions.signal ?? new AbortController().signal, }); - sessionStore.save(result.session); - return result; + // Stamp the turn's window occupancy so the stored session can + // restore the TUI's context gauge when it is reopened. A turn + // that built no prompt (failed before step 1) leaves whatever + // snapshot the previous turn persisted. + const usage = lastTurnContextUsage.get(session.id); + const finished = + usage === undefined + ? result.session + : { ...result.session, contextUsage: usage }; + sessionStore.save(finished); + return usage === undefined ? result : { ...result, session: finished }; } finally { + lastTurnContextUsage.delete(session.id); activeTraceSessions.delete(session.id); // A delete that arrived mid-turn was deferred to keep the pin honest; // complete it now that nothing is writing through the recorder. diff --git a/src/session/context-usage.ts b/src/session/context-usage.ts new file mode 100644 index 00000000..15c21eb7 --- /dev/null +++ b/src/session/context-usage.ts @@ -0,0 +1,154 @@ +import type { BuiltPrompt } from "../prompt/build-prompt-types.js"; + +/** + * What the last built prompt actually put in the model's context window. + * + * Lives in the session module (not the TUI) because the figure is a + * property of the *session*, not of the screen that happens to render + * it: `executeTurn` stamps the latest snapshot onto `SessionState` so a + * session reopened tomorrow — or switched to from another thread — + * shows its window fill immediately instead of a blank chip until the + * next turn rebuilds a prompt. + * + * Every field is a snapshot of the most recent `prompt_built`, refined by + * the completion's own token count when the provider reports one. + */ +export interface ContextUsageState { + /** + * Tokens in the last prompt. An estimate at `prompt_built` time + * (`estimateTokens` over-counts by design), replaced by the real + * tokenizer count once the step completes and the provider reports + * `promptTokens`. + */ + tokens: number | null; + /** + * Physical window the prompt was built against, when the runtime knows + * it. `null` on cloud providers, where the model profile carries no + * window — the chip resolves those from the model catalogue instead. + */ + contextWindow: number | null; + /** Turns `packConversation` dropped to make the transcript fit. */ + droppedTurns: number; + /** Tokens the `### conversation` section actually rendered to. */ + conversationTokens: number; + /** + * Ceiling that section is packed to — `conversationCapEffective`. The + * one number that says when older turns start being dropped, and the + * only budget figure that is defined even when nobody knows the + * physical window (the clamp falls back to the configured cap). + */ + conversationCap: number | null; + /** + * The cap as configured (`agent.conversationMaxTokens`), before the + * window clamp. Equal to `conversationCap` when config is what binds; + * larger when the window is. That comparison is the only way to tell + * an operator which knob actually moves their limit. + */ + conversationCapConfigured: number | null; + /** + * The configured cap is `0` — auto. `conversationCapConfigured` is + * then a fallback rather than a ceiling, so the comparison above says + * nothing and the panel must not name `agent.conversationMaxTokens` + * as what is holding the transcript down. Nothing is: the window is. + */ + conversationCapAuto: boolean; + /** Macro-turns the prompt carried. */ + conversationPairs: number; + /** Macro-turns dropped whole. */ + droppedPairs: number; + /** `agent.conversationMaxPairs` in force. */ + conversationPairsCap: number; + /** Which limit trimmed history, when either did. */ + conversationBoundBy: "pairs" | "tokens" | null; + /** + * Token cost of each macro-turn, oldest first — enough to price a + * different pair count without building another prompt, so moving the + * dial redraws the gauge while the operator is looking at it. + */ + pairCosts: readonly number[]; + /** Per-section breakdown, for the detail view. Empty before the first prompt. */ + sections: readonly ContextUsageSection[]; +} + +export interface ContextUsageSection { + label: string; + tokens: number; +} + +/** A window nothing has been built against yet. */ +export const EMPTY_CONTEXT_USAGE: ContextUsageState = { + tokens: null, + contextWindow: null, + droppedTurns: 0, + conversationTokens: 0, + conversationCap: null, + conversationCapConfigured: null, + conversationCapAuto: false, + conversationPairs: 0, + droppedPairs: 0, + conversationPairsCap: 0, + conversationBoundBy: null, + pairCosts: [], + sections: [], +}; + +/** + * The transcript's row label. Exported because the context panel has to + * find that one row to recalculate it when the task count changes, and + * matching on a literal string in two files is a bug waiting for someone + * to reword one of them. + */ +export const CONVERSATION_SECTION_LABEL = "conversation"; + +/** + * Order the sections are shown in: the fixed cost first, then the + * transcript, then everything the memory fabric contributed, then the + * small stuff. Not the order `BuiltPrompt.tokens` declares them in — + * that one follows the prompt's own assembly, which is not how anyone + * reads a bill. + */ +const SECTIONS: readonly { + key: keyof BuiltPrompt["tokens"]; + label: string; +}[] = [ + { key: "stablePrefix", label: "prompt scaffold" }, + { key: "conversation", label: CONVERSATION_SECTION_LABEL }, + { key: "recalled", label: "recalled memory" }, + { key: "memoryIndex", label: "memory index" }, + { key: "worldSnapshot", label: "world snapshot" }, + { key: "loadedTools", label: "loaded tools" }, + { key: "loadedSkills", label: "loaded skills" }, + { key: "sessionFacts", label: "session facts" }, + { key: "profile", label: "profile" }, + { key: "taskPolicy", label: "task policy" }, +]; + +/** + * Project a built prompt into the readout the composer shows. + * + * Sections that cost nothing are dropped rather than listed as zeros: a + * session with no skills loaded should not have to read the word + * "skills" to find that out. + */ +export function contextUsageFromPrompt(prompt: BuiltPrompt): ContextUsageState { + const sections: ContextUsageSection[] = []; + for (const { key, label } of SECTIONS) { + const tokens = prompt.tokens[key]; + if (tokens > 0) sections.push({ label, tokens }); + } + return { + tokens: prompt.tokens.total, + contextWindow: prompt.contextWindow, + droppedTurns: prompt.droppedTurns, + conversationTokens: prompt.tokens.conversation, + conversationCap: prompt.conversationCapEffective, + conversationCapConfigured: prompt.limits.conversation, + conversationCapAuto: prompt.conversationCapAuto, + conversationPairs: prompt.conversationPairs, + droppedPairs: prompt.droppedPairs, + conversationPairsCap: prompt.conversationPairsCap, + conversationBoundBy: prompt.conversationBoundBy, + pairCosts: prompt.pairCosts, + sections, + }; +} diff --git a/src/session/index.ts b/src/session/index.ts index ed288af9..1435f690 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -37,3 +37,12 @@ export type { ConversationTurn, PackedConversation, } from "./conversation-turn.js"; +export { + CONVERSATION_SECTION_LABEL, + EMPTY_CONTEXT_USAGE, + contextUsageFromPrompt, +} from "./context-usage.js"; +export type { + ContextUsageState, + ContextUsageSection, +} from "./context-usage.js"; diff --git a/src/session/session-state.ts b/src/session/session-state.ts index 83fb35c4..c2263998 100644 --- a/src/session/session-state.ts +++ b/src/session/session-state.ts @@ -8,6 +8,7 @@ import { appendTurn, type ConversationTurn, } from "./conversation-turn.js"; +import type { ContextUsageState } from "./context-usage.js"; export type SessionStatus = | "pending" @@ -124,6 +125,16 @@ export interface SessionState { createdAt: number; updatedAt: number; lastError: string | null; + /** + * Snapshot of the last turn's window occupancy — what the TUI's + * context chip draws. Stamped by `executeTurn` right before the + * post-turn save (every origin funnels through it), so reopening or + * switching into a session restores the gauge immediately instead of + * showing nothing until the next prompt is built. Deliberately NOT + * ephemeral: the whole point is to survive the process. Absent on + * sessions that predate the field or have never run a turn. + */ + contextUsage?: ContextUsageState; /** * Free-form session metadata. Reserved keys (set by the runtime, not * the agent — agents may read but must not overwrite them): diff --git a/src/session/session-store.test.ts b/src/session/session-store.test.ts index 61d5ad17..a7ed5add 100644 --- a/src/session/session-store.test.ts +++ b/src/session/session-store.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { SessionStore } from "./session-store.js"; import { createEmptySessionState } from "./session-state.js"; +import { EMPTY_CONTEXT_USAGE } from "./context-usage.js"; describe("SessionStore", () => { let tmp: string; @@ -59,6 +60,27 @@ describe("SessionStore", () => { expect(loaded?.loadedTools[0]?.name).toBe("os.git.show"); }); + it("round-trips the persisted context-usage snapshot", () => { + const initial = createEmptySessionState({ + id: "s-ctx", + workingDir: "/w", + }); + const snapshot = { + ...EMPTY_CONTEXT_USAGE, + tokens: 12_345, + contextWindow: 131_072, + conversationTokens: 9_000, + conversationPairs: 4, + sections: [{ label: "conversation", tokens: 9_000 }], + }; + store.save({ ...initial, contextUsage: snapshot }); + const loaded = store.load("s-ctx"); + expect(loaded?.contextUsage).toEqual(snapshot); + // A session written before the field existed simply has none. + store.save(createEmptySessionState({ id: "s-old", workingDir: "/w" })); + expect(store.load("s-old")?.contextUsage).toBeUndefined(); + }); + it("updates an existing session in place", () => { const state = createEmptySessionState({ id: "s2", diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 12c03a1c..4e861e6d 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -544,6 +544,10 @@ export class ChatOrchestrator { workingDir: loaded.workingDir, messages: turnsToMessages(loaded.turns), running, + // Restore the context gauge this session persisted with its last + // turn (absent on threads that never ran one — the reducer then + // resets the chip rather than keeping the old thread's figure). + ...(loaded.contextUsage ? { contextUsage: loaded.contextUsage } : {}), }); // The stored snapshot above misses everything the still-running // turn has said (a turn saves only when it finishes — for a thread @@ -1061,6 +1065,9 @@ export class ChatOrchestrator { sessionId: turnSessionId, workingDir: this.session.workingDir, messages: turnsToMessages(this.session.turns), + ...(this.session.contextUsage + ? { contextUsage: this.session.contextUsage } + : {}), }); } const next = this.queue.shift(); diff --git a/src/tui/context-usage-from-prompt.ts b/src/tui/context-usage-from-prompt.ts index 04a0f540..2a1125bd 100644 --- a/src/tui/context-usage-from-prompt.ts +++ b/src/tui/context-usage-from-prompt.ts @@ -1,80 +1,11 @@ -import type { BuiltPrompt } from "../prompt/build-prompt-types.js"; -import type { ContextUsageSection, ContextUsageState } from "./tui-state.js"; - -/** A window nothing has been built against yet. */ -export const EMPTY_CONTEXT_USAGE: ContextUsageState = { - tokens: null, - contextWindow: null, - droppedTurns: 0, - conversationTokens: 0, - conversationCap: null, - conversationCapConfigured: null, - conversationCapAuto: false, - conversationPairs: 0, - droppedPairs: 0, - conversationPairsCap: 0, - conversationBoundBy: null, - pairCosts: [], - sections: [], -}; - /** - * Order the sections are shown in: the fixed cost first, then the - * transcript, then everything the memory fabric contributed, then the - * small stuff. Not the order `BuiltPrompt.tokens` declares them in — - * that one follows the prompt's own assembly, which is not how anyone - * reads a bill. + * The projection and its constants moved to `src/session/context-usage.ts` + * so the runtime can stamp the same snapshot onto `SessionState` without + * reaching into the TUI. This re-export keeps the TUI-side import paths + * (reducers, panels, tests) stable. */ -/** - * The transcript's row label. Exported because the context panel has to - * find that one row to recalculate it when the task count changes, and - * matching on a literal string in two files is a bug waiting for someone - * to reword one of them. - */ -export const CONVERSATION_SECTION_LABEL = "conversation"; - -const SECTIONS: readonly { - key: keyof BuiltPrompt["tokens"]; - label: string; -}[] = [ - { key: "stablePrefix", label: "prompt scaffold" }, - { key: "conversation", label: CONVERSATION_SECTION_LABEL }, - { key: "recalled", label: "recalled memory" }, - { key: "memoryIndex", label: "memory index" }, - { key: "worldSnapshot", label: "world snapshot" }, - { key: "loadedTools", label: "loaded tools" }, - { key: "loadedSkills", label: "loaded skills" }, - { key: "sessionFacts", label: "session facts" }, - { key: "profile", label: "profile" }, - { key: "taskPolicy", label: "task policy" }, -]; - -/** - * Project a built prompt into the readout the composer shows. - * - * Sections that cost nothing are dropped rather than listed as zeros: a - * session with no skills loaded should not have to read the word - * "skills" to find that out. - */ -export function contextUsageFromPrompt(prompt: BuiltPrompt): ContextUsageState { - const sections: ContextUsageSection[] = []; - for (const { key, label } of SECTIONS) { - const tokens = prompt.tokens[key]; - if (tokens > 0) sections.push({ label, tokens }); - } - return { - tokens: prompt.tokens.total, - contextWindow: prompt.contextWindow, - droppedTurns: prompt.droppedTurns, - conversationTokens: prompt.tokens.conversation, - conversationCap: prompt.conversationCapEffective, - conversationCapConfigured: prompt.limits.conversation, - conversationCapAuto: prompt.conversationCapAuto, - conversationPairs: prompt.conversationPairs, - droppedPairs: prompt.droppedPairs, - conversationPairsCap: prompt.conversationPairsCap, - conversationBoundBy: prompt.conversationBoundBy, - pairCosts: prompt.pairCosts, - sections, - }; -} +export { + CONVERSATION_SECTION_LABEL, + EMPTY_CONTEXT_USAGE, + contextUsageFromPrompt, +} from "../session/context-usage.js"; diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index 67251504..cda3a968 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { reduceTuiState } from "./agent-event-reducer.js"; +import { EMPTY_CONTEXT_USAGE } from "./context-usage-from-prompt.js"; import { reduceUiAction } from "./reduce-ui-actions.js"; import { THEME_NAMES } from "./theme/theme.js"; import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; @@ -177,6 +178,44 @@ describe("reduceUiAction message_queued", () => { }); expect(next?.queuedMessages).toEqual([]); }); + + it("restores the target session's persisted context gauge on switch", () => { + const state = { + ...createInitialTuiState(SESSION), + contextUsage: { ...EMPTY_CONTEXT_USAGE, tokens: 999 }, + }; + const snapshot = { + ...EMPTY_CONTEXT_USAGE, + tokens: 4321, + conversationTokens: 2100, + conversationPairs: 2, + sections: [{ label: "conversation", tokens: 2100 }], + }; + const next = reduceUiAction(state, { + type: "session_switched", + sessionId: "s2", + workingDir: "/tmp", + messages: [], + contextUsage: snapshot, + }); + expect(next?.contextUsage).toEqual(snapshot); + }); + + it("resets the gauge when the target session carries no snapshot", () => { + // Carrying the old thread's figure over would claim the fresh + // session is exactly as full as the one just left. + const state = { + ...createInitialTuiState(SESSION), + contextUsage: { ...EMPTY_CONTEXT_USAGE, tokens: 999 }, + }; + const next = reduceUiAction(state, { + type: "session_switched", + sessionId: "s2", + workingDir: "/tmp", + messages: [], + }); + expect(next?.contextUsage).toEqual(EMPTY_CONTEXT_USAGE); + }); }); describe("reduceUiAction while_busy_mode_changed", () => { diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 0b87ff26..3eaec0a1 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -393,6 +393,11 @@ export function reduceUiAction( // this surface did not watch the turn start, so "elapsed since // re-attach" is the honest figure it can show. status: action.running ? "running" : "idle", + // The gauge belongs to the thread on screen: restore the target + // session's persisted snapshot, or reset when it has none — + // carrying the old thread's figure over would claim this one is + // exactly as full as the one just left. + contextUsage: action.contextUsage ?? EMPTY_CONTEXT_USAGE, messages: [...action.messages], reasoning: [], feed: [], diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index 9016d614..c5161bec 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -20,6 +20,7 @@ import type { UninstallAction } from "./uninstall/uninstall-actions.js"; import type { FallbackPanelAction } from "./llm-panel/fallback/fallback-panel-actions.js"; import type { WhileBusySubmitMode } from "../config/index.js"; import type { ChatMessage, SessionPickerEntry, TuiTab, TuiUiMode } from "./tui-state.js"; +import type { ContextUsageState } from "../session/context-usage.js"; /** * Every action the reducer knows how to fold into `TuiState`. All side @@ -228,6 +229,14 @@ export type TuiAction = * pretending the session is idle. Absent means idle. */ running?: boolean; + /** + * The target session's persisted window-occupancy snapshot, when + * it has one. The reducer restores the context chip from it; + * absent resets the gauge (a fresh session, or one that predates + * the persisted field) instead of leaving the previous thread's + * numbers on screen. + */ + contextUsage?: ContextUsageState; } /** Header/runtime: user saved a new llama-server base URL (e.g. via /llama). */ | { type: "llama_url_changed"; url: string } diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 4c6d3a25..662b5aae 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -4,6 +4,7 @@ import { } from "../approval/approval-level.js"; import type { CodingMode } from "./coding-mode.js"; import { EMPTY_CONTEXT_USAGE } from "./context-usage-from-prompt.js"; +import type { ContextUsageState } from "../session/context-usage.js"; import type { ComposerSwitchState } from "./composer-switch/composer-switch-state.js"; import type { ContextMenuState } from "./context-menu/context-menu-state.js"; import type { ApprovalRequest } from "../approval/approval-gate.js"; @@ -230,70 +231,15 @@ export interface RollingMetrics { * readout that answers "how full is the window right now". The window * does not empty when you press Enter. * - * Every field is a snapshot of the most recent `prompt_built`, refined by - * the completion's own token count when the provider reports one. + * The shape itself now lives in `src/session/context-usage.ts` — the + * runtime persists the same snapshot on `SessionState` so a reopened + * session can restore the gauge — and is re-exported here so TUI-side + * importers keep their path. */ -export interface ContextUsageState { - /** - * Tokens in the last prompt. An estimate at `prompt_built` time - * (`estimateTokens` over-counts by design), replaced by the real - * tokenizer count once the step completes and the provider reports - * `promptTokens`. - */ - tokens: number | null; - /** - * Physical window the prompt was built against, when the runtime knows - * it. `null` on cloud providers, where the model profile carries no - * window — the chip resolves those from the model catalogue instead. - */ - contextWindow: number | null; - /** Turns `packConversation` dropped to make the transcript fit. */ - droppedTurns: number; - /** Tokens the `### conversation` section actually rendered to. */ - conversationTokens: number; - /** - * Ceiling that section is packed to — `conversationCapEffective`. The - * one number that says when older turns start being dropped, and the - * only budget figure that is defined even when nobody knows the - * physical window (the clamp falls back to the configured cap). - */ - conversationCap: number | null; - /** - * The cap as configured (`agent.conversationMaxTokens`), before the - * window clamp. Equal to `conversationCap` when config is what binds; - * larger when the window is. That comparison is the only way to tell - * an operator which knob actually moves their limit. - */ - conversationCapConfigured: number | null; - /** - * The configured cap is `0` — auto. `conversationCapConfigured` is - * then a fallback rather than a ceiling, so the comparison above says - * nothing and the panel must not name `agent.conversationMaxTokens` - * as what is holding the transcript down. Nothing is: the window is. - */ - conversationCapAuto: boolean; - /** Macro-turns the prompt carried. */ - conversationPairs: number; - /** Macro-turns dropped whole. */ - droppedPairs: number; - /** `agent.conversationMaxPairs` in force. */ - conversationPairsCap: number; - /** Which limit trimmed history, when either did. */ - conversationBoundBy: "pairs" | "tokens" | null; - /** - * Token cost of each macro-turn, oldest first — enough to price a - * different pair count without building another prompt, so moving the - * dial redraws the gauge while the operator is looking at it. - */ - pairCosts: readonly number[]; - /** Per-section breakdown, for the detail view. Empty before the first prompt. */ - sections: readonly ContextUsageSection[]; -} - -export interface ContextUsageSection { - label: string; - tokens: number; -} +export type { + ContextUsageState, + ContextUsageSection, +} from "../session/context-usage.js"; export interface TuiSessionInfo { sessionId: string | null;