fix(tui): split terminal tab title from window title

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 02:31:47 -05:00
parent 866c9adae3
commit f1a91ad416
5 changed files with 48 additions and 10 deletions

View file

@ -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'

View file

@ -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'

View file

@ -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
}

View file

@ -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(() => {

View file

@ -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