tmux-ide 2.9.0-beta.32 → 2.9.0-beta.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -11279,7 +11279,7 @@ var require_package = __commonJS({
11279
11279
  "package.json"(exports, module) {
11280
11280
  module.exports = {
11281
11281
  name: "tmux-ide",
11282
- version: "2.9.0-beta.32",
11282
+ version: "2.9.0-beta.34",
11283
11283
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
11284
11284
  type: "module",
11285
11285
  bin: {
@@ -48541,6 +48541,8 @@ var init_terminal_replica_interpreter = __esm({
48541
48541
  #widgetGate = false;
48542
48542
  #markerTail = "";
48543
48543
  #pendingResize = null;
48544
+ // null: no unpublished writes; undefined: a write requires full projection.
48545
+ #pendingWriteDirty = null;
48544
48546
  #pendingRaw = [];
48545
48547
  #pendingRawBytes = 0;
48546
48548
  #rawContinuityLost = false;
@@ -48792,6 +48794,12 @@ var init_terminal_replica_interpreter = __esm({
48792
48794
  this.#observability.nowMicros(),
48793
48795
  trace
48794
48796
  );
48797
+ const dirty = this.#backend.dirtyRange();
48798
+ const pending = this.#pendingWriteDirty;
48799
+ this.#pendingWriteDirty = pending === null ? dirty : pending === void 0 || dirty === void 0 ? void 0 : {
48800
+ start: Math.min(pending.start, dirty.start),
48801
+ end: Math.max(pending.end, dirty.end)
48802
+ };
48795
48803
  if (this.#backend.requiresNativeReseed()) {
48796
48804
  this.#commit(false);
48797
48805
  return;
@@ -48847,7 +48855,15 @@ var init_terminal_replica_interpreter = __esm({
48847
48855
  return;
48848
48856
  }
48849
48857
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
48858
+ const pending = this.#pendingWriteDirty;
48859
+ if (pending !== null) {
48860
+ dirty = pending === void 0 || dirty === void 0 ? void 0 : {
48861
+ start: Math.min(pending.start, dirty.start),
48862
+ end: Math.max(pending.end, dirty.end)
48863
+ };
48864
+ }
48850
48865
  const projected = this.#project(forceSeed ? void 0 : dirty);
48866
+ this.#pendingWriteDirty = null;
48851
48867
  const previous = this.#snapshot;
48852
48868
  const dirtyRows = [];
48853
48869
  for (let index = 0; index < projected.grid.length; index += 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.32",
3
+ "version": "2.9.0-beta.34",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,8 @@ export class TerminalReplicaInterpreter {
38
38
  #widgetGate = false;
39
39
  #markerTail = "";
40
40
  #pendingResize = null;
41
+ // null: no unpublished writes; undefined: a write requires full projection.
42
+ #pendingWriteDirty = null;
41
43
  #pendingRaw = [];
42
44
  #pendingRawBytes = 0;
43
45
  #rawContinuityLost = false;
@@ -306,6 +308,19 @@ export class TerminalReplicaInterpreter {
306
308
  await this.#writeToBackend(this.#backend, data);
307
309
  if (this.#observability.enabled)
308
310
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
311
+ // The parser resets its dirty tracker per write. Synchronized output may
312
+ // span many writes before publication, so retain their union until commit.
313
+ const dirty = this.#backend.dirtyRange();
314
+ const pending = this.#pendingWriteDirty;
315
+ this.#pendingWriteDirty =
316
+ pending === null
317
+ ? dirty
318
+ : pending === undefined || dirty === undefined
319
+ ? undefined
320
+ : {
321
+ start: Math.min(pending.start, dirty.start),
322
+ end: Math.max(pending.end, dirty.end),
323
+ };
309
324
  // DEC synchronized-output is atomic: no intermediate frame leaks.
310
325
  if (this.#backend.requiresNativeReseed()) {
311
326
  this.#commit(false);
@@ -369,7 +384,18 @@ export class TerminalReplicaInterpreter {
369
384
  return;
370
385
  }
371
386
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
387
+ const pending = this.#pendingWriteDirty;
388
+ if (pending !== null) {
389
+ dirty =
390
+ pending === undefined || dirty === undefined
391
+ ? undefined
392
+ : {
393
+ start: Math.min(pending.start, dirty.start),
394
+ end: Math.max(pending.end, dirty.end),
395
+ };
396
+ }
372
397
  const projected = this.#project(forceSeed ? undefined : dirty);
398
+ this.#pendingWriteDirty = null;
373
399
  const previous = this.#snapshot;
374
400
  const dirtyRows = [];
375
401
  for (let index = 0; index < projected.grid.length; index += 1) {
@@ -6,6 +6,7 @@ import { TuiButton } from "../ui/button.jsx";
6
6
  import { useKeyboardRoute } from "../ui/keyboard-router.jsx";
7
7
  import { applicationPaneRenameKeyAction, applicationPaneRenamePaste, } from "./application-pane-rename-input.js";
8
8
  import { HomeAgentSearch } from "../ui/home-agent-search.jsx";
9
+ import { createAgentStatusMarker } from "../ui/agent-status-marker.js";
9
10
  import { ActivityIndicator } from "../ui/activity-indicator.jsx";
10
11
  import { componentPalette } from "../ui/state.js";
11
12
  import { homeAgentStatusLabel, } from "./application-home-agents.js";
@@ -181,6 +182,12 @@ export function HomeAgentRoster(props) {
181
182
  <For each={keys()}>
182
183
  {(key) => {
183
184
  const row = () => byKey().get(key);
185
+ const marker = createAgentStatusMarker({
186
+ theme: () => props.theme,
187
+ status: () => row().activity,
188
+ attention: () => row().attention,
189
+ unavailable: () => stale(row()),
190
+ });
184
191
  return (<box height={rowHeight()} flexDirection="column" backgroundColor={componentPalette(props.theme, {
185
192
  selected: props.selection.selectedKey === key,
186
193
  disabled: row().paneId === null || stale(row()),
@@ -197,7 +204,7 @@ export function HomeAgentRoster(props) {
197
204
  : padCells(row().name, columns().agent) +
198
205
  padCells([row().machineLabel, row().serverLabel, row().sessionName]
199
206
  .filter(Boolean)
200
- .join(" / "), columns().session)} detail={padCells(`${!stale(row()) && row().attention ? "! " : ""}${stale(row()) ? "last seen" : homeAgentStatusLabel(row().activity).toLowerCase()}`, columns().status)} detailAlign="end" marker=" " selected={props.selection.selectedKey === key} focused={props.inputActive && !editing() && props.selection.selectedKey === key} hovered={hovered() === key} status={stale(row()) ? "unknown" : statusTone(row())} disabled={row().paneId === null || stale(row())} onActivate={(source) => {
207
+ .join(" / "), columns().session)} detail={padCells(`${marker()} ${stale(row()) ? "last seen" : homeAgentStatusLabel(row().activity).toLowerCase()}`, columns().status)} detailAlign="end" marker=" " selected={props.selection.selectedKey === key} focused={props.inputActive && !editing() && props.selection.selectedKey === key} hovered={hovered() === key} status={stale(row()) ? "unknown" : statusTone(row())} disabled={row().paneId === null || stale(row())} onActivate={(source) => {
201
208
  if (!props.inputActive || row().paneId === null || stale(row()))
202
209
  return;
203
210
  props.onSelect(key);
@@ -1,3 +1,4 @@
1
+ import { createAgentStatusMarker } from "../ui/agent-status-marker.js";
1
2
  import { fleetHostColor, summarizeFleetActivity } from "./fleet-presentation.js";
2
3
  import { TuiButton } from "../ui/button.jsx";
3
4
  /* @jsxImportSource @opentui/solid */
@@ -345,6 +346,12 @@ export function ApplicationMachineSidebar(props) {
345
346
  return current().agentHeading;
346
347
  },
347
348
  };
349
+ const agentMarker = createAgentStatusMarker({
350
+ theme: () => props.theme,
351
+ status: () => row.agent?.activity,
352
+ attention: () => Boolean(row.agent?.attention),
353
+ unavailable: () => row.group.state !== "ready" || Boolean(row.agent?.disabled),
354
+ });
348
355
  return (<box width={Math.max(1, props.width - 1)} height={rowHeight(row)} flexShrink={0} flexDirection="column">
349
356
  <box height={sectionGap(row)} flexShrink={0}/>
350
357
  <Show when={row.serverHeading}>
@@ -368,9 +375,7 @@ export function ApplicationMachineSidebar(props) {
368
375
  : row.group.label} marker={row.server
369
376
  ? " "
370
377
  : row.agent
371
- ? row.agent.attention
372
- ? "!"
373
- : " "
378
+ ? agentMarker()
374
379
  : row.session
375
380
  ? props.model.favorites?.().includes(row.session.id)
376
381
  ? " ★"
@@ -1,14 +1,12 @@
1
+ import { createAgentStatusMarker } from "./agent-status-marker.js";
1
2
  import { Badge } from "./badge.jsx";
2
3
  /** Agent-specific semantic badge. Agent lifecycle remains outside this presentation primitive. */
3
4
  export function AgentBadge(props) {
4
- const marker = () => {
5
- if (props.attention || props.status === "blocked")
6
- return "!";
7
- if (props.status === "working")
8
- return props.theme.glyphs.active;
9
- if (props.status === "done")
10
- return props.theme.glyphs.check;
11
- return props.theme.glyphs.inactive;
12
- };
5
+ const marker = createAgentStatusMarker({
6
+ theme: () => props.theme,
7
+ status: () => props.activity ?? props.status,
8
+ attention: () => Boolean(props.attention),
9
+ unavailable: () => Boolean(props.disabled),
10
+ });
13
11
  return (<Badge theme={props.theme} label={props.label} tone={props.status} marker={marker()} width={props.width} selected={props.selected} focused={props.focused} hovered={props.hovered} pressed={props.pressed} disabled={props.disabled} attention={props.attention || props.status === "blocked"} loading={props.loading} empty={props.empty} status={props.status}/>);
14
12
  }
@@ -0,0 +1,61 @@
1
+ import { batch, createEffect, createSignal, onCleanup } from "solid-js";
2
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ const listeners = new Set();
4
+ let timer;
5
+ let frame = 0;
6
+ /** One clock for mounted working indicators, with no daemon or inventory work. */
7
+ function subscribe(listener) {
8
+ listeners.add(listener);
9
+ listener(frame);
10
+ if (!timer) {
11
+ timer = setInterval(() => {
12
+ frame = (frame + 1) % FRAMES.length;
13
+ batch(() => listeners.forEach((update) => update(frame)));
14
+ }, 80);
15
+ timer.unref?.();
16
+ }
17
+ return () => {
18
+ listeners.delete(listener);
19
+ if (listeners.size === 0 && timer) {
20
+ clearInterval(timer);
21
+ timer = undefined;
22
+ frame = 0;
23
+ }
24
+ };
25
+ }
26
+ /** Presentation only: never infer activity from animation or process liveness. */
27
+ export function createAgentStatusMarker(options) {
28
+ const [current, setCurrent] = createSignal(0);
29
+ const working = () => options.status() === "running" || options.status() === "working";
30
+ const animated = () => working() &&
31
+ !options.attention?.() &&
32
+ !options.unavailable?.() &&
33
+ !options.theme().accessibility.reducedMotion;
34
+ createEffect(() => {
35
+ if (animated())
36
+ onCleanup(subscribe(setCurrent));
37
+ });
38
+ return () => {
39
+ if (options.unavailable?.())
40
+ return "·";
41
+ if (options.attention?.())
42
+ return "!";
43
+ switch (options.status()) {
44
+ case "running":
45
+ case "working":
46
+ return animated() ? FRAMES[current()] : options.theme().glyphs.active;
47
+ case "waiting":
48
+ case "blocked":
49
+ return "!";
50
+ case "complete":
51
+ case "done":
52
+ return options.theme().glyphs.check;
53
+ case "failed":
54
+ return "×";
55
+ case "idle":
56
+ return options.theme().glyphs.inactive;
57
+ default:
58
+ return "·";
59
+ }
60
+ };
61
+ }
@@ -153,7 +153,7 @@ export function PaneTitleBar(props) {
153
153
  ? "accent"
154
154
  : "done"} selected={props.selected} focused={props.keyboardFocused || props.terminalFocused}/>)}
155
155
  </Show>
156
- {showBadge() ? (<AgentBadge theme={props.theme} label={statusLabel()} status={status()} width={badgeWidth(statusLabel())} selected={props.selected} focused={props.keyboardFocused || props.terminalFocused} hovered={hovered()} attention={props.attention}/>) : null}
156
+ {showBadge() ? (<AgentBadge theme={props.theme} label={statusLabel()} status={status()} activity={props.activity} width={badgeWidth(statusLabel())} selected={props.selected} focused={props.keyboardFocused || props.terminalFocused} hovered={hovered()} attention={props.attention}/>) : null}
157
157
  {actionWidth() > 0 ? (<box width={actionWidth()} height={1} flexShrink={0}>
158
158
  <IconButton theme={props.theme} icon={actionsVisible() ? "⋯" : " "} label="Pane actions" variant="ghost" width={actionWidth()} focused={props.menuFocused} selected={props.menuOpen} disabled={props.menuDisabled} background={palette().background} onPress={() => activateMenu()}/>
159
159
  </box>) : null}
@@ -161,6 +161,8 @@ export class TerminalReplicaInterpreter {
161
161
  #widgetGate = false;
162
162
  #markerTail = "";
163
163
  #pendingResize: { cols: number; rows: number } | null = null;
164
+ // null: no unpublished writes; undefined: a write requires full projection.
165
+ #pendingWriteDirty: { start: number; end: number } | null | undefined = null;
164
166
  #pendingRaw: Uint8Array[] = [];
165
167
  #pendingRawBytes = 0;
166
168
  #rawContinuityLost = false;
@@ -464,6 +466,19 @@ export class TerminalReplicaInterpreter {
464
466
  this.#observability.nowMicros(),
465
467
  trace,
466
468
  );
469
+ // The parser resets its dirty tracker per write. Synchronized output may
470
+ // span many writes before publication, so retain their union until commit.
471
+ const dirty = this.#backend.dirtyRange();
472
+ const pending = this.#pendingWriteDirty;
473
+ this.#pendingWriteDirty =
474
+ pending === null
475
+ ? dirty
476
+ : pending === undefined || dirty === undefined
477
+ ? undefined
478
+ : {
479
+ start: Math.min(pending.start, dirty.start),
480
+ end: Math.max(pending.end, dirty.end),
481
+ };
467
482
  // DEC synchronized-output is atomic: no intermediate frame leaks.
468
483
  if (this.#backend.requiresNativeReseed()) {
469
484
  this.#commit(false);
@@ -531,7 +546,18 @@ export class TerminalReplicaInterpreter {
531
546
  return;
532
547
  }
533
548
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
549
+ const pending = this.#pendingWriteDirty;
550
+ if (pending !== null) {
551
+ dirty =
552
+ pending === undefined || dirty === undefined
553
+ ? undefined
554
+ : {
555
+ start: Math.min(pending.start, dirty.start),
556
+ end: Math.max(pending.end, dirty.end),
557
+ };
558
+ }
534
559
  const projected = this.#project(forceSeed ? undefined : dirty);
560
+ this.#pendingWriteDirty = null;
535
561
  const previous = this.#snapshot;
536
562
  const dirtyRows: CanonicalTerminalReplicaPatch["patch"]["rows"] = [];
537
563
  for (let index = 0; index < projected.grid.length; index += 1) {
@@ -7,7 +7,7 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
7
7
  ○ website CANONICAL-CELL
8
8
 
9
9
  Agents
10
- • Codex [WORKING]
10
+ ● Codex [WORKING]
11
11
 
12
12
 
13
13
 
@@ -23,7 +23,7 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
23
23
  ○ website CANONICAL-CELL
24
24
 
25
25
  Agents
26
- • Codex [WORKING]
26
+ ● Codex [WORKING]
27
27
 
28
28
 
29
29
 
@@ -39,7 +39,7 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
39
39
  ○ website CANONICAL-CELL
40
40
 
41
41
  Agents
42
- • Codex [WORKING]
42
+ ● Codex [WORKING]
43
43
 
44
44
 
45
45
 
@@ -76,7 +76,7 @@ exports[`production ApplicationShellView preserves canonical attention while pri
76
76
  ● main ● Codex ● working ⋯ ! Scout ! idle
77
77
  CANONICAL-CELL CANONICAL-CELL
78
78
  Agents
79
- • Codex [WORKING]
79
+ ● Codex [WORKING]
80
80
  ! Scout ! [IDLE]
81
81
 
82
82
 
@@ -10,6 +10,7 @@ import {
10
10
  applicationPaneRenamePaste,
11
11
  } from "./application-pane-rename-input.ts";
12
12
  import { HomeAgentSearch } from "../ui/home-agent-search.tsx";
13
+ import { createAgentStatusMarker } from "../ui/agent-status-marker.ts";
13
14
  import { ActivityIndicator } from "../ui/activity-indicator.tsx";
14
15
  import { componentPalette, type ComponentInteractionState } from "../ui/state.ts";
15
16
  import {
@@ -266,6 +267,12 @@ export function HomeAgentRoster(props: HomeAgentRosterProps) {
266
267
  <For each={keys()}>
267
268
  {(key) => {
268
269
  const row = () => byKey().get(key)!;
270
+ const marker = createAgentStatusMarker({
271
+ theme: () => props.theme,
272
+ status: () => row().activity,
273
+ attention: () => row().attention,
274
+ unavailable: () => stale(row()),
275
+ });
269
276
  return (
270
277
  <box
271
278
  height={rowHeight()}
@@ -305,7 +312,7 @@ export function HomeAgentRoster(props: HomeAgentRosterProps) {
305
312
  )
306
313
  }
307
314
  detail={padCells(
308
- `${!stale(row()) && row().attention ? "! " : ""}${stale(row()) ? "last seen" : homeAgentStatusLabel(row().activity).toLowerCase()}`,
315
+ `${marker()} ${stale(row()) ? "last seen" : homeAgentStatusLabel(row().activity).toLowerCase()}`,
309
316
  columns().status,
310
317
  )}
311
318
  detailAlign="end"
@@ -1,3 +1,4 @@
1
+ import { createAgentStatusMarker } from "../ui/agent-status-marker.ts";
1
2
  import type { TmuxServerDescriptor, TmuxServerScope } from "@tmux-ide/contracts";
2
3
  import { fleetHostColor, summarizeFleetActivity } from "./fleet-presentation.ts";
3
4
  import { TuiButton } from "../ui/button.tsx";
@@ -528,6 +529,12 @@ export function ApplicationMachineSidebar(props: {
528
529
  return current().agentHeading;
529
530
  },
530
531
  };
532
+ const agentMarker = createAgentStatusMarker({
533
+ theme: () => props.theme,
534
+ status: () => row.agent?.activity,
535
+ attention: () => Boolean(row.agent?.attention),
536
+ unavailable: () => row.group.state !== "ready" || Boolean(row.agent?.disabled),
537
+ });
531
538
  return (
532
539
  <box
533
540
  width={Math.max(1, props.width - 1)}
@@ -573,9 +580,7 @@ export function ApplicationMachineSidebar(props: {
573
580
  row.server
574
581
  ? " "
575
582
  : row.agent
576
- ? row.agent.attention
577
- ? "!"
578
- : " "
583
+ ? agentMarker()
579
584
  : row.session
580
585
  ? props.model.favorites?.().includes(row.session.id)
581
586
  ? " ★"
@@ -1,3 +1,4 @@
1
+ import { createAgentStatusMarker } from "../ui/agent-status-marker.ts";
1
2
  /* @jsxImportSource @opentui/solid */
2
3
  import type { AgentActivity } from "@tmux-ide/contracts";
3
4
  import type { JSX } from "solid-js";
@@ -156,6 +157,12 @@ export function ApplicationSidebarAgents(
156
157
  <For each={props.shell.semantic.sidebar.agents}>
157
158
  {(agent) => {
158
159
  const status = () => agentStatus(agent.activity);
160
+ const marker = createAgentStatusMarker({
161
+ theme: () => props.theme,
162
+ status: () => agent.activity,
163
+ attention: () => agent.attention,
164
+ unavailable: () => !agent.paneId,
165
+ });
159
166
  return (
160
167
  <NavigationRow
161
168
  theme={props.theme}
@@ -165,7 +172,7 @@ export function ApplicationSidebarAgents(
165
172
  detailMarker={agent.attention ? "!" : undefined}
166
173
  detailAlign="adjacent"
167
174
  width={rowWidth()}
168
- marker={agent.attention ? "!" : "•"}
175
+ marker={marker()}
169
176
  focused={sidebarFocused() && agent.paneId === focusedAgentPane()}
170
177
  status={status()}
171
178
  attention={agent.attention || status() === "blocked"}
@@ -1,5 +1,7 @@
1
1
  /* @jsxImportSource @opentui/solid */
2
+ import type { AgentActivity } from "@tmux-ide/contracts";
2
3
  import type { SemanticThemeSnapshot } from "../theme.ts";
4
+ import { createAgentStatusMarker } from "./agent-status-marker.ts";
3
5
  import { Badge } from "./badge.tsx";
4
6
  import type { ComponentInteractionState } from "./state.ts";
5
7
 
@@ -9,17 +11,18 @@ export interface AgentBadgeProps extends ComponentInteractionState {
9
11
  theme: SemanticThemeSnapshot;
10
12
  label: string;
11
13
  status: AgentBadgeStatus;
14
+ activity?: AgentActivity;
12
15
  width?: number;
13
16
  }
14
17
 
15
18
  /** Agent-specific semantic badge. Agent lifecycle remains outside this presentation primitive. */
16
19
  export function AgentBadge(props: AgentBadgeProps) {
17
- const marker = () => {
18
- if (props.attention || props.status === "blocked") return "!";
19
- if (props.status === "working") return props.theme.glyphs.active;
20
- if (props.status === "done") return props.theme.glyphs.check;
21
- return props.theme.glyphs.inactive;
22
- };
20
+ const marker = createAgentStatusMarker({
21
+ theme: () => props.theme,
22
+ status: () => props.activity ?? props.status,
23
+ attention: () => Boolean(props.attention),
24
+ unavailable: () => Boolean(props.disabled),
25
+ });
23
26
  return (
24
27
  <Badge
25
28
  theme={props.theme}
@@ -0,0 +1,69 @@
1
+ import { batch, createEffect, createSignal, onCleanup } from "solid-js";
2
+ import type { AgentActivity } from "@tmux-ide/contracts";
3
+ import type { SemanticThemeSnapshot } from "../theme.ts";
4
+
5
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const listeners = new Set<(frame: number) => void>();
7
+ let timer: ReturnType<typeof setInterval> | undefined;
8
+ let frame = 0;
9
+
10
+ /** One clock for mounted working indicators, with no daemon or inventory work. */
11
+ function subscribe(listener: (frame: number) => void): () => void {
12
+ listeners.add(listener);
13
+ listener(frame);
14
+ if (!timer) {
15
+ timer = setInterval(() => {
16
+ frame = (frame + 1) % FRAMES.length;
17
+ batch(() => listeners.forEach((update) => update(frame)));
18
+ }, 80);
19
+ timer.unref?.();
20
+ }
21
+ return () => {
22
+ listeners.delete(listener);
23
+ if (listeners.size === 0 && timer) {
24
+ clearInterval(timer);
25
+ timer = undefined;
26
+ frame = 0;
27
+ }
28
+ };
29
+ }
30
+
31
+ /** Presentation only: never infer activity from animation or process liveness. */
32
+ export function createAgentStatusMarker(options: {
33
+ theme: () => SemanticThemeSnapshot;
34
+ status: () => AgentActivity | "working" | "blocked" | "done" | "unknown" | undefined;
35
+ attention?: () => boolean;
36
+ unavailable?: () => boolean;
37
+ }) {
38
+ const [current, setCurrent] = createSignal(0);
39
+ const working = () => options.status() === "running" || options.status() === "working";
40
+ const animated = () =>
41
+ working() &&
42
+ !options.attention?.() &&
43
+ !options.unavailable?.() &&
44
+ !options.theme().accessibility.reducedMotion;
45
+ createEffect(() => {
46
+ if (animated()) onCleanup(subscribe(setCurrent));
47
+ });
48
+ return () => {
49
+ if (options.unavailable?.()) return "·";
50
+ if (options.attention?.()) return "!";
51
+ switch (options.status()) {
52
+ case "running":
53
+ case "working":
54
+ return animated() ? FRAMES[current()]! : options.theme().glyphs.active;
55
+ case "waiting":
56
+ case "blocked":
57
+ return "!";
58
+ case "complete":
59
+ case "done":
60
+ return options.theme().glyphs.check;
61
+ case "failed":
62
+ return "×";
63
+ case "idle":
64
+ return options.theme().glyphs.inactive;
65
+ default:
66
+ return "·";
67
+ }
68
+ };
69
+ }
@@ -290,6 +290,7 @@ export function PaneTitleBar(props: PaneTitleBarProps) {
290
290
  theme={props.theme}
291
291
  label={statusLabel()!}
292
292
  status={status()!}
293
+ activity={props.activity}
293
294
  width={badgeWidth(statusLabel())}
294
295
  selected={props.selected}
295
296
  focused={props.keyboardFocused || props.terminalFocused}