From 1cee25fdeab3c965b9ca98e760dbea8991da4e1a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:27 +0800 Subject: [PATCH] fix(tui): resolve context capacity for active model --- packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 37 ++++++++++++++++++++++++++++--- packages/ui/tui/tests/harness.ts | 8 +++++-- packages/ui/tui/tests/tui.spec.ts | 30 +++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 4cfa958e8e..18fda35648 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 34263ffadd..137ef7a264 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -737,7 +737,7 @@ class FooterComponent implements Component { private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number, + private readonly contextPercent: () => number | undefined, private readonly runningSeconds: () => number, ) {} @@ -755,7 +755,8 @@ class FooterComponent implements Component { const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` const model = displayText(this.currentModel() ?? 'model unset') const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` - const context = `${this.contextPercent()}% context` + const contextPercent = this.contextPercent() + const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context` const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` const compactRight = `${context} ${modelState}` if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { @@ -1058,6 +1059,11 @@ export function createTuiChat( let activeQuestion: PendingQuestion | undefined let modelOverlay: OverlayHandle | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } + let contextWindow: number | undefined + let contextResolution: Promise< + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() @@ -1070,7 +1076,9 @@ export function createTuiChat( () => showReasoning, () => tokens, () => target.current?.model, - () => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)), + () => contextWindow === undefined + ? undefined + : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), ) ui.addChild(header) @@ -1096,12 +1104,34 @@ export function createTuiChat( const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelContext(selected.provider, selected.model).then( + context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const), + (error: unknown) => ({ kind: 'error', error } as const), + ) + contextResolution = resolution + void resolution.then((result) => { + if (contextResolution !== resolution) return + if (result.kind === 'error') { + appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + requestRender() + }) + } + resolveContextWindow(target.current) + const selectModel = (selected: ModelChoice): void => { if (target.current?.provider === selected.provider && target.current.model === selected.model) { appendNotice(`Model is already ${targetLabel(selected)}.`) return } target.current = { provider: selected.provider, model: selected.model } + resolveContextWindow(target.current) appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) } @@ -1432,6 +1462,7 @@ export function createTuiChat( const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true + contextResolution = undefined clearStatus() modelOverlay?.hide() modelOverlay = undefined diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 7bdd8d479f..fe37afe72e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,7 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -31,6 +31,7 @@ export interface TuiHarnessOptions { providers: LlmProviderInfo[] models: LlmModelInfo[] listModels?: (provider: string) => Promise + resolveModelContext?: (provider: string, model: string) => Promise } } @@ -75,9 +76,12 @@ export async function createTuiTestHarness model.provider === provider).map(model => ({ ...model }))) }, + resolveModelContext(provider: string, model: string) { + return catalog.resolveModelContext?.(provider, model) + ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) + }, } as never) ctx.provide('tokenMeter', { - contextWindow: options.contextWindow ?? 128_000, measure() { return { totalTokens: options.contextTokens ?? 0 } }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e331c74b17..1a2e70049e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -115,7 +115,6 @@ async function dispose(setupResult: Awaited>): Promise< function provideTokenMeter(ctx: Context): void { ctx.provide('tokenMeter', { - contextWindow: 128_000, measure() { return { totalTokens: 0 } }, @@ -537,8 +536,10 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { + const initialContext = Promise.withResolvers<{ contextWindow: number }>() const result = await setup({ agentOptions: { provider: 'alpha', model: 'a1' }, + contextTokens: 50, catalog: { providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }], models: [ @@ -547,6 +548,9 @@ describe('pi-tui chat lifecycle and transcript', () => { { provider: 'beta', id: 'b1', name: 'Beta One' }, { provider: 'beta', id: 'shared', name: 'Beta Shared' }, ], + resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1' + ? initialContext.promise + : Promise.resolve({ contextWindow: 200 }), }, }) @@ -574,6 +578,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Model selected: beta/b1') expect(result.agent.sent).toEqual([]) expect(result.agent.steered).toEqual([]) + initialContext.resolve({ contextWindow: 100 }) + await tick() + expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)') result.terminal.send('/model') result.terminal.send('\r') @@ -584,7 +591,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() - expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)') + expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)') const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) @@ -620,6 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [{ id: 'alpha', name: 'Alpha' }], models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelContext: () => Promise.resolve(undefined), }, }) unset.terminal.send('/model') @@ -628,6 +636,7 @@ describe('pi-tui chat lifecycle and transcript', () => { unset.terminal.send('\r') await tick() expect(unset.terminal.output).toContain('Model selected: alpha/a1') + expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)') await dispose(unset) const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) @@ -649,12 +658,14 @@ describe('pi-tui chat lifecycle and transcript', () => { providers: [{ id: 'deepseek', name: 'DeepSeek' }], models: [], listModels: () => Promise.reject(new Error('catalog offline')), + resolveModelContext: () => Promise.reject(new Error('capacity offline')), }, }) failed.terminal.send('/model') failed.terminal.send('\r') await tick() expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline') + expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline') await dispose(failed) }) @@ -690,6 +701,21 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(rejectedResult.terminal.output).not.toContain('late catalog failure') await rejectedResult.ctx.fiber.dispose() + + const context = Promise.withResolvers<{ contextWindow: number }>() + const contextResult = await setup({ + contextTokens: 99, + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + resolveModelContext: () => context.promise, + }, + }) + await contextResult.controller.dispose() + context.resolve({ contextWindow: 100 }) + await tick() + expect(contextResult.terminal.output).not.toContain('99% context') + await contextResult.ctx.fiber.dispose() }) it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {