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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ the resolved app appearance automatically.
grid. On Windows and Linux, Ctrl+C copies a terminal selection (else interrupt)
and Ctrl+V pastes; macOS keeps ⌘C/⌘V. Claude Code starts with its complete dashboard and alternate-screen
renderer in a configurable terminal font and size. Work tabs keep their width in a wheel-scrollable strip with an overflow
selector and tree-matched file icons, and middle-click closes a tab. Only descriptors restore after
selector and tree-matched file icons, and middle-click closes a tab. Claude Code and Codex CLI tabs pulse the
running status dot while a turn is executing and return to steady green when the agent is idle at a prompt.
Only descriptors restore after
relaunch; selecting one starts a fresh process. The New Terminal split button
can launch a one-off native or WSL shell, while Settings → Terminal provides
a global default, paired repository and shell selectors for per-repository
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2346,6 +2346,14 @@ and fullscreen-renderer compatibility environment, keeping the complete agent
dashboard visible from a repository-root shell after onboarding notices have
been consumed.

**DAN-75 agent turn status on Work terminal tabs shipped (2026-09-21):** The
Workbench tab-strip and overflow-menu dots still use process lifecycle for
ordinary shells. When Claude Code or Codex CLI is hosting a live PTY, OSC
titles already on the output stream overlay **agent executing** (pulsing green)
versus **turn finished / idle** (steady green) without a new IPC stack. Unknown
CLIs stay lifecycle-only; a false busy signal is treated as worse than remaining
green.

**DAN-47 light terminal contrast shipped (2026-08-24):** Embedded terminals
now provide xterm's complete normal and bright ANSI palettes from Strand's
resolved theme tokens. PowerShell and colorized CLI output remain legible on
Expand Down
5 changes: 5 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,11 @@ community plugins, performance and platform certification from Git feature gaps.
light/dark 16-color ANSI palettes (`terminalTheme`; DAN-47), and `configure_terminal_environment`
provides xterm/true-color capability and Claude Code full-dashboard/
alternate-screen compatibility.
- ☑ Workbench terminal tab dots distinguish Claude Code / Codex CLI turn
execution from turn-idle while the PTY stays `running`, using OSC 0/2
titles (and OSC 9;4 only after the CLI is recognized) already on the PTY
stream (`terminalAgentActivity`, `work-terminal-state.agent-busy`; DAN-75).
Plain shells keep dormant/running/starting/exited/error meaning.
- ☑ `portable-pty` native runtime and Tauri channels: direct resolved argv,
recovered PATH, bounded ordered output/input/resize, per-session reader,
repository cwd/open validation, Unix process groups, Windows kill-on-close
Expand Down
11 changes: 11 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,17 @@ other shells and cover them as a single tested environment contract. Mount only
the active file document so terminal continuity does not turn inactive file tabs
into a background rendering cost.

**Workbench terminal dots overlay agent turn state from OSC titles, never
from a second IPC path (2026-09-21).** Claude Code and Codex CLI already write
OSC 0/2 titles (braille spinner while a turn runs; asterisk idle marker or the
project-only remainder when waiting at a prompt) onto the existing PTY stream.
Parse those sequences in the UI output handler and store `agentActivity` on the
tab. Do not treat OSC 9;4 progress as an agent until the session is already
recognized — package managers emit it too. False "busy" is worse than staying
lifecycle-green. Reset the stream when the PTY leaves `running` so a relaunch
of the same tab id cannot inherit a spinner. Keep the overlay off unknown CLIs
and ordinary shells.

