Просмотр исходного кода

fix(tui): resolve context capacity for active model

Tianyi Cui 2 месяцев назад
Родитель
Сommit
1cee25fdea

+ 1 - 1
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.
 

+ 34 - 3
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<void> => {
     shuttingDown ??= (async () => {
       disposed = true
+      contextResolution = undefined
       clearStatus()
       modelOverlay?.hide()
       modelOverlay = undefined

+ 6 - 2
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<LlmModelInfo[]>
+    resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
   }
 }
 
@@ -75,9 +76,12 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
       return catalog.listModels?.(provider)
         ?? Promise.resolve(catalog.models.filter(model => 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 }
     },

+ 28 - 2
packages/ui/tui/tests/tui.spec.ts

@@ -115,7 +115,6 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): 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 () => {