@bastani/atomic 0.6.6 → 0.6.7-0

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.
Files changed (49) hide show
  1. package/README.md +22 -16
  2. package/dist/sdk/components/compact-switcher.d.ts.map +1 -1
  3. package/dist/sdk/components/connectors.d.ts +1 -0
  4. package/dist/sdk/components/connectors.d.ts.map +1 -1
  5. package/dist/sdk/components/edge.d.ts +1 -1
  6. package/dist/sdk/components/edge.d.ts.map +1 -1
  7. package/dist/sdk/components/graph-theme.d.ts.map +1 -1
  8. package/dist/sdk/components/header.d.ts.map +1 -1
  9. package/dist/sdk/components/node-card.d.ts.map +1 -1
  10. package/dist/sdk/components/orchestrator-panel.d.ts +7 -1
  11. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
  12. package/dist/sdk/components/renderer-background.d.ts +9 -0
  13. package/dist/sdk/components/renderer-background.d.ts.map +1 -0
  14. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  15. package/dist/sdk/components/statusline.d.ts.map +1 -1
  16. package/dist/sdk/components/tui-diagnostics.d.ts +56 -0
  17. package/dist/sdk/components/tui-diagnostics.d.ts.map +1 -0
  18. package/dist/sdk/components/workflow-picker-panel.d.ts +2 -1
  19. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  20. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  21. package/dist/sdk/runtime/theme.d.ts +4 -0
  22. package/dist/sdk/runtime/theme.d.ts.map +1 -1
  23. package/dist/theme/colors.d.ts +2 -0
  24. package/dist/theme/colors.d.ts.map +1 -1
  25. package/package.json +2 -1
  26. package/src/cli.ts +3 -3
  27. package/src/commands/cli/management-commands.ts +4 -3
  28. package/src/commands/cli/session.test.ts +79 -6
  29. package/src/commands/cli/session.ts +65 -9
  30. package/src/completions/fish.ts +9 -3
  31. package/src/completions/powershell.ts +27 -3
  32. package/src/completions/zsh.ts +9 -2
  33. package/src/sdk/components/compact-switcher.tsx +10 -5
  34. package/src/sdk/components/connectors.ts +4 -0
  35. package/src/sdk/components/edge.tsx +5 -3
  36. package/src/sdk/components/graph-theme.ts +2 -3
  37. package/src/sdk/components/header.tsx +21 -9
  38. package/src/sdk/components/node-card.tsx +13 -7
  39. package/src/sdk/components/orchestrator-panel.tsx +47 -2
  40. package/src/sdk/components/renderer-background.ts +49 -0
  41. package/src/sdk/components/session-graph-panel.tsx +9 -2
  42. package/src/sdk/components/statusline.tsx +26 -22
  43. package/src/sdk/components/tui-diagnostics.ts +273 -0
  44. package/src/sdk/components/workflow-picker-panel.tsx +33 -22
  45. package/src/sdk/runtime/executor.ts +28 -1
  46. package/src/sdk/runtime/theme.ts +28 -36
  47. package/src/services/system/install-ui.ts +16 -17
  48. package/src/theme/colors.ts +14 -9
  49. package/src/theme/logo.ts +23 -12
@@ -7,7 +7,7 @@
7
7
  * tmux directly.
8
8
  */
9
9
 
10
- import { select, confirm, isCancel, cancel } from "@clack/prompts";
10
+ import { select, multiselect, confirm, isCancel, cancel } from "@clack/prompts";
11
11
  import { createPainter, type PaletteKey } from "../../theme/colors.ts";