**Terminal defaults and explicit shell choices have different lifetimes
(2026-07-20).** The primary New Terminal action follows the repository/global
default at process start; a shell chosen from its split menu is stored in that
Expand Down
2 changes: 2 additions & 0 deletions ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export const en = {
'work.fileMissingTitle': 'File no longer exists',
'work.fileMissingBody': '{path} was removed from the working tree. This pinned tab stays open for context.',
'work.terminalRunning': 'Terminal running',
'work.terminalAgentBusy': 'Agent executing',
'work.terminalAgentIdle': 'Agent idle',
'work.terminalStarting': 'Terminal starting',
'work.terminalDormant': 'Terminal not started',
'work.terminalExited': 'Process exited with code {code}',
Expand Down
101 changes: 101 additions & 0 deletions ui/src/lib/terminalAgentActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it } from 'vitest';

import {
consumeTerminalAgentOutput,
resetTerminalAgentStream,
terminalIndicatorClass,
} from './terminalAgentActivity';

const encoder = new TextEncoder();

afterEach(() => {
resetTerminalAgentStream('tab');
});

function osc(payload: string, terminator: 'bel' | 'st' = 'bel'): Uint8Array {
const end = terminator === 'bel' ? '\x07' : '\x1b\\';
return encoder.encode(`\x1b]${payload}${end}`);
}

function title(text: string, terminator: 'bel' | 'st' = 'bel'): Uint8Array {
return osc(`0;${text}`, terminator);
}

function feed(bytes: Uint8Array, tabId = 'tab') {
return consumeTerminalAgentOutput(tabId, bytes);
}

describe('terminal agent activity from PTY OSC', () => {
it('leaves ordinary shells on lifecycle-only state', () => {
expect(feed(encoder.encode('user@host:~/src$ ls\r\n'))).toBeNull();
expect(feed(title('user@host:~/project'))).toBeNull();
expect(feed(title('src/codex/README.md'))).toBeNull();
expect(feed(osc('9;4;1;40'))).toBeNull();
});

it('marks Claude Code busy on a braille OSC title and idle on the asterisk marker', () => {
expect(feed(title('⠂ read the files'))).toEqual({ activity: 'busy' });
expect(feed(title('⠄ still working'))).toBeNull();
expect(feed(title('✳ Claude'))).toEqual({ activity: 'idle' });
expect(feed(title('✱ ready'))).toBeNull();
});

it('treats Codex spinner titles as busy and the project-only follow-up as idle', () => {
expect(feed(title('⠋ strand'))).toEqual({ activity: 'busy' });
expect(feed(title('strand'))).toEqual({ activity: 'idle' });
});

it('recognizes a named Claude title as idle until a busy status arrives', () => {
expect(feed(title('Claude Code'))).toEqual({ activity: 'idle' });
expect(feed(title('Thinking...'))).toEqual({ activity: 'busy' });
expect(feed(title('Ready'))).toEqual({ activity: 'idle' });
});

it('recognizes a named Codex title as idle', () => {
expect(feed(title('codex'), 'codex-tab')).toEqual({ activity: 'idle' });
resetTerminalAgentStream('codex-tab');
});

it('does not treat a lone Working... title as an agent', () => {
expect(feed(title('Working...'))).toBeNull();
});

it('ignores OSC 9;4 until the session is a recognized agent', () => {
expect(feed(osc('9;4;3'))).toBeNull();
expect(feed(title('codex'))).toEqual({ activity: 'idle' });
expect(feed(osc('9;4;1;12'))).toEqual({ activity: 'busy' });
expect(feed(osc('9;4;0'))).toEqual({ activity: 'idle' });
});

it('reassembles an OSC title split across PTY chunks', () => {
const bytes = title('⠋ home', 'st');
expect(feed(bytes.subarray(0, 6))).toBeNull();
expect(feed(bytes.subarray(6))).toEqual({ activity: 'busy' });
});

it('drops agent overlay when a shell title returns after the CLI exits', () => {
expect(feed(title('⠋ strand'))).toEqual({ activity: 'busy' });
expect(feed(title('user@host:~/strand'))).toEqual({ activity: null });
expect(feed(title('user@host:~/strand'))).toBeNull();
});

it('resets stream state so a relaunched tab cannot inherit busy', () => {
expect(feed(title('⠋ strand'))).toEqual({ activity: 'busy' });
resetTerminalAgentStream('tab');
expect(feed(title('user@host:~'))).toBeNull();
});
});

