From f1a91ad416bfb0c4073d0f5afa04e557f14556f4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 02:31:47 -0500 Subject: [PATCH] fix(tui): split terminal tab title from window title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal.app truncates background tab titles from the left, so a single long OSC 0 string (marker · session · model · cwd) leaves only the tail visible — usually the cwd or process name. Emit OSC 1 (icon/tab) with just the short session title and OSC 2 (window) with the full composed string, so background tabs show the session name instead of the cwd tail. --- ui-tui/packages/hermes-ink/index.d.ts | 1 + .../packages/hermes-ink/src/entry-exports.ts | 1 + .../src/ink/hooks/use-terminal-title.ts | 43 +++++++++++++++---- ui-tui/src/app/useMainApp.ts | 7 ++- ui-tui/src/types/hermes-ink.d.ts | 6 ++- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index a0db6e7e0c7..77469aa67bf 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -29,6 +29,7 @@ export { default as useStdin } from './src/ink/hooks/use-stdin.ts' export { useTabStatus } from './src/ink/hooks/use-tab-status.ts' export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' +export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts' export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' export { default as measureElement } from './src/ink/measure-element.ts' export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 1488124034d..6bca33a6f43 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -21,6 +21,7 @@ export { default as useStdin } from './ink/hooks/use-stdin.js' export { useTabStatus } from './ink/hooks/use-tab-status.js' export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js' export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' +export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js' export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts index 6b5b28f5c3e..08508bb770f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts @@ -7,15 +7,20 @@ import { TerminalWriteContext } from '../useTerminalNotification.js' /** * Declaratively set the terminal tab/window title. * - * Pass a string to set the title. ANSI escape sequences are stripped - * automatically so callers don't need to know about terminal encoding. + * Pass a single string to set both the tab and window title (OSC 0). + * Pass `{ tab, window }` to set them independently: the short `tab` string + * goes to OSC 1 (icon/tab label) and the longer `window` string goes to + * OSC 2 (window title bar). This matters for terminals like Apple + * Terminal.app whose narrow background tabs truncate the title from the + * left — a single long OSC 0 string leaves only the tail visible, while a + * separate short OSC 1 keeps the session name readable. + * * Pass `null` to opt out — the hook becomes a no-op and leaves the * terminal title untouched. * * On Windows, uses `process.title` (classic conhost doesn't support OSC). - * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout. */ -export function useTerminalTitle(title: string | null): void { +export function useTerminalTitle(title: string | TerminalTitlePair | null): void { const writeRaw = useContext(TerminalWriteContext) useEffect(() => { @@ -23,12 +28,34 @@ export function useTerminalTitle(title: string | null): void { return } - const clean = stripAnsi(title) - if (process.platform === 'win32') { + const clean = stripAnsi(typeof title === 'string' ? title : title.window ?? title.tab ?? '') process.title = clean - } else { - writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean)) + return + } + + if (typeof title === 'string') { + writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title))) + return + } + + // Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars + // show the short session name instead of a truncated tail. + const tab = stripAnsi(title.tab ?? '') + const window = stripAnsi(title.window ?? '') + if (tab && window) { + writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window)) + } else if (window) { + writeRaw(osc(OSC.SET_TITLE_AND_ICON, window)) + } else if (tab) { + writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab)) } }, [title, writeRaw]) } + +export interface TerminalTitlePair { + /** Short title for the tab/icon label (OSC 1). */ + tab?: string + /** Full title for the window title bar (OSC 2). */ + window?: string +} diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index cc3cea4a3b7..19729f080e0 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -626,7 +626,12 @@ export function useMainApp(gw: GatewayClient) { const tabCwd = ui.info?.cwd useTerminalTitle( - model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes' + model + ? { + tab: composeTabTitle(marker, ui.sessionTitle, '', ''), + window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : ''), + } + : 'Hermes' ) useEffect(() => { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 94df7504f50..7f7a53d9760 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -167,7 +167,11 @@ declare module '@hermes/ink' { readonly write: (data: string) => boolean } export function useTerminalFocus(): boolean - export function useTerminalTitle(title: string | null): void + export function useTerminalTitle(title: string | TerminalTitlePair | null): void + export interface TerminalTitlePair { + tab?: string + window?: string + } export function useDeclaredCursor(args: { readonly line: number readonly column: number