12
12
  import {
13
13
  listSessions as _listSessions,
@@ -40,6 +40,8 @@ export interface SessionDeps {
40
40
  killSession: (name: string) => void;
41
41
  /** Prompt function for the session picker — defaults to @clack/prompts select. */
42
42
  select: typeof select;
43
+ /** Prompt function for the session kill picker — defaults to @clack/prompts multiselect. */
44
+ multiselect: typeof multiselect;
43
45
  /** Prompt function for yes/no confirmations — defaults to @clack/prompts confirm. */
44
46
  confirm: typeof confirm;
45
47
  isCancel: typeof isCancel;
@@ -57,6 +59,7 @@ const defaultDeps: SessionDeps = {
57
59
  detachAndAttachAtomic: _detachAndAttachAtomic,
58
60
  killSession: _killSession,
59
61
  select,
62
+ multiselect,
60
63
  confirm,
61
64
  isCancel,
62
65
  };
@@ -273,10 +276,13 @@ export async function sessionPickerCommand(agents: string[] = [], scope: Session
273
276
  // ─── Session kill command ────────────────────────────────────────────────────
274
277
 
275
278
  /**
276
- * Kill a named session or all sessions matching the given scope and agents.
279
+ * Kill a named session or selected sessions matching the given scope and agents.
277
280
  *
278
281
  * - If `sessionId` is provided: confirm and kill that one session.
279
- * - If `sessionId` is omitted: confirm and kill all sessions in scope.
282
+ * - If `sessionId` is omitted: pick sessions with a checkbox multi-select,
283
+ * then confirm and kill the selected sessions.
284
+ * - If `all: true` and `sessionId` is omitted: preselect every matching
285
+ * session and only ask for confirmation unless `yes: true` is also set.
280
286
  *
281
287
  * Pass `yes: true` (the `-y/--yes` flag on the CLI) to skip the
282
288
  * confirmation prompt — useful for orchestrating agents that need to
@@ -287,9 +293,10 @@ export async function sessionKillCommand(
287
293
  agents: string[] = [],
288
294
  scope: SessionScope = "all",
289
295
  deps: SessionDeps = defaultDeps,
290
- options: { yes?: boolean } = {},
296
+ options: { yes?: boolean; all?: boolean } = {},
291
297
  ): Promise<number> {
292
298
  const skipConfirm = options.yes === true;
299
+ const selectAll = options.all === true;
293
300
  const paint = createPainter();
294
301
 
295
302
  if (!deps.isTmuxInstalled()) {
@@ -351,7 +358,7 @@ export async function sessionKillCommand(
351
358
  return 0;
352
359
  }
353
360
 
354
- // ── Kill-all path ─────────────────────────────────────────────────────────
361
+ // ── Multi-kill path ───────────────────────────────────────────────────────
355
362
  const targets = filterByAgent(filterByScope(deps.listSessions(), scope), agents);
356
363
 
357
364
  if (targets.length === 0) {
@@ -359,12 +366,29 @@ export async function sessionKillCommand(
359
366
  return 0;
360
367
  }
361
368
 
362
- const noun = targets.length === 1 ? "session" : "sessions";
369
+ const selectedNames = selectAll
370
+ ? targets.map((t) => t.name)
371
+ : await selectSessionsToKill(targets, deps);
372
+
373
+ if (deps.isCancel(selectedNames)) {
374
+ cancel("Cancelled.");
375
+ return 0;
376
+ }
377
+
378
+ if (selectedNames.length === 0) {
379
+ process.stdout.write(
380
+ "\n " + paint("dim", "No sessions selected.") + "\n\n",
381
+ );
382
+ return 0;
383
+ }
384
+
385
+ const selectedTargets = targets.filter((t) => selectedNames.includes(t.name));
386
+ const noun = selectedTargets.length === 1 ? "session" : "sessions";
363
387
  const scopePrefix = scope === "all" ? "" : `${scope} `;
364
388
  const answer = skipConfirm
365
389
  ? true
366
390
  : await deps.confirm({
367
- message: `Kill all ${targets.length} ${scopePrefix}${noun}?`,
391
+ message: `Kill ${selectedTargets.length} ${scopePrefix}${noun}?`,
368
392
  initialValue: false,
369
393
  });
370
394
 
@@ -374,11 +398,11 @@ export async function sessionKillCommand(
374
398
  }
375
399
 
376
400
  if (answer === true) {
377
- for (const t of targets) {
401
+ for (const t of selectedTargets) {
378
402
  deps.killSession(t.name);
379
403
  }
380
404
  process.stdout.write(
381
- "\n " + paint("success", "✓") + " killed " + paint("text", String(targets.length)) + " " + paint("dim", noun) + "\n\n",
405
+ "\n " + paint("success", "✓") + " killed " + paint("text", String(selectedTargets.length)) + " " + paint("dim", noun) + "\n\n",
382
406
  );
383
407
  return 0;
384
408
  }
@@ -389,3 +413,35 @@ export async function sessionKillCommand(
389
413
  );
390
414
  return 0;
391
415
  }
416
+
417
+ const SELECT_ALL_SESSIONS = "__atomic_select_all_sessions__";
418
+
419
+ async function selectSessionsToKill(
420
+ targets: TmuxSession[],
421
+ deps: SessionDeps,
422
+ ): Promise<string[] | symbol> {
423
+ const selected = await deps.multiselect({
424
+ message: "Select sessions to kill (Space toggles, Enter continues)",
425
+ options: [
426
+ {
427
+ value: SELECT_ALL_SESSIONS,
428
+ label: "All matching sessions",
429
+ hint: `selects ${targets.length}`,
430
+ },
431
+ ...targets.map((s) => {
432
+ const age = formatAge(s.created);
433
+ const tag = s.attached ? "attached" : undefined;
434
+ return {
435
+ value: s.name,
436
+ label: s.name,
437
+ hint: tag ? `${age}, ${tag}` : age,
438
+ };
439
+ }),
440
+ ],
441
+ required: false,
442
+ });
443
+
444
+ if (deps.isCancel(selected)) return selected;
445
+ if (selected.includes(SELECT_ALL_SESSIONS)) return targets.map((t) => t.name);
446
+ return selected;
447
+ }
@@ -86,10 +86,12 @@ complete -c atomic -n '__atomic_using_cmd chat; and not __fish_seen_subcommand_f
86
86
  # chat session
87
87
  complete -c atomic -n '__atomic_using_cmd chat session; and not __fish_seen_subcommand_from list connect kill' -a list -d 'List running sessions'
88
88
  complete -c atomic -n '__atomic_using_cmd chat session; and not __fish_seen_subcommand_from list connect kill' -a connect -d 'Attach to a running session'
89
- complete -c atomic -n '__atomic_using_cmd chat session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill a running session (omit id to kill all)'
89
+ complete -c atomic -n '__atomic_using_cmd chat session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill running sessions'
90
90
  complete -c atomic -n '__atomic_using_cmd chat session list' -s a -l agent -d 'Filter by agent' -r -a "$agents"
91
91
  complete -c atomic -n '__atomic_using_cmd chat session connect' -s a -l agent -d 'Filter by agent' -r -a "$agents"
92
92
  complete -c atomic -n '__atomic_using_cmd chat session kill' -s a -l agent -d 'Filter by agent' -r -a "$agents"
93
+ complete -c atomic -n '__atomic_using_cmd chat session kill' -l all -d 'Select all matching sessions'
94
+ complete -c atomic -n '__atomic_using_cmd chat session kill' -s y -l yes -d 'Skip confirmation prompt'
93
95
 
94
96
  # ── workflow ────────────────────────────────────────────────────────────────
95
97
 
@@ -104,19 +106,23 @@ complete -c atomic -n '__atomic_using_cmd workflow list' -s a -l agent -d 'Filte
104
106
  # workflow session
105
107
  complete -c atomic -n '__atomic_using_cmd workflow session; and not __fish_seen_subcommand_from list connect kill' -a list -d 'List running sessions'
106
108
  complete -c atomic -n '__atomic_using_cmd workflow session; and not __fish_seen_subcommand_from list connect kill' -a connect -d 'Attach to a running session'
107
- complete -c atomic -n '__atomic_using_cmd workflow session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill a running session (omit id to kill all)'
109
+ complete -c atomic -n '__atomic_using_cmd workflow session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill running sessions'
108
110
  complete -c atomic -n '__atomic_using_cmd workflow session list' -s a -l agent -d 'Filter by agent' -r -a "$agents"
109
111
  complete -c atomic -n '__atomic_using_cmd workflow session connect' -s a -l agent -d 'Filter by agent' -r -a "$agents"
110
112
  complete -c atomic -n '__atomic_using_cmd workflow session kill' -s a -l agent -d 'Filter by agent' -r -a "$agents"
113
+ complete -c atomic -n '__atomic_using_cmd workflow session kill' -l all -d 'Select all matching sessions'
114
+ complete -c atomic -n '__atomic_using_cmd workflow session kill' -s y -l yes -d 'Skip confirmation prompt'
111
115
 
112
116
  # ── session (top-level) ────────────────────────────────────────────────────
113
117
 
114
118
  complete -c atomic -n '__atomic_using_cmd session; and not __fish_seen_subcommand_from list connect kill' -a list -d 'List running sessions'
115
119
  complete -c atomic -n '__atomic_using_cmd session; and not __fish_seen_subcommand_from list connect kill' -a connect -d 'Attach to a running session'
116
- complete -c atomic -n '__atomic_using_cmd session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill a running session (omit id to kill all)'
120
+ complete -c atomic -n '__atomic_using_cmd session; and not __fish_seen_subcommand_from list connect kill' -a kill -d 'Kill running sessions'
117
121
  complete -c atomic -n '__atomic_using_cmd session list' -s a -l agent -d 'Filter by agent' -r -a "$agents"
118
122
  complete -c atomic -n '__atomic_using_cmd session connect' -s a -l agent -d 'Filter by agent' -r -a "$agents"
119
123
  complete -c atomic -n '__atomic_using_cmd session kill' -s a -l agent -d 'Filter by agent' -r -a "$agents"
124
+ complete -c atomic -n '__atomic_using_cmd session kill' -l all -d 'Select all matching sessions'
125
+ complete -c atomic -n '__atomic_using_cmd session kill' -s y -l yes -d 'Skip confirmation prompt'
120
126
 
121
127
  # ── config ──────────────────────────────────────────────────────────────────
122
128
 
@@ -80,7 +80,15 @@ Register-ArgumentCompleter -Native -CommandName atomic -ScriptBlock {
80
80
  $completions = @(
81
81
  @{ text = 'list'; tip = 'List running sessions' }
82
82
  @{ text = 'connect'; tip = 'Attach to a running session' }
83
- @{ text = 'kill'; tip = 'Kill a running session (omit id to kill all)' }
83
+ @{ text = 'kill'; tip = 'Kill running sessions' }
84
+ )
85
+ } elseif ($cmds.Count -ge 3 -and $cmds[2] -eq 'kill') {
86
+ $completions = @(
87
+ @{ text = '-a'; tip = 'Filter by agent' }
88
+ @{ text = '--agent'; tip = 'Filter by agent' }
89
+ @{ text = '--all'; tip = 'Select all matching sessions' }
90
+ @{ text = '-y'; tip = 'Skip confirmation prompt' }
91
+ @{ text = '--yes'; tip = 'Skip confirmation prompt' }
84
92
  )
85
93
  } else {
86
94
  $completions = @(
@@ -110,7 +118,15 @@ Register-ArgumentCompleter -Native -CommandName atomic -ScriptBlock {
110
118
  $completions = @(
111
119
  @{ text = 'list'; tip = 'List running sessions' }
112
120
  @{ text = 'connect'; tip = 'Attach to a running session' }
113
- @{ text = 'kill'; tip = 'Kill a running session (omit id to kill all)' }
121
+ @{ text = 'kill'; tip = 'Kill running sessions' }
122
+ )
123
+ } elseif ($cmds.Count -ge 3 -and $cmds[2] -eq 'kill') {
124
+ $completions = @(
125
+ @{ text = '-a'; tip = 'Filter by agent' }
126
+ @{ text = '--agent'; tip = 'Filter by agent' }
127
+ @{ text = '--all'; tip = 'Select all matching sessions' }
128
+ @{ text = '-y'; tip = 'Skip confirmation prompt' }
129
+ @{ text = '--yes'; tip = 'Skip confirmation prompt' }
114
130
  )
115
131
  } else {
116
132
  $completions = @(
@@ -125,7 +141,15 @@ Register-ArgumentCompleter -Native -CommandName atomic -ScriptBlock {
125
141
  $completions = @(
126
142
  @{ text = 'list'; tip = 'List running sessions' }
127
143
  @{ text = 'connect'; tip = 'Attach to a running session' }
128
- @{ text = 'kill'; tip = 'Kill a running session (omit id to kill all)' }
144
+ @{ text = 'kill'; tip = 'Kill running sessions' }
145
+ )
146
+ } elseif ($cmds.Count -ge 2 -and $cmds[1] -eq 'kill') {
147
+ $completions = @(
148
+ @{ text = '-a'; tip = 'Filter by agent' }
149
+ @{ text = '--agent'; tip = 'Filter by agent' }
150
+ @{ text = '--all'; tip = 'Select all matching sessions' }
151
+ @{ text = '-y'; tip = 'Skip confirmation prompt' }
152
+ @{ text = '--yes'; tip = 'Skip confirmation prompt' }
129
153
  )
130
154
  } else {
131
155
  $completions = @(
@@ -123,17 +123,24 @@ _atomic_session() {
123
123
  local -a subs=(
124
124
  'list:List running sessions'
125
125
  'connect:Attach to a running session'
126
- 'kill:Kill a running session (omit id to kill all)'
126
+ 'kill:Kill running sessions'
127
127
  )
128
128
  _describe 'subcommand' subs
129
129
  ;;
130
130
  subargs)
131
131
  case "\${words[1]}" in
132
- list|connect|kill)
132
+ list|connect)
133
133
  _arguments \\
134
134
  '*'{-a,--agent}'[Filter by agent]:agent:(claude opencode copilot)' \\
135
135
  '(-h --help)'{-h,--help}'[Show help]'
136
136
  ;;
137
+ kill)
138
+ _arguments \\
139
+ '*'{-a,--agent}'[Filter by agent]:agent:(claude opencode copilot)' \\
140
+ '--all[Select all matching sessions]' \\
141
+ '(-y --yes)'{-y,--yes}'[Skip confirmation prompt]' \\
142
+ '(-h --help)'{-h,--help}'[Show help]'
143
+ ;;
137
144
  esac
138
145
  ;;
139
146
  esac
@@ -44,6 +44,9 @@ export function CompactSwitcher({ selectedIndex }: CompactSwitcherProps) {
44
44
  const isSelected = i === selectedIndex;
45
45
  const icon = statusIcon(agent.status);
46
46
  const iconColor = statusColor(agent.status, theme);
47
+ const rowBackground = isSelected
48
+ ? lerpColor(theme.backgroundElement, theme.primary, 0.12)
49
+ : theme.backgroundElement;
47
50
  const duration =
48
51
  agent.startedAt !== null
49
52
  ? fmtDuration((agent.endedAt ?? Date.now()) - agent.startedAt)
@@ -56,15 +59,17 @@ export function CompactSwitcher({ selectedIndex }: CompactSwitcherProps) {
56
59
  flexDirection="row"
57
60
  paddingLeft={1}
58
61
  paddingRight={1}
59
- backgroundColor={isSelected ? lerpColor(theme.backgroundElement, theme.primary, 0.12) : theme.backgroundElement}
62
+ backgroundColor={rowBackground}
60
63
  >
61
64
  <text>
62
- <span fg={theme.textDim}>{String(i + 1).padStart(2)} </span>
63
- <span fg={iconColor}>{icon} </span>
64
- <span fg={isSelected ? theme.text : theme.textMuted}>{agent.name}</span>
65
+ <span fg={theme.textDim} bg={rowBackground}>{String(i + 1).padStart(2)} </span>
66
+ <span fg={iconColor} bg={rowBackground}>{icon} </span>
67
+ <span fg={isSelected ? theme.text : theme.textMuted} bg={rowBackground}>{agent.name}</span>
65
68
  </text>
66
69
  <box flexGrow={1} />
67
- <text fg={theme.textDim}>{duration}</text>
70
+ <text>
71
+ <span fg={theme.textDim} bg={rowBackground}>{duration}</span>
72
+ </text>
68
73
  </box>
69
74
  );
70
75
  })}
@@ -10,6 +10,7 @@ export interface ConnectorResult {
10
10
  width: number;
11
11
  height: number;
12
12
  color: string;
13
+ backgroundColor: string;
13
14
  }
14
15
 
15
16
  /** Fan-out connector: one parent branching down to one or more tree children. */
@@ -39,6 +40,7 @@ export function buildConnector(
39
40
  width: 1,
40
41
  height: numRows,
41
42
  color: theme.borderActive,
43
+ backgroundColor: theme.background,
42
44
  };
43
45
  }
44
46
 
@@ -85,6 +87,7 @@ export function buildConnector(
85
87
  width,
86
88
  height: numRows,
87
89
  color: theme.borderActive,
90
+ backgroundColor: theme.background,
88
91
  };
89
92
  }
90
93
 
@@ -152,5 +155,6 @@ export function buildMergeConnector(
152
155
  width,
153
156
  height: numRows,
154
157
  color: theme.borderActive,
158
+ backgroundColor: theme.background,
155
159
  };
156
160
  }
