Parcourir la source

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
Turtle il y a 2 mois
Parent
commit
30c7e76ea5
3 fichiers modifiés avec 60 ajouts et 35 suppressions
  1. 8 9
      packages/ui/tui/src/index.ts
  2. 21 14
      packages/ui/tui/tests/harness.ts
  3. 31 12
      packages/ui/tui/tests/tui.spec.ts

+ 8 - 9
packages/ui/tui/src/index.ts

@@ -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) {
-        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)
     void applyTitle().catch(ignoreTitleFailure)
   }
   }

+ 21 - 14
packages/ui/tui/tests/harness.ts

@@ -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 }

+ 31 - 12
packages/ui/tui/tests/tui.spec.ts

@@ -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)')
-    result.terminal.resize(52)
+    // Context resolution is async (resolveModelContext); settle before reading.
     await tick()
     await tick()
-    expect(result.terminal.output).toContain('42% context  deepseek-v4-flash(reasoning:on)')
-    result.terminal.resize(65)
+    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()
     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)
     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() })
       },
       },