Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/cli/src/dashboard/compositor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
Expand Down Expand Up @@ -257,6 +262,9 @@ export class Compositor {

async run(): Promise<void> {
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: <selected box>`.
pushTerminalTitle(this.out);
if (this.inp.isTTY) this.inp.setRawMode(true);
this.inp.resume();
this.inp.on('data', this.onData);
Expand Down Expand Up @@ -928,7 +936,26 @@ export class Compositor {
}
}

/** Drive the host terminal/tab title from the selected box:
* `AgentBox: <session title | box name>`, 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
Expand Down Expand Up @@ -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?.();
}
}
2 changes: 1 addition & 1 deletion apps/cli/src/dashboard/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/src/terminal/title.ts
Original file line number Diff line number Diff line change
@@ -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 ; <title> 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`);
}
33 changes: 31 additions & 2 deletions apps/cli/src/wrapped-pty/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions apps/cli/test/terminal-title.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading