mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(tui): reconcile master's model selector and session titles with the staging footer and status line
Post-rebase reconciliation of the two TUI lines that evolved in parallel: - header subtitle prefers the latest logged session title over the configured welcome; the process-local auto-title owns the whole terminal title while a logged session/title still wins through the suffixed form - footer keeps staging's model/cwd/usage/cache layout and gains master's context-percent segment; per-step usage dedup carries cache buckets - test harness only stubs the llm catalog when the test did not mount the real LlmService, and defaults the TUI clock to the real Date.now - the plugin-shaped /reload test composes commands+llm like the shipped app
This commit is contained in:
@@ -508,7 +508,7 @@ class HeaderComponent implements Component {
|
||||
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly welcome: string | undefined,
|
||||
private readonly subtitle: () => string | undefined,
|
||||
private readonly palette: Palette,
|
||||
private readonly gradient: boolean,
|
||||
private readonly currentModel: () => string | undefined,
|
||||
@@ -529,9 +529,10 @@ class HeaderComponent implements Component {
|
||||
const title = `${name} ${this.palette.bold('HARNESS')}`
|
||||
const model = displayText(this.currentModel() ?? 'model unset')
|
||||
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
||||
const subtitle = this.subtitle()
|
||||
const lines = [
|
||||
title,
|
||||
...this.welcome === undefined ? [] : [this.palette.muted(displayText(this.welcome))],
|
||||
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||
this.palette.dim(detail),
|
||||
]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
@@ -1380,7 +1381,7 @@ export function createTuiChat(
|
||||
let sessionTitle = foldSessionTitle(agent.session.events)?.title
|
||||
const header = new HeaderComponent(
|
||||
agent,
|
||||
config.welcome,
|
||||
() => sessionTitle ?? config.welcome,
|
||||
palette,
|
||||
resolved.color && resolved.truecolor,
|
||||
() => target.current?.model,
|
||||
@@ -1555,12 +1556,10 @@ export function createTuiChat(
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
||||
const title = titleLine(contentText(assembler.message().content))
|
||||
if (!disposed && title.length > 0) {
|
||||
sessionTitle = title
|
||||
header.invalidate()
|
||||
updateTerminalTitle()
|
||||
requestRender()
|
||||
}
|
||||
// Unlike a logged `session/title` (which suffixes the product title), the
|
||||
// process-local auto-title owns the whole terminal title. A logged title
|
||||
// arriving later still wins through `updateTerminalTitle`.
|
||||
if (!disposed && title.length > 0) runtime.terminal.setTitle(displayText(title))
|
||||
}
|
||||
void applyTitle().catch(ignoreTitleFailure)
|
||||
}
|
||||
|
||||
@@ -79,19 +79,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
|
||||
],
|
||||
}
|
||||
ctx.provide('llm', {
|
||||
listProviders() {
|
||||
return catalog.providers.map(provider => ({ ...provider }))
|
||||
},
|
||||
listModels(provider: string) {
|
||||
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', {
|
||||
measure() {
|
||||
return { totalTokens: options.contextTokens ?? 0 }
|
||||
@@ -107,6 +94,23 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
} else {
|
||||
await options.configureContext(ctx)
|
||||
}
|
||||
// A configureContext may mount the real LlmService (e.g. the auto-title
|
||||
// suites); only fill the advisory-catalog stub when none was provided.
|
||||
if (ctx.get('llm') === undefined) {
|
||||
ctx.provide('llm', {
|
||||
listProviders() {
|
||||
return catalog.providers.map(provider => ({ ...provider }))
|
||||
},
|
||||
listModels(provider: string) {
|
||||
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)
|
||||
}
|
||||
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
|
||||
if (options.sessionPersistence !== undefined) {
|
||||
ctx.provide('sessionPersistence', options.sessionPersistence as never)
|
||||
@@ -156,7 +160,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
}, options.config), {
|
||||
terminal,
|
||||
exit,
|
||||
now: options.now ?? (() => 0),
|
||||
// Default to the real clock (runtime.now falls back to Date.now) so the
|
||||
// elapsed-status suites can drive time via timers or Date.now spies; a
|
||||
// test pins the clock only by passing `now` explicitly.
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||
})
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
|
||||
@@ -124,6 +124,15 @@ function provideTokenMeter(ctx: Context): void {
|
||||
} as never)
|
||||
}
|
||||
|
||||
/** Minimal advisory-catalog llm stub for tests composing their own context. */
|
||||
function provideLlmCatalog(ctx: Context): void {
|
||||
ctx.provide('llm', {
|
||||
listProviders: () => [],
|
||||
listModels: () => Promise.resolve([]),
|
||||
resolveModelContext: () => Promise.resolve(undefined),
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe('TUI config', () => {
|
||||
it('defaults every direct-call TUI option', () => {
|
||||
expect(resolveTuiConfig(undefined)).toEqual({
|
||||
@@ -320,6 +329,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const result = await setup({
|
||||
contextWindow: 100,
|
||||
contextTokens: 42,
|
||||
// Short cwd: the footer clips its right (context/tools) segment first,
|
||||
// and the default worktree path would swallow it at 88 columns.
|
||||
cwd: '/opt',
|
||||
now: () => now,
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'restored prompt')
|
||||
@@ -346,13 +358,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('restored answer')
|
||||
expect(result.terminal.output).toContain('write tests')
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
||||
expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)')
|
||||
// Context resolution is async (resolveModelContext); settle before reading.
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('42% context tools:collapsed')
|
||||
// Narrow terminals clip the right-hand context/tools segment first; the
|
||||
// model-led left segment stays.
|
||||
result.terminal.resize(52)
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
|
||||
result.terminal.resize(65)
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
|
||||
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||||
result.terminal.resize(88)
|
||||
await tick()
|
||||
|
||||
@@ -462,7 +475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
||||
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
|
||||
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
expect(result.terminal.stopped).toBe(1)
|
||||
@@ -804,14 +817,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('cache 0%')
|
||||
|
||||
result.terminal.output = ''
|
||||
// Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed
|
||||
// Warm call lands live on the next step (same-step usage replaces rather
|
||||
// than accumulates): 5 uncached + 30 cache-read + 5 cache-write billed
|
||||
// input, so 30 of the 50 total prompt tokens are hits → 60%.
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
|
||||
inputTokens: 5,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 30,
|
||||
cacheWriteTokens: 5,
|
||||
})
|
||||
}, { turn: 1, step: 2 })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('cache 60%')
|
||||
expect(result.terminal.output).not.toContain('cache 0%')
|
||||
@@ -928,7 +942,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.agent.steered).toEqual([])
|
||||
initialContext.resolve({ contextWindow: 100 })
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
|
||||
expect(result.terminal.output).not.toContain('50% context tools:collapsed')
|
||||
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
@@ -939,7 +953,8 @@ 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('25% context tools:compact b1(reasoning:on)')
|
||||
expect(result.terminal.output).toContain('b1 ')
|
||||
expect(result.terminal.output).toContain('25% context tools:collapsed')
|
||||
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
@@ -984,7 +999,8 @@ 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)')
|
||||
expect(unset.terminal.output).toContain('a1 ')
|
||||
expect(unset.terminal.output).not.toContain('% context')
|
||||
await dispose(unset)
|
||||
|
||||
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
||||
@@ -1740,8 +1756,11 @@ describe('terminal mounting', () => {
|
||||
// `ctx.loader` proxy read would THROW `cannot get property without
|
||||
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
provideLlmCatalog(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
@@ -1752,7 +1771,7 @@ describe('terminal mounting', () => {
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
await ctx.plugin({
|
||||
inject: ['agents', 'userInteraction', 'tools'],
|
||||
inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'],
|
||||
apply: (pluginCtx: Context) => {
|
||||
mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user