From 5b6bbd91d52ebffb22b7bd367ad0672ab475ac28 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 11:02:02 +0000 Subject: [PATCH] feat(work): distinguish agent turn busy vs idle on terminal tabs Workbench terminal dots stay lifecycle-only for ordinary shells. For Claude Code and Codex CLI, parse OSC titles already on the PTY stream so a running tab pulses while a turn executes and returns to steady green when the agent is idle. Co-authored-by: Daniels --- README.md | 4 +- ROADMAP.md | 8 + TASKS.md | 5 + docs/learnings.md | 11 ++ ui/src/lib/i18n.ts | 2 + ui/src/lib/terminalAgentActivity.test.ts | 101 +++++++++++ ui/src/lib/terminalAgentActivity.ts | 216 +++++++++++++++++++++++ ui/src/lib/workTabs.test.ts | 9 +- ui/src/lib/workTabs.ts | 7 + ui/src/stores/work.ts | 32 +++- ui/src/styles/features.css | 11 ++ ui/src/views/Work.test.ts | 9 + ui/src/views/Work.tsx | 33 +++- website/docs/work.md | 10 ++ 14 files changed, 444 insertions(+), 14 deletions(-) create mode 100644 ui/src/lib/terminalAgentActivity.test.ts create mode 100644 ui/src/lib/terminalAgentActivity.ts diff --git a/README.md b/README.md index 81c383f9..fe00a90c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 75a9cfae..90522616 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/TASKS.md b/TASKS.md index bf14dbb7..f5099503 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/docs/learnings.md b/docs/learnings.md index f7b99c73..2ebdb8b7 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -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 diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 9d4223cd..912e80ec 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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}', diff --git a/ui/src/lib/terminalAgentActivity.test.ts b/ui/src/lib/terminalAgentActivity.test.ts new file mode 100644 index 00000000..7f90a345 --- /dev/null +++ b/ui/src/lib/terminalAgentActivity.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/terminalAgentActivity.ts b/ui/src/lib/terminalAgentActivity.ts new file mode 100644 index 00000000..e19fbcc9 --- /dev/null +++ b/ui/src/lib/terminalAgentActivity.ts @@ -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(); + +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(); +} diff --git a/ui/src/lib/workTabs.test.ts b/ui/src/lib/workTabs.test.ts index 48db18de..024e3e8e 100644 --- a/ui/src/lib/workTabs.test.ts +++ b/ui/src/lib/workTabs.test.ts @@ -45,9 +45,9 @@ describe('Work tabs', () => { activePaneId: 'root', layout: { kind: 'pane', id: 'root', tabIds: ['a', 'b', 'c'], activeTabId: 'b' }, tabs: [ - { kind: 'terminal', id: 'a', repoPath: 'r', label: 'A', shell: null, runtimeId: null, lifecycle: 'dormant', exitCode: null, error: null }, - { kind: 'terminal', id: 'b', repoPath: 'r', label: 'B', shell: null, runtimeId: null, lifecycle: 'dormant', exitCode: null, error: null }, - { kind: 'terminal', id: 'c', repoPath: 'r', label: 'C', shell: null, runtimeId: null, lifecycle: 'dormant', exitCode: null, error: null }, + { kind: 'terminal', id: 'a', repoPath: 'r', label: 'A', shell: null, runtimeId: null, lifecycle: 'dormant', agentActivity: null, exitCode: null, error: null }, + { kind: 'terminal', id: 'b', repoPath: 'r', label: 'B', shell: null, runtimeId: null, lifecycle: 'dormant', agentActivity: null, exitCode: null, error: null }, + { kind: 'terminal', id: 'c', repoPath: 'r', label: 'C', shell: null, runtimeId: null, lifecycle: 'dormant', agentActivity: null, exitCode: null, error: null }, ], }; expect(closeWorkTab(state, 'b').activeTabId).toBe('c'); @@ -176,6 +176,7 @@ describe('Work tabs', () => { shell: null, runtimeId: null, lifecycle: 'dormant' as const, + agentActivity: null, exitCode: null, error: null, })); @@ -197,7 +198,7 @@ describe('Work tabs', () => { it('restores terminal descriptors without selecting or starting them', () => { const restored = restoreTerminalDescriptors([{ id: 'term', label: 'PowerShell' }]); expect(restored.activeTabId).toBeNull(); - expect(restored.tabs[0]).toMatchObject({ lifecycle: 'dormant', runtimeId: null }); + expect(restored.tabs[0]).toMatchObject({ lifecycle: 'dormant', runtimeId: null, agentActivity: null }); }); it('keeps an explicitly selected shell in the persisted descriptor', () => { diff --git a/ui/src/lib/workTabs.ts b/ui/src/lib/workTabs.ts index 2bf1ae4e..03739ce5 100644 --- a/ui/src/lib/workTabs.ts +++ b/ui/src/lib/workTabs.ts @@ -16,6 +16,10 @@ export type WorkFileTab = { export type TerminalLifecycle = 'dormant' | 'starting' | 'running' | 'exited' | 'error'; +/** Overlay on a live PTY: agent executing a turn vs waiting at a prompt. + * Null means unknown / not a recognized agent CLI — keep lifecycle-only dots. */ +export type TerminalAgentActivity = 'busy' | 'idle'; + export type WorkTerminalTab = { kind: 'terminal'; id: string; @@ -26,6 +30,8 @@ export type WorkTerminalTab = { shell: EmbeddedShellChoice | null; runtimeId: string | null; lifecycle: TerminalLifecycle; + /** Runtime-only; not persisted. */ + agentActivity: TerminalAgentActivity | null; exitCode: number | null; error: string | null; }; @@ -453,6 +459,7 @@ export function restoreTerminalDescriptors(descriptors: TerminalDescriptor[]): R shell: descriptor.shell ?? null, runtimeId: null, lifecycle: 'dormant' as const, + agentActivity: null, exitCode: null, error: null, })), diff --git a/ui/src/stores/work.ts b/ui/src/stores/work.ts index f9b58e1c..2738a4dc 100644 --- a/ui/src/stores/work.ts +++ b/ui/src/stores/work.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { settings } from '../lib/db'; import { t } from '../lib/i18n'; +import { resetTerminalAgentStream } from '../lib/terminalAgentActivity'; import { activateWorkPane, activateWorkTab, @@ -20,6 +21,7 @@ import { terminalDescriptors, workPanes, type RepoWorkTabs, + type TerminalAgentActivity, type TerminalLifecycle, type WorkFileTab, type WorkFileMode, @@ -73,6 +75,7 @@ interface WorkState { lifecycle: TerminalLifecycle, detail?: { exitCode?: number | null; error?: string | null }, ): void; + setTerminalAgentActivity(repoPath: string, id: string, activity: TerminalAgentActivity | null): void; clearTerminalRuntime(repoPath: string, id: string): void; reconcile(repoPath: string, change: FilesTreeMutationChange): void; setFileDraft(repoPath: string, path: string, draft: WorkFileDraft | null): void; @@ -154,7 +157,7 @@ export const useWork = create((set, get) => ({ kind: 'terminal', id, repoPath, label: label ?? t('work.terminalLabel', { count: terminalNumber }), shell, - runtimeId: null, lifecycle: 'dormant', exitCode: null, error: null, + runtimeId: null, lifecycle: 'dormant', agentActivity: null, exitCode: null, error: null, }; const next = { ...appendWorkTab(repo, tab, paneId), restored: true }; set((state) => ({ repos: { ...state.repos, [repoPath]: next } })); @@ -196,6 +199,7 @@ export const useWork = create((set, get) => ({ label: t('work.terminalLabel', { count: terminalNumber }), runtimeId: null, lifecycle: 'dormant', + agentActivity: null, exitCode: null, error: null, }; @@ -281,16 +285,40 @@ export const useWork = create((set, get) => ({ }, setTerminalState(repoPath, id, lifecycle, detail = {}) { + if (lifecycle !== 'running') resetTerminalAgentStream(id); set((state) => ({ repos: { ...state.repos, [repoPath]: mapTab(state.repos[repoPath], id, (tab) => tab.kind === 'terminal' - ? { ...tab, lifecycle, exitCode: detail.exitCode ?? tab.exitCode, error: detail.error ?? null } + ? { + ...tab, + lifecycle, + agentActivity: lifecycle === 'running' ? tab.agentActivity : null, + exitCode: detail.exitCode ?? tab.exitCode, + error: detail.error ?? null, + } : tab), }, })); }, + setTerminalAgentActivity(repoPath, id, activity) { + set((state) => { + const repo = state.repos[repoPath]; + const tab = repo?.tabs.find((item) => item.id === id); + if (!tab || tab.kind !== 'terminal' || tab.agentActivity === activity) return state; + if (activity !== null && tab.lifecycle !== 'running' && tab.lifecycle !== 'starting') return state; + return { + repos: { + ...state.repos, + [repoPath]: mapTab(repo, id, (item) => item.kind === 'terminal' + ? { ...item, agentActivity: activity } + : item), + }, + }; + }); + }, + clearTerminalRuntime(repoPath, id) { set((state) => ({ repos: { diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 899dd3e9..0a8199d8 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -7872,12 +7872,23 @@ textarea.clone-input { height: 6px; flex: 0 0 6px; border-radius: 50%; + display: inline-block; background: var(--text-dim); } .work-terminal-state.running { background: var(--add); } .work-terminal-state.starting { background: var(--warn); } .work-terminal-state.exited, .work-terminal-state.error { background: var(--del); } +.work-terminal-state.running.agent-busy { + box-shadow: 0 0 0 2px var(--add-bg-strong); + animation: work-terminal-agent-busy 0.9s ease-in-out infinite; +} +@keyframes work-terminal-agent-busy { + 50% { opacity: 0.35; transform: scale(1.35); } +} +@media (prefers-reduced-motion: reduce) { + .work-terminal-state.running.agent-busy { animation: none; } +} .work-content { position: relative; flex: 1; diff --git a/ui/src/views/Work.test.ts b/ui/src/views/Work.test.ts index 84fbce7a..e655acb2 100644 --- a/ui/src/views/Work.test.ts +++ b/ui/src/views/Work.test.ts @@ -25,3 +25,12 @@ describe('Work terminal renderer visibility', () => { expect(rule('.work-terminal-pane.visible')).toContain('visibility: visible'); }); }); + +describe('Work terminal agent status indicator', () => { + it('pulses the running dot only when the agent-busy overlay is present', () => { + expect(rule('.work-terminal-state.running')).toContain('var(--add)'); + expect(rule('.work-terminal-state.running')).not.toContain('animation:'); + expect(rule('.work-terminal-state.running.agent-busy')).toContain('work-terminal-agent-busy'); + expect(rule('.work-terminal-state.running.agent-busy')).toContain('var(--add-bg-strong)'); + }); +}); diff --git a/ui/src/views/Work.tsx b/ui/src/views/Work.tsx index e6afbc08..582c80b7 100644 --- a/ui/src/views/Work.tsx +++ b/ui/src/views/Work.tsx @@ -25,6 +25,10 @@ import { osType } from '../lib/integrations'; import { repoFamilyName } from '../lib/repoIdentity'; import { errMessage, tauri } from '../lib/tauri'; import { consumeTerminalClipboardKey, readClipboardText } from '../lib/terminalClipboard'; +import { + consumeTerminalAgentOutput, + terminalIndicatorClass, +} from '../lib/terminalAgentActivity'; import { terminalTheme } from '../lib/terminalTheme'; import type { EmbeddedShellChoice, TerminalEvent } from '../lib/types'; import { @@ -808,7 +812,7 @@ function WorkTabs({ )} {tab.kind === 'file' ? leaf(tab.path) : tab.label} {tab.kind === 'terminal' && ( - + )} @@ -1246,6 +1250,8 @@ function onTerminalEvent(tab: WorkTerminalTab, terminal: Terminal | null, event: const binary = atob(event.data); const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); terminal?.write(bytes); + const update = consumeTerminalAgentOutput(tab.id, bytes); + if (update) useWork.getState().setTerminalAgentActivity(tab.repoPath, tab.id, update.activity); } else if (event.type === 'exit') { useWork.getState().clearTerminalRuntime(tab.repoPath, tab.id); useWork.getState().setTerminalState(tab.repoPath, tab.id, 'exited', { exitCode: event.code }); @@ -1278,9 +1284,22 @@ function leaf(path: string): string { } function terminalStatus(tab: WorkTerminalTab): string { - if (tab.lifecycle === 'running') return t('work.terminalRunning'); - if (tab.lifecycle === 'starting') return t('work.terminalStarting'); - if (tab.lifecycle === 'exited') return t('work.terminalExited', { code: tab.exitCode ?? 0 }); - if (tab.lifecycle === 'error') return t('work.terminalErrorShort'); - return t('work.terminalDormant'); + switch (tab.lifecycle) { + case 'running': + if (tab.agentActivity === 'busy') return t('work.terminalAgentBusy'); + if (tab.agentActivity === 'idle') return t('work.terminalAgentIdle'); + return t('work.terminalRunning'); + case 'starting': + return t('work.terminalStarting'); + case 'exited': + return t('work.terminalExited', { code: tab.exitCode ?? 0 }); + case 'error': + return t('work.terminalErrorShort'); + case 'dormant': + return t('work.terminalDormant'); + default: { + const exhaustive: never = tab.lifecycle; + return exhaustive; + } + } } diff --git a/website/docs/work.md b/website/docs/work.md index 5832515b..7a4e9db1 100644 --- a/website/docs/work.md +++ b/website/docs/work.md @@ -117,6 +117,16 @@ complete dashboard even after setup tips and release notes have already been seen. This does not change Strand's repository-root working directory and the hints are ignored by other CLI agents. +While the shell is running, the tab-strip and open-tabs menu dots stay gray +(dormant), green (process alive), amber (starting), or red (exited/error). When +the terminal is hosting Claude Code or Codex CLI, the green running dot also +shows agent turn state: a pulsing ring while the agent is executing, and a +steady green once the turn finishes and the CLI is waiting for input. Screen +readers hear **Agent executing** or **Agent idle** instead of **Terminal +running**. Other shells and unknown CLIs keep the process-lifecycle meaning; +Strand only overlays agent state from OSC titles those CLIs already emit, and +prefers staying green over a false busy signal. + Terminal descriptors restore after restarting Strand, but processes never do. Restored tabs are unselected and dormant; explicitly selecting one starts a fresh shell using its saved one-off choice or the repository's current default.