@@ -2,10 +2,12 @@
2
2
 
3
3
  import type { ConnectorResult } from "./connectors.ts";
4
4
 
5
- export function Edge({ text, col, row, width, height, color: edgeColor }: ConnectorResult) {
5
+ export function Edge({ text, col, row, width, height, color: edgeColor, backgroundColor }: ConnectorResult) {
6
6
  return (
7
- <box position="absolute" left={col} top={row} width={width} height={height}>
8
- <text fg={edgeColor}>{text}</text>
7
+ <box position="absolute" left={col} top={row} width={width} height={height} backgroundColor={backgroundColor}>
8
+ <text>
9
+ <span fg={edgeColor} bg={backgroundColor}>{text}</span>
10
+ </text>
9
11
  </box>
10
12
  );
11
13
  }
@@ -1,7 +1,6 @@
1
1
  // ─── Graph Theme ──────────────────────────────────
2
2
 
3
3
  import type { TerminalTheme } from "../runtime/theme.ts";
4
- import { lerpColor } from "./color-utils.ts";
5
4
 
6
5
  export interface GraphTheme {
7
6
  background: string;
@@ -24,13 +23,13 @@ export function deriveGraphTheme(t: TerminalTheme): GraphTheme {
24
23
  background: t.bg,
25
24
  backgroundElement: t.surface,
26
25
  text: t.text,
27
- textMuted: lerpColor(t.text, t.bg, 0.3),
26
+ textMuted: t.textMuted,
28
27
  textDim: t.dim,
29
28
  primary: t.accent,
30
29
  success: t.success,
31
30
  error: t.error,
32
31
  warning: t.warning,
33
- info: t.accent,
32
+ info: t.info,
34
33
  mauve: t.mauve,
35
34
  border: t.borderDim,
36
35
  borderActive: t.border,
@@ -9,11 +9,21 @@ import {
9
9
  TmuxSessionContext,
10
10
  } from "./orchestrator-panel-contexts.ts";
11
11
 
12
- function CountBadge({ color, icon, count }: { color: string; icon: string; count: number }) {
12
+ function CountBadge({
13
+ color,
14
+ icon,
15
+ count,
16
+ backgroundColor,
17
+ }: {
18
+ color: string;
19
+ icon: string;
20
+ count: number;
21
+ backgroundColor: string;
22
+ }) {
13
23
  if (count <= 0) return null;
14
24
  return (
15
25
  <text>
16
- <span fg={color}>{icon} {count}</span>
26
+ <span fg={color} bg={backgroundColor}>{icon} {count}</span>
17
27
  </text>
18
28
  );
19
29
  }
@@ -55,18 +65,20 @@ export function Header() {
55
65
 
56
66
  {tmuxSession ? (
57
67
  <box paddingLeft={1} alignItems="center">
58
- <text fg={theme.text}>
59
- <strong>{tmuxSession}</strong>
68
+ <text>
69
+ <span fg={theme.text} bg={theme.backgroundElement}>
70
+ <strong>{tmuxSession}</strong>
71
+ </span>
60
72
  </text>
61
73
  </box>
62
74
  ) : null}
63
75
 
64
76
  <box flexGrow={1} justifyContent="flex-end" flexDirection="row" gap={2}>
65
- <CountBadge color={theme.success} icon={"\u2713"} count={counts.complete} />
66
- <CountBadge color={theme.warning} icon={"\u25CF"} count={counts.running} />
67
- <CountBadge color={theme.info} icon={"?"} count={counts.awaiting_input} />
68
- <CountBadge color={theme.textDim} icon={"\u25CB"} count={counts.pending} />
69
- <CountBadge color={theme.error} icon={"\u2717"} count={counts.error} />
77
+ <CountBadge color={theme.success} backgroundColor={theme.backgroundElement} icon={"\u2713"} count={counts.complete} />
78
+ <CountBadge color={theme.warning} backgroundColor={theme.backgroundElement} icon={"\u25CF"} count={counts.running} />
79
+ <CountBadge color={theme.info} backgroundColor={theme.backgroundElement} icon={"?"} count={counts.awaiting_input} />
80
+ <CountBadge color={theme.textDim} backgroundColor={theme.backgroundElement} icon={"\u25CB"} count={counts.pending} />
81
+ <CountBadge color={theme.error} backgroundColor={theme.backgroundElement} icon={"\u2717"} count={counts.error} />
70
82
  </box>
71
83
  </box>
72
84
  );
@@ -28,12 +28,12 @@ export const NodeCard = React.memo(function NodeCard({
28
28
  if (isRunning) {
29
29
  const t = (Math.sin((pulsePhase / 32) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
30
30
  borderCol = focused
31
- ? lerpColor(theme.warning, "#ffffff", 0.2)
31
+ ? lerpColor(theme.warning, theme.text, 0.2)
32
32
  : lerpColor(theme.border, theme.warning, t);
33
33
  } else if (isAwaitingInput) {
34
34
  const t = (Math.sin((pulsePhase / 32) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
35
35
  borderCol = focused
36
- ? lerpColor(theme.info, "#ffffff", 0.2)
36
+ ? lerpColor(theme.info, theme.text, 0.2)
37
37
  : lerpColor(theme.border, theme.info, t);
38
38
  } else if (isPending) {
39
39
  borderCol = focused ? sc : theme.borderActive;
@@ -41,8 +41,8 @@ export const NodeCard = React.memo(function NodeCard({
41
41
  borderCol = sc;
42
42
  }
43
43
 
44
- // Background: focused nodes get a subtle status-colored tint
45
- const bgCol = focused ? lerpColor(theme.background, sc, 0.12) : "transparent";
44
+ // Keep the card interior aligned with the graph canvas; status color belongs to the border/text.
45
+ const bgCol = theme.background;
46
46
 
47
47
  // Duration computed live from start/end timestamps
48
48
  const durCol = isPending ? theme.textDim : sc;
@@ -68,15 +68,21 @@ export const NodeCard = React.memo(function NodeCard({
68
68
  titleAlignment="center"
69
69
  >
70
70
  <box alignItems="center">
71
- <text fg={durCol}>{duration}</text>
71
+ <text>
72
+ <span fg={durCol} bg={bgCol}>{duration}</span>
73
+ </text>
72
74
  </box>
73
75
  {isAwaitingInput && (
74
76
  <>
75
77
  <box alignItems="center">
76
- <text fg={theme.info}>waiting for response</text>
78
+ <text>
79
+ <span fg={theme.info} bg={bgCol}>waiting for response</span>
80
+ </text>
77
81
  </box>
78
82
  <box alignItems="center">
79
- <text fg={theme.textDim}>↵ enter to respond</text>
83
+ <text>
84
+ <span fg={theme.textDim} bg={bgCol}>↵ enter to respond</span>
85
+ </text>
80
86
  </box>
81
87
  </>
82
88
  )}
@@ -14,20 +14,39 @@ import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-p
14
14
  import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts";
15
15
  import { SessionGraphPanel } from "./session-graph-panel.tsx";
16
16
  import { ErrorBoundary } from "./error-boundary.tsx";
17
+ import {
18
+ requestRendererBackgroundRepaint,
19
+ resetRendererTerminalBackground,
20
+ setRendererBackground,
21
+ } from "./renderer-background.ts";
22
+ import { createTuiDiagnostics, type TuiDiagnostics } from "./tui-diagnostics.ts";
17
23
 
18
24
  export class OrchestratorPanel {
19
25
  private store: PanelStore;
20
26
  private renderer: CliRenderer;
21
27
  private destroyed = false;
28
+ private terminalBackgroundSynced: boolean;
29
+ private diagnostics: TuiDiagnostics | null = null;
30
+ private unsubscribeDiagnostics: (() => void) | null = null;
22
31
 
23
32
  private constructor(
24
33
  renderer: CliRenderer,
25
34
  store: PanelStore,
26
35
  graphTheme: GraphTheme,
27
36
  tmuxSession: string,
37
+ terminalBackgroundSynced: boolean,
28
38
  ) {
29
39
  this.renderer = renderer;
30
40
  this.store = store;
41
+ this.terminalBackgroundSynced = terminalBackgroundSynced;
42
+ this.diagnostics = createTuiDiagnostics({
43
+ renderer,
44
+ graphTheme,
45
+ getSnapshot: () => this.getDiagnosticSnapshot(),
46
+ });
47
+ this.unsubscribeDiagnostics = this.diagnostics
48
+ ? store.subscribe(() => this.diagnostics?.capture("store-update"))
49
+ : null;
31
50
 
32
51
  createRoot(renderer).render(
33
52
  <StoreContext.Provider value={store}>
@@ -56,6 +75,8 @@ export class OrchestratorPanel {
56
75
  </ThemeContext.Provider>
57
76
  </StoreContext.Provider>,
58
77
  );
78
+ requestRendererBackgroundRepaint(this.renderer);
79
+ this.diagnostics?.capture("post-mount");
59
80
  }
60
81
 
61
82
  /**
@@ -69,18 +90,20 @@ export class OrchestratorPanel {
69
90
  exitOnCtrlC: false,
70
91
  exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"],
71
92
  });
72
- return OrchestratorPanel.createWithRenderer(renderer, options);
93
+ return OrchestratorPanel.createWithRenderer(renderer, options, { syncTerminalBackground: true });
73
94
  }
74
95
 
75
96
  /** Create with an externally-provided renderer (e.g. a test renderer). */
76
97
  static createWithRenderer(
77
98
  renderer: CliRenderer,
78
99
  options: PanelOptions,
100
+ { syncTerminalBackground = false }: { syncTerminalBackground?: boolean } = {},
79
101
  ): OrchestratorPanel {
80
102
  const termTheme = resolveTheme(renderer.themeMode);
103
+ setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: syncTerminalBackground });
81
104
  const graphTheme = deriveGraphTheme(termTheme);
82
105
  const store = new PanelStore();
83
- return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession);
106
+ return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession, syncTerminalBackground);
84
107
  }
85
108
 
86
109
  /**
@@ -175,7 +198,15 @@ export class OrchestratorPanel {
175
198
  destroy(): void {
176
199
  if (this.destroyed) return;
177
200
  this.destroyed = true;
201
+ this.unsubscribeDiagnostics?.();
202
+ this.unsubscribeDiagnostics = null;
203
+ this.diagnostics?.capture("destroy");
204
+ this.diagnostics?.dispose();
205
+ this.diagnostics = null;
178
206
  try {
207
+ if (this.terminalBackgroundSynced) {
208
+ resetRendererTerminalBackground(this.renderer);
209
+ }
179
210
  this.renderer.destroy();
180
211
  } catch {}
181
212
  }
@@ -214,4 +245,18 @@ export class OrchestratorPanel {
214
245
  sessions: this.store.sessions,
215
246
  };
216
247
  }
248
+
249
+ private getDiagnosticSnapshot() {
250
+ return {
251
+ workflowName: this.store.workflowName,
252
+ agent: this.store.agent,
253
+ prompt: this.store.prompt,
254
+ fatalError: this.store.fatalError,
255
+ completionReached: this.store.completionReached,
256
+ sessions: this.store.sessions,
257
+ backgroundTaskCount: this.store.backgroundTaskCount,
258
+ viewMode: this.store.viewMode,
259
+ activeAgentId: this.store.activeAgentId,
260
+ };
261
+ }
217
262
  }
@@ -0,0 +1,49 @@
1
+ import type { CliRenderer } from "@opentui/core";
2
+
3
+ export function setRendererBackground(
4
+ renderer: CliRenderer,
5
+ color: string,
6
+ { syncTerminalDefault = false }: { syncTerminalDefault?: boolean } = {},
7
+ ): void {
8
+ renderer.setBackgroundColor(color);
9
+ if (syncTerminalDefault) {
10
+ process.stdout.write(wrapForTmuxIfNeeded(terminalBackgroundColorSequence(color)));
11
+ }
12
+ }
13
+
14
+ export function requestRendererBackgroundRepaint(renderer: CliRenderer): void {
15
+ // OpenTUI 0.1.103+ no longer syncs the renderer background to the terminal
16
+ // default via OSC 11. Force the next frame so blank cells with this background
17
+ // are emitted instead of being skipped as unchanged initial buffer contents.
18
+ Object.assign(renderer, { forceFullRepaintRequested: true });
19
+ renderer.requestRender();
20
+ }
21
+
22
+ export function resetRendererTerminalBackground(renderer: CliRenderer): void {
23
+ if (process.env.TMUX) {
24
+ process.stdout.write(wrapForTmuxIfNeeded("\x1b]111\x07"));
25
+ return;
26
+ }
27
+
28
+ renderer.resetTerminalBgColor();
29
+ }
30
+
31
+ export function terminalBackgroundColorSequence(color: string): string {
32
+ const match = /^#?([0-9a-f]{6})$/i.exec(color);
33
+ if (!match) {
34
+ throw new Error(`Cannot sync terminal background for non-hex color: ${color}`);
35
+ }
36
+
37
+ const hex = match[1]!;
38
+ return `\x1b]11;rgb:${hex.slice(0, 2)}/${hex.slice(2, 4)}/${hex.slice(4, 6)}\x07`;
39
+ }
40
+
41
+ export function wrapForTmuxIfNeeded(sequence: string): string {
42
+ if (!process.env.TMUX) return sequence;
43
+
44
+ let escaped = "";
45
+ for (const char of sequence) {
46
+ escaped += char === "\x1b" ? "\x1b\x1b" : char;
47
+ }
48
+ return `\x1bPtmux;${escaped}\x1b\\`;
49
+ }
@@ -433,9 +433,16 @@ export function SessionGraphPanel() {
433
433
  },
434
434
  }}
435
435
  >
436
- <box width={canvasW} height={canvasH} position="relative">
436
+ <box width={canvasW} height={canvasH} position="relative" backgroundColor={theme.background}>
437
437
  {/* Offset all content by padding to center the graph */}
438
- <box position="absolute" left={padX} top={padY} width={layout.width} height={layout.height}>
438
+ <box
439
+ position="absolute"
440
+ left={padX}
441
+ top={padY}
442
+ width={layout.width}
443
+ height={layout.height}
444
+ backgroundColor={theme.background}
445
+ >
439
446
  {/* Connectors (rendered behind nodes) */}
440
447
  {connectors.map((conn, i) => (
441
448
  <Edge key={`e${i}`} {...conn} />