fix(tui): resolve context capacity for active model
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ class FooterComponent implements Component {
|
|||||||
private readonly showReasoning: () => boolean,
|
private readonly showReasoning: () => boolean,
|
||||||
private readonly tokens: () => { input: number; output: number },
|
private readonly tokens: () => { input: number; output: number },
|
||||||
private readonly currentModel: () => string | undefined,
|
private readonly currentModel: () => string | undefined,
|
||||||
private readonly contextPercent: () => number,
|
private readonly contextPercent: () => number | undefined,
|
||||||
private readonly runningSeconds: () => number,
|
private readonly runningSeconds: () => number,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -755,7 +755,8 @@ class FooterComponent implements Component {
|
|||||||
const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}`
|
const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}`
|
||||||
const model = displayText(this.currentModel() ?? 'model unset')
|
const model = displayText(this.currentModel() ?? 'model unset')
|
||||||
const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
|
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 fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
|
||||||
const compactRight = `${context} ${modelState}`
|
const compactRight = `${context} ${modelState}`
|
||||||
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
|
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
|
||||||
@@ -1058,6 +1059,11 @@ export function createTuiChat(
|
|||||||
let activeQuestion: PendingQuestion | undefined
|
let activeQuestion: PendingQuestion | undefined
|
||||||
let modelOverlay: OverlayHandle | undefined
|
let modelOverlay: OverlayHandle | undefined
|
||||||
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: 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()
|
let modelCommands = Promise.resolve()
|
||||||
const now = (): number => runtime.now?.() ?? Date.now()
|
const now = (): number => runtime.now?.() ?? Date.now()
|
||||||
|
|
||||||
@@ -1070,7 +1076,9 @@ export function createTuiChat(
|
|||||||
() => showReasoning,
|
() => showReasoning,
|
||||||
() => tokens,
|
() => tokens,
|
||||||
() => target.current?.model,
|
() => 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)),
|
() => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
|
||||||
)
|
)
|
||||||
ui.addChild(header)
|
ui.addChild(header)
|
||||||
@@ -1096,12 +1104,34 @@ export function createTuiChat(
|
|||||||
|
|
||||||
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
|
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 => {
|
const selectModel = (selected: ModelChoice): void => {
|
||||||
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
|
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
|
||||||
appendNotice(`Model is already ${targetLabel(selected)}.`)
|
appendNotice(`Model is already ${targetLabel(selected)}.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target.current = { provider: selected.provider, model: selected.model }
|
target.current = { provider: selected.provider, model: selected.model }
|
||||||
|
resolveContextWindow(target.current)
|
||||||
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
|
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,6 +1462,7 @@ export function createTuiChat(
|
|||||||
const shutdown = (exitProcess: boolean): Promise<void> => {
|
const shutdown = (exitProcess: boolean): Promise<void> => {
|
||||||
shuttingDown ??= (async () => {
|
shuttingDown ??= (async () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
|
contextResolution = undefined
|
||||||
clearStatus()
|
clearStatus()
|
||||||
modelOverlay?.hide()
|
modelOverlay?.hide()
|
||||||
modelOverlay = undefined
|
modelOverlay = undefined
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import type { Terminal } from '@earendil-works/pi-tui'
|
import type { Terminal } from '@earendil-works/pi-tui'
|
||||||
import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
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 CommandService from '@deepseek-ai/dsh-commands'
|
||||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
@@ -31,6 +31,7 @@ export interface TuiHarnessOptions {
|
|||||||
providers: LlmProviderInfo[]
|
providers: LlmProviderInfo[]
|
||||||
models: LlmModelInfo[]
|
models: LlmModelInfo[]
|
||||||
listModels?: (provider: string) => Promise<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)
|
return catalog.listModels?.(provider)
|
||||||
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
|
?? 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)
|
} as never)
|
||||||
ctx.provide('tokenMeter', {
|
ctx.provide('tokenMeter', {
|
||||||
contextWindow: options.contextWindow ?? 128_000,
|
|
||||||
measure() {
|
measure() {
|
||||||
return { totalTokens: options.contextTokens ?? 0 }
|
return { totalTokens: options.contextTokens ?? 0 }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<
|
|||||||
|
|
||||||
function provideTokenMeter(ctx: Context): void {
|
function provideTokenMeter(ctx: Context): void {
|
||||||
ctx.provide('tokenMeter', {
|
ctx.provide('tokenMeter', {
|
||||||
contextWindow: 128_000,
|
|
||||||
measure() {
|
measure() {
|
||||||
return { totalTokens: 0 }
|
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 () => {
|
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({
|
const result = await setup({
|
||||||
agentOptions: { provider: 'alpha', model: 'a1' },
|
agentOptions: { provider: 'alpha', model: 'a1' },
|
||||||
|
contextTokens: 50,
|
||||||
catalog: {
|
catalog: {
|
||||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||||
models: [
|
models: [
|
||||||
@@ -547,6 +548,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||||
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
|
{ 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.terminal.output).toContain('Model selected: beta/b1')
|
||||||
expect(result.agent.sent).toEqual([])
|
expect(result.agent.sent).toEqual([])
|
||||||
expect(result.agent.steered).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('/model')
|
||||||
result.terminal.send('\r')
|
result.terminal.send('\r')
|
||||||
@@ -584,7 +591,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
result.agent.status = 'idle'
|
result.agent.status = 'idle'
|
||||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||||
await tick()
|
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))
|
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||||
@@ -620,6 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
catalog: {
|
catalog: {
|
||||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||||
|
resolveModelContext: () => Promise.resolve(undefined),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
unset.terminal.send('/model')
|
unset.terminal.send('/model')
|
||||||
@@ -628,6 +636,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
unset.terminal.send('\r')
|
unset.terminal.send('\r')
|
||||||
await tick()
|
await tick()
|
||||||
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
|
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
|
||||||
|
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
|
||||||
await dispose(unset)
|
await dispose(unset)
|
||||||
|
|
||||||
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
||||||
@@ -649,12 +658,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||||
models: [],
|
models: [],
|
||||||
listModels: () => Promise.reject(new Error('catalog offline')),
|
listModels: () => Promise.reject(new Error('catalog offline')),
|
||||||
|
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
failed.terminal.send('/model')
|
failed.terminal.send('/model')
|
||||||
failed.terminal.send('\r')
|
failed.terminal.send('\r')
|
||||||
await tick()
|
await tick()
|
||||||
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
|
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)
|
await dispose(failed)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -690,6 +701,21 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
await tick()
|
await tick()
|
||||||
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
|
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
|
||||||
await rejectedResult.ctx.fiber.dispose()
|
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 () => {
|
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user