feat(tui): include session name in the terminal titlebar (#43188)

The terminal/console titlebar was composed from status marker + model +
cwd only; the session's (auto-)title never appeared, even though the TUI
already knows it.

Change the format to `<marker> <session name> · <model> · <cwd>`, with the
session name and cwd each omitted when absent so single-segment titles stay
clean. The current session's live title is pulled from the existing
session.active_list poll (which already carries each session's current flag
and title), so there's no extra round-trip; UiState gains a sessionTitle
field updated only when it actually changes, preserving the existing
idle-flicker guard.

Extract the join logic into a pure composeTabTitle() helper in domain/paths
and cover its edge cases (name omitted, cwd omitted, whitespace-only name,
marker-only fallback, truncation, boundary length) in paths.test.ts.
This commit is contained in:
Ben Barclay 2026-06-10 11:24:01 +10:00 committed by GitHub
parent 258d24039f
commit d33965396e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 85 additions and 6 deletions

View file

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
describe('shortCwd', () => {
const origHome = process.env.HOME
@ -68,3 +68,43 @@ describe('fmtCwdBranch', () => {
expect(out).toContain(')')
})
})
describe('composeTabTitle', () => {
it('joins marker, name, model, and cwd in order', () => {
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
})
it('glues the marker to the first segment with a space, not a separator', () => {
expect(composeTabTitle('⏳', 'my session', 'opus-4', '~/proj').startsWith('⏳ my session')).toBe(true)
})
it('omits the session name when empty (matches the pre-name format)', () => {
expect(composeTabTitle('✓', '', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
})
it('treats a whitespace-only name as absent', () => {
expect(composeTabTitle('✓', ' ', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
})
it('omits the cwd when empty', () => {
expect(composeTabTitle('✓', 'my session', 'opus-4', '')).toBe('✓ my session · opus-4')
})
it('falls back to just the marker when only the marker is present', () => {
expect(composeTabTitle('✓', '', '', '')).toBe('✓')
})
it('truncates an over-long session name with an ellipsis', () => {
const long = 'a'.repeat(40)
const out = composeTabTitle('✓', long, 'opus-4', '', 28)
const namePart = out.slice('✓ '.length).split(' · ')[0]
expect(namePart.endsWith('…')).toBe(true)
expect(namePart.length).toBe(28)
})
it('keeps a name at the boundary length intact', () => {
const name = 'b'.repeat(28)
const out = composeTabTitle('✓', name, 'opus-4', '', 28)
expect(out).toBe(`${name} · opus-4`)
})
})

View file

@ -128,6 +128,7 @@ export interface UiState {
pasteCollapseChars: number
sections: SectionVisibility
sessionTitle: string
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle

View file

@ -22,6 +22,7 @@ const buildUiState = (): UiState => ({
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
sessionTitle: '',
showCost: false,
showReasoning: false,
sid: null,

View file

@ -7,7 +7,7 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
ClarifyRespondResponse,
@ -524,12 +524,22 @@ export function useMainApp(gw: GatewayClient) {
if (!stopped && result?.sessions) {
const liveSessionCount = result.sessions.length
// Only patch when the count actually changed. patchUiState always
// Surface the current session's (auto-)title for the terminal
// titlebar. The active_list poll already carries it, so no extra
// round-trip is needed.
const currentSid = getUiState().sid
const sessionTitle =
result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
// Only patch when something actually changed. patchUiState always
// produces a new state object, which notifies every $uiState
// subscriber; patching unconditionally on each 1.5s poll re-renders
// the whole TUI and causes idle flicker.
if (getUiState().liveSessionCount !== liveSessionCount) {
patchUiState({ liveSessionCount })
const prev = getUiState()
if (prev.liveSessionCount !== liveSessionCount || prev.sessionTitle !== sessionTitle) {
patchUiState({ liveSessionCount, sessionTitle })
}
}
})
@ -546,13 +556,16 @@ export function useMainApp(gw: GatewayClient) {
}, [gw, ui.sid])
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
// Format: `<marker> <session name> · <model> · <cwd>` — name/cwd omitted when absent.
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
const tabCwd = ui.info?.cwd
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
useTerminalTitle(
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
)
useEffect(() => {
if (!ui.sid || !stdout) {

View file

@ -14,3 +14,27 @@ export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => {
return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}`
}
/**
* Compose the terminal titlebar string:
* `<marker> <session name> · <model> · <cwd>`
*
* The session name and cwd are each omitted when empty, and a long session
* name is truncated. The marker is always glued to the first present segment
* with a plain space (not a ` · ` separator). When no model is known yet the
* caller should fall back to a plain brand string instead of calling this.
*/
export const composeTabTitle = (
marker: string,
sessionName: string,
model: string,
cwd: string,
maxName = 28
): string => {
const name = sessionName.trim()
const shortName = name.length > maxName ? `${name.slice(0, maxName - 1)}` : name
const segments = [shortName, model, cwd].filter(Boolean)
return segments.length ? `${marker} ${segments.join(' · ')}` : marker
}