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
5 changed files with 85 additions and 6 deletions
+1
View File
@@ -128,6 +128,7 @@ export interface UiState {
pasteCollapseChars: number
sections: SectionVisibility
sessionTitle: string
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle
+1
View File
@@ -22,6 +22,7 @@ const buildUiState = (): UiState => ({
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
sessionTitle: '',
showCost: false,
showReasoning: false,
sid: null,
+18 -5
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) {