describe('terminalIndicatorClass', () => {
it('keeps lifecycle classes for plain shells and idle agents', () => {
expect(terminalIndicatorClass('running', null)).toBe('work-terminal-state running');
expect(terminalIndicatorClass('running', 'idle')).toBe('work-terminal-state running');
expect(terminalIndicatorClass('dormant', null)).toBe('work-terminal-state dormant');
expect(terminalIndicatorClass('starting', 'busy')).toBe('work-terminal-state starting');
expect(terminalIndicatorClass('exited', null)).toBe('work-terminal-state exited');
});

it('adds agent-busy only while the PTY is running', () => {
expect(terminalIndicatorClass('running', 'busy')).toBe('work-terminal-state running agent-busy');
});
});
216 changes: 216 additions & 0 deletions ui/src/lib/terminalAgentActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/** Agent busy vs turn-idle for Workbench terminal tabs.
*
* Claude Code and Codex CLI already emit OSC 0/2 titles (and sometimes
* OSC 9;4 progress) on the existing PTY stream. Strand does not add a
* second IPC path: parse those sequences from output bytes, and keep
* unknown CLIs on lifecycle-only dots. False "busy" is worse than green.
*/

import type { TerminalAgentActivity, TerminalLifecycle } from './workTabs';

export type { TerminalAgentActivity };

export type AgentActivityUpdate = {
activity: TerminalAgentActivity | null;
};

const ESC = 0x1b;
const BEL = 0x07;
const OSC_INTRODUCER = 0x5d; // ]
const ST_FINAL = 0x5c; // \
const MAX_LEFTOVER = 1_024;
const MAX_PAYLOAD = 512;

/** Braille-pattern block used as the working spinner prefix (CC + Codex). */
const BRAILLE_MIN = 0x2800;
const BRAILLE_MAX = 0x28FF;

/** Asterisk-family idle markers Claude Code prefixes onto OSC titles. */
const IDLE_MARKERS = new Set([0x2731, 0x2733, 0x273b, 0x273d]);

const BUSY_STATUS = /^(Starting|Thinking|Working|Waiting|Undoing)\.\.\.$/;
const NAMED_AGENT = /^(?:claude(?:\s+code)?|codex)(?:$|[\s—\-:])/i;
const SHELL_TITLE = /^[\w.-]+@[\w.-]+:/;

type Detector = {
leftover: Uint8Array;
recognized: boolean;
activity: TerminalAgentActivity | null;
};

const streams = new Map<string, Detector>();

export function resetTerminalAgentStream(tabId: string): void {
streams.delete(tabId);
}

export function consumeTerminalAgentOutput(
tabId: string,
bytes: Uint8Array,
): AgentActivityUpdate | null {
if (bytes.length === 0) return null;
const detector = streams.get(tabId) ?? {
leftover: new Uint8Array(0),
recognized: false,
activity: null,
};
const before = detector.activity;

if (detector.leftover.length === 0 && bytes.indexOf(ESC) < 0) {
return null;
}

const { leftover, commands } = parseOscSequences(concat(detector.leftover, bytes));
detector.leftover = leftover;
for (const command of commands) applyOsc(detector, command);
streams.set(tabId, detector);

if (detector.activity === before) return null;
return { activity: detector.activity };
}

export function terminalIndicatorClass(
lifecycle: TerminalLifecycle,
activity: TerminalAgentActivity | null,
): string {
const busy = lifecycle === 'running' && activity === 'busy';
return `work-terminal-state ${lifecycle}${busy ? ' agent-busy' : ''}`;
}

