refactor(tui): drop the TUI-local auto-title; titles come from the session-title service
Master's log-backed session-title capability already titles sessions durably (deterministic fallback in the spine, optional model providers). Remove the TUI's own autoTitle generation — the latch, prompt, cap, and llm stream call — and keep the terminal rename: the TUI folds the logged title on mount and sets '<session title> — <configured title>' on every accepted session/title event. The tui-agent example and the scripted PTY fixture mount session-title-first-message-llm so titles stay model-made; the scripted adapter's tool-less branch now answers that provider's auxiliary request. See .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md
This commit is contained in:
@@ -42,7 +42,6 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
|
||||
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
|
||||
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
|
||||
| `autoTitle` | `true` | Replace `title` with a short model-made title derived from the session's first user message; a resumed session re-derives it from that stored message on mount (needs an `llm` service and an agent provider/model) |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
|
||||
@@ -46,10 +46,9 @@ import {
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { assertNever, BlockAssembler, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
@@ -108,15 +107,8 @@ export interface TuiConfig {
|
||||
* process boundary, so most deployments leave it unset.
|
||||
*/
|
||||
truecolor?: boolean
|
||||
/** Terminal window title while the UI is mounted. */
|
||||
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
|
||||
title?: string
|
||||
/**
|
||||
* Replace {@link TuiConfig.title} with a short model-generated title derived
|
||||
* from the session's first user message; a resumed session re-derives it from
|
||||
* that stored message on mount. No-op without an `llm` service or an agent
|
||||
* provider/model. On by default.
|
||||
*/
|
||||
autoTitle?: boolean
|
||||
}
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
@@ -132,7 +124,6 @@ const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
const autoTitleSchema = z.boolean().default(true)
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
@@ -148,7 +139,6 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
autoTitle: autoTitleSchema,
|
||||
})
|
||||
|
||||
/** Serializable plugin configuration. */
|
||||
@@ -183,7 +173,6 @@ export const Config: z<Config> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
autoTitle: autoTitleSchema,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
@@ -200,7 +189,6 @@ export interface ResolvedTuiConfig {
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
title: string
|
||||
autoTitle: boolean
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
@@ -239,7 +227,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
color: config?.color ?? true,
|
||||
truecolor: config?.truecolor ?? false,
|
||||
title: config?.title ?? 'DeepSeek Harness',
|
||||
autoTitle: config?.autoTitle ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,24 +413,6 @@ function contentText(content: readonly ContentBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** Longest auto-generated title kept before the tail is elided; fits common tmux/tab widths. */
|
||||
const AUTO_TITLE_MAX_LENGTH = 40
|
||||
|
||||
/** Task framing for the auto-title model call; written from the model's view, not the UI's. */
|
||||
const AUTO_TITLE_SYSTEM_PROMPT = [
|
||||
"Summarize the user's request as a short title of 2 to 5 lowercase words.",
|
||||
'Use no punctuation or quotation marks. Reply with only the title.',
|
||||
].join('\n')
|
||||
|
||||
/** First non-empty line of the model's reply, trimmed and capped for a terminal title. */
|
||||
function titleLine(text: string): string {
|
||||
const line = text.split('\n').map(part => part.trim()).find(part => part.length > 0) ?? ''
|
||||
return line.length > AUTO_TITLE_MAX_LENGTH ? `${line.slice(0, AUTO_TITLE_MAX_LENGTH - 1)}…` : line
|
||||
}
|
||||
|
||||
/** Auto-title is best-effort: a stream error or shutdown abort leaves the current title unchanged. */
|
||||
const ignoreTitleFailure = (): void => {}
|
||||
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
return content
|
||||
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
|
||||
@@ -1351,14 +1320,6 @@ export function createTuiChat(
|
||||
const skills = ctx.get('skills')
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const skillAbort = new AbortController()
|
||||
// Auto-title replaces the static title with a short model-generated title
|
||||
// derived from the session's first user message. A resumed session re-derives
|
||||
// it from that stored message on mount (see below); a fresh session derives it
|
||||
// when the first message arrives. It is already settled — keeping the static
|
||||
// title — only when the feature is off. The abort cancels an in-flight title
|
||||
// stream at shutdown.
|
||||
const titleAbort = new AbortController()
|
||||
let titleSettled = !resolved.autoTitle
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
const allToolCards = new Set<ToolCardComponent>()
|
||||
@@ -1534,44 +1495,6 @@ export function createTuiChat(
|
||||
})
|
||||
}
|
||||
|
||||
// Fire-and-forget a title request. The prompt is the trimmed first-message
|
||||
// text; an empty one is skipped without consuming the one-shot slot. The `llm`
|
||||
// service is optional, so a deployment without it (or without an agent
|
||||
// provider/model) silently keeps the static title.
|
||||
const generateTitle = (prompt: string): void => {
|
||||
if (titleSettled || prompt.length === 0) return
|
||||
titleSettled = true
|
||||
const llm = ctx.get('llm')
|
||||
const { provider, model } = agent.options
|
||||
if (llm === undefined || !provider || !model) return
|
||||
const options: GenerateOptions = {
|
||||
provider,
|
||||
model,
|
||||
system: AUTO_TITLE_SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
sessionId: agent.session.id,
|
||||
signal: titleAbort.signal,
|
||||
}
|
||||
const applyTitle = async (): Promise<void> => {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
||||
const title = titleLine(contentText(assembler.message().content))
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Resume: derive the title from the session's already-logged first user
|
||||
// message. A fresh session has none here and titles from the live message via
|
||||
// the session-event listener instead.
|
||||
const firstUserMessage = agent.session.events.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message',
|
||||
)
|
||||
if (firstUserMessage !== undefined) generateTitle(contentText(firstUserMessage.data.content).trim())
|
||||
|
||||
const clearStatus = (): void => {
|
||||
if (runningStatus !== undefined) {
|
||||
clearInterval(runningStatus.timer)
|
||||
@@ -1929,7 +1852,6 @@ export function createTuiChat(
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
contextResolution = undefined
|
||||
titleAbort.abort()
|
||||
clearStatus()
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
@@ -2318,7 +2240,6 @@ export function createTuiChat(
|
||||
if (session !== agent.session) return
|
||||
recordEventUsage(tokens, event)
|
||||
advanceTurnPhase(event)
|
||||
if (event.type === 'user/message') generateTitle(contentText(event.data.content).trim())
|
||||
if (event.type === 'steering/message' && pendingSteering > 0) {
|
||||
// A queued steering message reached the model as it drained; drop it from
|
||||
// the badge. Clamped because loop-authored steering (e.g. continuation
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
@@ -148,7 +148,6 @@ describe('TUI config', () => {
|
||||
color: true,
|
||||
truecolor: false,
|
||||
title: 'DeepSeek Harness',
|
||||
autoTitle: true,
|
||||
})
|
||||
expect(resolveTuiConfig({
|
||||
showReasoning: false,
|
||||
@@ -163,7 +162,6 @@ describe('TUI config', () => {
|
||||
color: false,
|
||||
truecolor: true,
|
||||
title: 'DSH',
|
||||
autoTitle: false,
|
||||
})).toEqual({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
@@ -177,7 +175,6 @@ describe('TUI config', () => {
|
||||
color: false,
|
||||
truecolor: true,
|
||||
title: 'DSH',
|
||||
autoTitle: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2148,222 +2145,3 @@ describe('banner sweep reveal', () => {
|
||||
expect(result.terminal.output.length).toBe(settled)
|
||||
})
|
||||
})
|
||||
|
||||
/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */
|
||||
class TitleAdapter extends LlmAdapter {
|
||||
lastOptions: GenerateOptions | undefined
|
||||
calls = 0
|
||||
constructor(private readonly reply: string | Error) {
|
||||
super()
|
||||
}
|
||||
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.calls += 1
|
||||
this.lastOptions = options
|
||||
if (this.reply instanceof Error) throw this.reply
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: this.reply }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */
|
||||
function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise<void> {
|
||||
return async (ctx: Context) => {
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
}
|
||||
}
|
||||
|
||||
describe('TUI auto-title', () => {
|
||||
const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' }
|
||||
|
||||
it('replaces the title with a model-generated title after the first user message', async () => {
|
||||
const adapter = new TitleAdapter('fix the login redirect')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'the login page throws a 500 on submit, please investigate')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('fix the login redirect')
|
||||
// The request carries the task framing, the user's first message, and no tools.
|
||||
expect(adapter.lastOptions?.provider).toBe('mock')
|
||||
expect(adapter.lastOptions?.model).toBe('mock-model')
|
||||
expect(adapter.lastOptions?.system).toContain('short title')
|
||||
expect(adapter.lastOptions?.tools).toBeUndefined()
|
||||
expect(adapter.lastOptions?.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] },
|
||||
])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('requests a title only once, even after later user messages', async () => {
|
||||
const adapter = new TitleAdapter('the settled title')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'the first request that earns the title')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('the settled title')
|
||||
appendUser(result.session, 'a second request that must not re-title')
|
||||
await tick()
|
||||
expect(adapter.calls).toBe(1)
|
||||
expect(result.terminal.title).toBe('the settled title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => {
|
||||
const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'do the big thing')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('this title is deliberately far too long…')
|
||||
expect(result.terminal.title.length).toBe(40)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('skips a whitespace-only first message without consuming the one-shot slot', async () => {
|
||||
const adapter = new TitleAdapter('the real title')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, ' ')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
appendUser(result.session, 'the first real request')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('the real title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('leaves the title unchanged when the model returns no usable text', async () => {
|
||||
const adapter = new TitleAdapter(' \n ')
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'anything at all')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('leaves the title unchanged when the title request fails', async () => {
|
||||
const adapter = new TitleAdapter(new Error('router unavailable'))
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'trigger a failing title request')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('re-derives the title on resume from the already-logged first user message', async () => {
|
||||
const adapter = new TitleAdapter('resumed session title')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions,
|
||||
configureContext: withLlm(adapter),
|
||||
beforeMount: (session) => {
|
||||
appendUser(session, 'the original first request')
|
||||
appendUser(session, 'a later request that must not seed the title')
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// The title comes from the stored first message, not any later one.
|
||||
expect(adapter.lastOptions?.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'the original first request' }] },
|
||||
])
|
||||
expect(result.terminal.title).toBe('resumed session title')
|
||||
// A message that arrives after the resume must not re-title.
|
||||
appendUser(result.session, 'a follow-up message')
|
||||
await tick()
|
||||
expect(adapter.calls).toBe(1)
|
||||
expect(result.terminal.title).toBe('resumed session title')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when auto-title is disabled', async () => {
|
||||
const adapter = new TitleAdapter('should not run')
|
||||
const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) })
|
||||
appendUser(result.session, 'a normal message with the feature off')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when no llm service is available', async () => {
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions })
|
||||
appendUser(result.session, 'no model can answer this')
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when the agent has no provider', async () => {
|
||||
const adapter = new TitleAdapter('unused')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions: { model: 'mock-model' },
|
||||
configureContext: withLlm(adapter),
|
||||
})
|
||||
appendUser(result.session, 'the provider is missing')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the static title when the agent has no model', async () => {
|
||||
const adapter = new TitleAdapter('unused')
|
||||
const result = await setup({
|
||||
config: { autoTitle: true },
|
||||
agentOptions: { provider: 'mock' },
|
||||
configureContext: withLlm(adapter),
|
||||
})
|
||||
appendUser(result.session, 'the model is missing')
|
||||
await tick()
|
||||
expect(adapter.lastOptions).toBeUndefined()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('aborts an in-flight title request on shutdown', async () => {
|
||||
const seen: { aborted: boolean } = { aborted: false }
|
||||
class HangingAdapter extends LlmAdapter {
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
seen.aborted = true
|
||||
reject(new Error('aborted'))
|
||||
})
|
||||
})
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) })
|
||||
appendUser(result.session, 'start a title request that never resolves')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
expect(seen.aborted).toBe(true)
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('does not set the title when the UI is torn down before the stream completes', async () => {
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
// Yields a full reply, then blocks on the gate so the post-stream title
|
||||
// apply runs only after the test has torn the UI down. Ignores `signal`,
|
||||
// so shutdown's abort cannot cut the stream short.
|
||||
async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'title that arrives too late' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
await gate
|
||||
}
|
||||
}
|
||||
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) })
|
||||
appendUser(result.session, 'start a title that finishes after teardown')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
release()
|
||||
await tick()
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user