diff --git a/apps/cli/src/dashboard/compositor.ts b/apps/cli/src/dashboard/compositor.ts index c1c315b..71f1a90 100644 --- a/apps/cli/src/dashboard/compositor.ts +++ b/apps/cli/src/dashboard/compositor.ts @@ -16,11 +16,13 @@ import { menuLines, lifecycleMenuLines, createMenuLines, + stripTitleGlyph, NEW_BOX_ID, ADVANCED_HINT_GROUPS, type SidebarBox, } from './sidebar.js'; import { renderFooter } from '../wrapped-pty/footer.js'; +import { popTerminalTitle, pushTerminalTitle, setTerminalTitle } from '../terminal/title.js'; import { postAnswer, subscribePrompts, type PromptStream } from '../wrapped-pty/prompt-client.js'; import type { BoxNoticeEvent, PromptAskEvent } from '@agentbox/relay'; @@ -167,6 +169,9 @@ export class Compositor { * the poll respawn so it can't interrupt the transition). */ private busy = false; private layout: DashboardLayout; + /** Last host terminal/tab title we emitted, to dedupe OSC writes across the + * frequent (spinner-driven) drawChrome calls. */ + private lastTitle: string | null = null; private prevRows: string[] | null = null; private renderTimer: ReturnType | null = null; private pollTimer: ReturnType | null = null; @@ -257,6 +262,9 @@ export class Compositor { async run(): Promise { this.out.write('\x1b[?1049h\x1b[?25l\x1b[2J' + MOUSE_ENABLE_SEQ + EXT_KEYS_ENABLE_SEQ); + // Save the user's tab title so teardown can restore it; updateTitle() (via + // drawChrome) then drives it to `AgentBox: `. + pushTerminalTitle(this.out); if (this.inp.isTTY) this.inp.setRawMode(true); this.inp.resume(); this.inp.on('data', this.onData); @@ -928,7 +936,26 @@ export class Compositor { } } + /** Drive the host terminal/tab title from the selected box: + * `AgentBox: `, or just `AgentBox` for the + * synthetic "+ New box" entry / no selection. Deduped via {@link lastTitle}. */ + private updateTitle(): void { + if (this.tornDown) return; + const box = this.selectedBox(); + const inner = + box && box.id !== NEW_BOX_ID + ? box.state === 'running' && box.sessionTitle + ? stripTitleGlyph(box.sessionTitle) + : box.name + : undefined; + const title = inner ? `AgentBox: ${inner}` : 'AgentBox'; + if (title === this.lastTitle) return; + this.lastTitle = title; + setTerminalTitle(title, this.out); + } + private drawChrome(): void { + this.updateTitle(); if (this.tornDown || this.layout.tooSmall) return; const { sidebar, sepX, statusY } = this.layout; // Inject the per-box pendingPrompt / checkpointing flags at render time @@ -1072,6 +1099,8 @@ export class Compositor { // Belt-and-suspenders: clear the whole mouse-mode family in case Claude // enabled one we didn't individually track. this.out.write(EXT_KEYS_DISABLE_SEQ + MOUSE_DISABLE_SEQ + '\x1b[?25h\x1b[0m\x1b[?1049l'); + // Restore the host terminal/tab title saved in run(). + popTerminalTitle(this.out); this.resolveDone?.(); } } diff --git a/apps/cli/src/dashboard/sidebar.ts b/apps/cli/src/dashboard/sidebar.ts index 2278ecc..9bbc49e 100644 --- a/apps/cli/src/dashboard/sidebar.ts +++ b/apps/cli/src/dashboard/sidebar.ts @@ -122,7 +122,7 @@ function projectLabel(project: string | undefined): string { * spinner glyph, e.g. `✳ `) plus any leading symbols/asterisks/space, so the * sidebar shows just the words. Falls back to the trimmed original if the * title is all decoration. */ -function stripTitleGlyph(s: string): string { +export function stripTitleGlyph(s: string): string { const t = s.replace(/^[\s\p{S}*·]+/u, ''); return t.length > 0 ? t : s.trim(); } diff --git a/apps/cli/src/terminal/title.ts b/apps/cli/src/terminal/title.ts new file mode 100644 index 0000000..f495021 --- /dev/null +++ b/apps/cli/src/terminal/title.ts @@ -0,0 +1,39 @@ +const ESC = '\x1b'; +const BEL = '\x07'; + +/** Replace control chars that would otherwise break the OSC string (the BEL + * terminator, or any C0 byte) and trim — a stray newline in the agent's + * session title must not corrupt the host terminal. */ +function sanitize(title: string): string { + return title.replace(/[\x00-\x1f\x7f]/g, ' ').trim(); +} + +/** + * Set the host terminal's title via OSC 0 (`ESC ] 0 ; BEL`), which sets + * both the window and icon title — the same sequence Claude Code emits. Guarded + * by `isTTY` so piped / redirected output stays clean. + */ +export function setTerminalTitle( + title: string, + stream: NodeJS.WriteStream = process.stdout, +): void { + if (!stream.isTTY) return; + stream.write(`${ESC}]0;${sanitize(title)}${BEL}`); +} + +/** + * Push the terminal's current title onto its title stack (XTPUSHTITLE, + * `CSI 22 ; 2 t`). Pair with {@link popTerminalTitle} on exit so the user's + * original tab title is restored. Terminals without title-stack support ignore + * the unknown CSI. + */ +export function pushTerminalTitle(stream: NodeJS.WriteStream = process.stdout): void { + if (!stream.isTTY) return; + stream.write(`${ESC}[22;2t`); +} + +/** Pop the title saved by {@link pushTerminalTitle} (XTPOPTITLE, `CSI 23 ; 2 t`). */ +export function popTerminalTitle(stream: NodeJS.WriteStream = process.stdout): void { + if (!stream.isTTY) return; + stream.write(`${ESC}[23;2t`); +} diff --git a/apps/cli/src/wrapped-pty/run.ts b/apps/cli/src/wrapped-pty/run.ts index 5c7182b..6469f58 100644 --- a/apps/cli/src/wrapped-pty/run.ts +++ b/apps/cli/src/wrapped-pty/run.ts @@ -3,6 +3,7 @@ import { readBoxStatus } from '@agentbox/sandbox-docker'; import type { AttachOpenIn } from '@agentbox/config'; import { loadPtyBackend } from '../pty/pty-backend.js'; import { detectHostTerminal, spawnInNewTerminal } from '../terminal/host.js'; +import { popTerminalTitle, pushTerminalTitle, setTerminalTitle } from '../terminal/title.js'; import { createInputRouter, type InputRouter, @@ -167,6 +168,15 @@ export async function runWrappedAttach(opts: WrappedAttachOptions): Promise<numb env: process.env, }); + // Mirror the agent's session title to the host terminal/tab title (iTerm2 + // etc.). tmux swallows the inner OSC title (set-titles off), so the host + // never sees it; we re-emit it ourselves from the polled status below. Save + // the user's current title first so teardown can restore it. Seed with the + // box name so the tab is named immediately, before the first status poll. + pushTerminalTitle(); + let lastEmittedTitle = opts.boxName; + setTerminalTitle(lastEmittedTitle); + // claude is always tmux-backed; a tmux-backed `agentbox shell` opts in via // `detachable: true`, a `--no-tmux` shell leaves it false (nothing to detach). const detachable = opts.detachable ?? opts.mode === 'claude'; @@ -419,8 +429,25 @@ export async function runWrappedAttach(opts: WrappedAttachOptions): Promise<numb name: opts.boxName, projectIndex: opts.projectIndex, }); - const nextTitle = status?.claude?.sessionTitle?.trim() || undefined; - const nextActivity = status?.claude?.state || undefined; + // Read the title/activity from the body of the agent we attached to; + // shell mode has no agent session so it keeps the box-name title. + const body = + opts.mode === 'codex' + ? status?.codex + : opts.mode === 'opencode' + ? status?.opencode + : opts.mode === 'shell' + ? undefined + : status?.claude; + const nextTitle = body?.sessionTitle?.trim() || undefined; + const nextActivity = body?.state || undefined; + // Mirror the live title to the host terminal/tab, falling back to the box + // name until the agent sets one. Deduped so we don't spam the terminal. + const desiredTitle = nextTitle ?? opts.boxName; + if (desiredTitle !== lastEmittedTitle) { + lastEmittedTitle = desiredTitle; + setTerminalTitle(desiredTitle); + } if (nextTitle === lastSessionTitle && nextActivity === lastActivity) return; lastSessionTitle = nextTitle; lastActivity = nextActivity; @@ -489,6 +516,8 @@ export async function runWrappedAttach(opts: WrappedAttachOptions): Promise<numb `\x1b[2K` + cursorMoveTo(rsFinal, csFinal), ); + // Restore the host terminal/tab title we saved at attach time. + popTerminalTitle(); if (exitCode === 0 && opts.detachNotice) { // Match the cosmetic of the old attachClaudeSession: overwrite tmux's diff --git a/apps/cli/test/terminal-title.test.ts b/apps/cli/test/terminal-title.test.ts new file mode 100644 index 0000000..e16d38c --- /dev/null +++ b/apps/cli/test/terminal-title.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + popTerminalTitle, + pushTerminalTitle, + setTerminalTitle, +} from '../src/terminal/title.js'; + +/** Minimal WriteStream stub capturing writes, with a settable `isTTY`. */ +function fakeStream(isTTY: boolean): { isTTY: boolean; out: string[] } & NodeJS.WriteStream { + const out: string[] = []; + return { + isTTY, + out, + write: (chunk: string) => { + out.push(chunk); + return true; + }, + } as unknown as { isTTY: boolean; out: string[] } & NodeJS.WriteStream; +} + +describe('setTerminalTitle', () => { + it('emits OSC 0 with BEL terminator on a TTY', () => { + const s = fakeStream(true); + setTerminalTitle('hi', s); + expect(s.out).toEqual(['\x1b]0;hi\x07']); + }); + + it('writes nothing when the stream is not a TTY', () => { + const s = fakeStream(false); + setTerminalTitle('hi', s); + expect(s.out).toEqual([]); + }); + + it('strips control chars that would break the OSC string', () => { + const s = fakeStream(true); + setTerminalTitle('a\nb\x07c', s); + expect(s.out).toEqual(['\x1b]0;a b c\x07']); + }); + + it('trims surrounding whitespace', () => { + const s = fakeStream(true); + setTerminalTitle(' spaced ', s); + expect(s.out).toEqual(['\x1b]0;spaced\x07']); + }); +}); + +describe('push/popTerminalTitle', () => { + it('emits the XTPUSHTITLE / XTPOPTITLE CSI on a TTY', () => { + const s = fakeStream(true); + pushTerminalTitle(s); + popTerminalTitle(s); + expect(s.out).toEqual(['\x1b[22;2t', '\x1b[23;2t']); + }); + + it('writes nothing when the stream is not a TTY', () => { + const s = fakeStream(false); + pushTerminalTitle(s); + popTerminalTitle(s); + expect(s.out).toEqual([]); + }); +});