function applyOsc(detector: Detector, command: OscCommand): void {
if (command.kind === 'progress') {
if (!detector.recognized) return;
if (command.state === 1 || command.state === 3) detector.activity = 'busy';
else detector.activity = 'idle';
return;
}
applyTitle(detector, command.text);
}

function applyTitle(detector: Detector, raw: string): void {
const title = raw.replace(/\s+/g, ' ').trim();
if (!title) return;

const first = title.codePointAt(0);
if (first != null && first >= BRAILLE_MIN && first <= BRAILLE_MAX) {
detector.recognized = true;
detector.activity = 'busy';
return;
}

const rest = stripIdleMarker(title);
if (BUSY_STATUS.test(rest) && (detector.recognized || NAMED_AGENT.test(rest))) {
detector.recognized = true;
detector.activity = 'busy';
return;
}

if (first != null && IDLE_MARKERS.has(first)) {
detector.recognized = true;
detector.activity = 'idle';
return;
}

if (NAMED_AGENT.test(title) || NAMED_AGENT.test(rest)) {
detector.recognized = true;
detector.activity = BUSY_STATUS.test(rest) ? 'busy' : 'idle';
return;
}

if (SHELL_TITLE.test(title)) {
detector.recognized = false;
detector.activity = null;
return;
}

if (detector.recognized) detector.activity = 'idle';
}

function stripIdleMarker(title: string): string {
const first = title.codePointAt(0);
if (first == null) return title;
if (!IDLE_MARKERS.has(first) && (first < BRAILLE_MIN || first > BRAILLE_MAX)) return title;
return title.slice(String.fromCodePoint(first).length).trim();
}

type OscCommand =
| { kind: 'title'; text: string }
| { kind: 'progress'; state: number };

function parseOscSequences(input: Uint8Array): { leftover: Uint8Array; commands: OscCommand[] } {
const commands: OscCommand[] = [];
let i = 0;
while (i < input.length) {
if (input[i] !== ESC) {
i += 1;
continue;
}
if (i + 1 >= input.length) {
return { leftover: capLeftover(input.subarray(i)), commands };
}
if (input[i + 1] !== OSC_INTRODUCER) {
i += 1;
continue;
}
let j = i + 2;
let terminator = -1;
let next = -1;
while (j < input.length) {
if (input[j] === BEL) {
terminator = j;
next = j + 1;
break;
}
if (input[j] === ESC && j + 1 < input.length && input[j + 1] === ST_FINAL) {
terminator = j;
next = j + 2;
break;
}
if (j - (i + 2) > MAX_PAYLOAD) break;
j += 1;
}
if (terminator < 0) {
if (input.length - i > MAX_LEFTOVER) {
i += 1;
continue;
}
return { leftover: capLeftover(input.subarray(i)), commands };
}
const command = classifyOsc(input.subarray(i + 2, terminator));
if (command) commands.push(command);
i = next;
}
return { leftover: new Uint8Array(0), commands };
}

function classifyOsc(payloadBytes: Uint8Array): OscCommand | null {
if (payloadBytes.length === 0) return null;
let payload: string;
try {
payload = new TextDecoder('utf-8', { fatal: true }).decode(payloadBytes);
} catch {
return null;
}
if (payload.startsWith('0;') || payload.startsWith('1;') || payload.startsWith('2;')) {
return { kind: 'title', text: payload.slice(2) };
}
if (payload.startsWith('9;4;')) {
const state = Number.parseInt(payload.slice(4), 10);
if (state === 0 || state === 1 || state === 2 || state === 3 || state === 4) {
return { kind: 'progress', state };
}
}
return null;
}

function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
if (left.length === 0) return right;
const next = new Uint8Array(left.length + right.length);
next.set(left, 0);
next.set(right, left.length);
return next;
}

function capLeftover(bytes: Uint8Array): Uint8Array {
return bytes.length > MAX_LEFTOVER ? new Uint8Array(0) : bytes.slice();
}
Loading
Loading