From d33965396e5c8b80bc845b33fa4d8446f630f155 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 10 Jun 2026 11:24:01 +1000 Subject: [PATCH] feat(tui): include session name in the terminal titlebar (#43188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` · · `, 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. --- ui-tui/src/__tests__/paths.test.ts | 42 +++++++++++++++++++++++++++++- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/app/useMainApp.ts | 23 ++++++++++++---- ui-tui/src/domain/paths.ts | 24 +++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/__tests__/paths.test.ts b/ui-tui/src/__tests__/paths.test.ts index ef3c31ff36e..d829dce2e5e 100644 --- a/ui-tui/src/__tests__/paths.test.ts +++ b/ui-tui/src/__tests__/paths.test.ts @@ -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`) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 5382bac9b71..30c62e03590 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -128,6 +128,7 @@ export interface UiState { pasteCollapseChars: number sections: SectionVisibility + sessionTitle: string showCost: boolean showReasoning: boolean indicatorStyle: IndicatorStyle diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index cacca23bdcc..470f4264b94 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -22,6 +22,7 @@ const buildUiState = (): UiState => ({ pasteCollapseLines: 5, pasteCollapseChars: 2000, sections: {}, + sessionTitle: '', showCost: false, showReasoning: false, sid: null, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d5bc706faca..3bd981b36cf 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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: ` · · ` — 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) { diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 43c023b6ba9..243c4fc50c8 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -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: + * ` · · ` + * + * 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 +}