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(
|
constructor(
|
||||||
private readonly agent: Agent,
|
private readonly agent: Agent,
|
||||||
private readonly welcome: string | undefined,
|
private readonly subtitle: () => string | undefined,
|
||||||
private readonly palette: Palette,
|
private readonly palette: Palette,
|
||||||
private readonly gradient: boolean,
|
private readonly gradient: boolean,
|
||||||
private readonly currentModel: () => string | undefined,
|
private readonly currentModel: () => string | undefined,
|
||||||
@@ -529,9 +529,10 @@ class HeaderComponent implements Component {
|
|||||||
const title = `${name} ${this.palette.bold('HARNESS')}`
|
const title = `${name} ${this.palette.bold('HARNESS')}`
|
||||||
const model = displayText(this.currentModel() ?? 'model unset')
|
const model = displayText(this.currentModel() ?? 'model unset')
|
||||||
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
||||||
|
const subtitle = this.subtitle()
|
||||||
const lines = [
|
const lines = [
|
||||||
title,
|
title,
|
||||||
...this.welcome === undefined ? [] : [this.palette.muted(displayText(this.welcome))],
|
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||||
this.palette.dim(detail),
|
this.palette.dim(detail),
|
||||||
]
|
]
|
||||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||||
@@ -1380,7 +1381,7 @@ export function createTuiChat(
|
|||||||
let sessionTitle = foldSessionTitle(agent.session.events)?.title
|
let sessionTitle = foldSessionTitle(agent.session.events)?.title
|
||||||
const header = new HeaderComponent(
|
const header = new HeaderComponent(
|
||||||
agent,
|
agent,
|
||||||
config.welcome,
|
() => sessionTitle ?? config.welcome,
|
||||||
palette,
|
palette,
|
||||||
resolved.color && resolved.truecolor,
|
resolved.color && resolved.truecolor,
|
||||||
() => target.current?.model,
|
() => target.current?.model,
|
||||||
@@ -1555,12 +1556,10 @@ export function createTuiChat(
|
|||||||
const assembler = new BlockAssembler()
|
const assembler = new BlockAssembler()
|
||||||
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
||||||
const title = titleLine(contentText(assembler.message().content))
|
const title = titleLine(contentText(assembler.message().content))
|
||||||
if (!disposed && title.length > 0) {
|
// Unlike a logged `session/title` (which suffixes the product title), the
|
||||||
sessionTitle = title
|
// process-local auto-title owns the whole terminal title. A logged title
|
||||||
header.invalidate()
|
// arriving later still wins through `updateTerminalTitle`.
|
||||||
updateTerminalTitle()
|
if (!disposed && title.length > 0) runtime.terminal.setTitle(displayText(title))
|
||||||
requestRender()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void applyTitle().catch(ignoreTitleFailure)
|
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' },
|
{ 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', {
|
ctx.provide('tokenMeter', {
|
||||||
measure() {
|
measure() {
|
||||||
return { totalTokens: options.contextTokens ?? 0 }
|
return { totalTokens: options.contextTokens ?? 0 }
|
||||||
@@ -107,6 +94,23 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
} else {
|
} else {
|
||||||
await options.configureContext(ctx)
|
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 (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
|
||||||
if (options.sessionPersistence !== undefined) {
|
if (options.sessionPersistence !== undefined) {
|
||||||
ctx.provide('sessionPersistence', options.sessionPersistence as never)
|
ctx.provide('sessionPersistence', options.sessionPersistence as never)
|
||||||
@@ -156,7 +160,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
}, options.config), {
|
}, options.config), {
|
||||||
terminal,
|
terminal,
|
||||||
exit,
|
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 }),
|
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||||
})
|
})
|
||||||
return { ctx, session, agent, terminal, exit, controller }
|
return { ctx, session, agent, terminal, exit, controller }
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ function provideTokenMeter(ctx: Context): void {
|
|||||||
} as never)
|
} 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', () => {
|
describe('TUI config', () => {
|
||||||
it('defaults every direct-call TUI option', () => {
|
it('defaults every direct-call TUI option', () => {
|
||||||
expect(resolveTuiConfig(undefined)).toEqual({
|
expect(resolveTuiConfig(undefined)).toEqual({
|
||||||
@@ -320,6 +329,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
const result = await setup({
|
const result = await setup({
|
||||||
contextWindow: 100,
|
contextWindow: 100,
|
||||||
contextTokens: 42,
|
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,
|
now: () => now,
|
||||||
beforeMount(session) {
|
beforeMount(session) {
|
||||||
appendUser(session, 'restored prompt')
|
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('restored answer')
|
||||||
expect(result.terminal.output).toContain('write tests')
|
expect(result.terminal.output).toContain('write tests')
|
||||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
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)
|
result.terminal.resize(52)
|
||||||
await tick()
|
await tick()
|
||||||
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
|
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||||||
result.terminal.resize(65)
|
|
||||||
await tick()
|
|
||||||
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
|
|
||||||
result.terminal.resize(88)
|
result.terminal.resize(88)
|
||||||
await tick()
|
await tick()
|
||||||
|
|
||||||
@@ -462,7 +475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
|
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
|
||||||
await tick()
|
await tick()
|
||||||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
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)
|
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||||
await dispose(result)
|
await dispose(result)
|
||||||
expect(result.terminal.stopped).toBe(1)
|
expect(result.terminal.stopped).toBe(1)
|
||||||
@@ -804,14 +817,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
expect(result.terminal.output).toContain('cache 0%')
|
expect(result.terminal.output).toContain('cache 0%')
|
||||||
|
|
||||||
result.terminal.output = ''
|
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%.
|
// input, so 30 of the 50 total prompt tokens are hits → 60%.
|
||||||
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
|
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 5,
|
outputTokens: 5,
|
||||||
cacheReadTokens: 30,
|
cacheReadTokens: 30,
|
||||||
cacheWriteTokens: 5,
|
cacheWriteTokens: 5,
|
||||||
})
|
}, { turn: 1, step: 2 })
|
||||||
await tick()
|
await tick()
|
||||||
expect(result.terminal.output).toContain('cache 60%')
|
expect(result.terminal.output).toContain('cache 60%')
|
||||||
expect(result.terminal.output).not.toContain('cache 0%')
|
expect(result.terminal.output).not.toContain('cache 0%')
|
||||||
@@ -928,7 +942,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
expect(result.agent.steered).toEqual([])
|
expect(result.agent.steered).toEqual([])
|
||||||
initialContext.resolve({ contextWindow: 100 })
|
initialContext.resolve({ contextWindow: 100 })
|
||||||
await tick()
|
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('/model')
|
||||||
result.terminal.send('\r')
|
result.terminal.send('\r')
|
||||||
@@ -939,7 +953,8 @@ 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('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))
|
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' })
|
||||||
@@ -984,7 +999,8 @@ 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)')
|
expect(unset.terminal.output).toContain('a1 ')
|
||||||
|
expect(unset.terminal.output).not.toContain('% context')
|
||||||
await dispose(unset)
|
await dispose(unset)
|
||||||
|
|
||||||
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
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
|
// `ctx.loader` proxy read would THROW `cannot get property without
|
||||||
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
|
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
|
provideTokenMeter(ctx)
|
||||||
|
provideLlmCatalog(ctx)
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
|
await ctx.plugin(CommandService)
|
||||||
await ctx.plugin(UserInteractionService)
|
await ctx.plugin(UserInteractionService)
|
||||||
ctx.provide('tools', { get: () => undefined } as never)
|
ctx.provide('tools', { get: () => undefined } as never)
|
||||||
const session = ctx.sessions.create(SessionId('main'))
|
const session = ctx.sessions.create(SessionId('main'))
|
||||||
@@ -1752,7 +1771,7 @@ describe('terminal mounting', () => {
|
|||||||
const terminal = new FakeTerminal()
|
const terminal = new FakeTerminal()
|
||||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||||
await ctx.plugin({
|
await ctx.plugin({
|
||||||
inject: ['agents', 'userInteraction', 'tools'],
|
inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'],
|
||||||
apply: (pluginCtx: Context) => {
|
apply: (pluginCtx: Context) => {
|
||||||
mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() })
|
mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() })
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user