one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Pure rendering for the subagent panel — Claude Code's below-editor agent tree
3
+ * and its Enter-to-view child transcript. All lines are cut/padded as PLAIN
4
+ * text before painting so ANSI escapes never enter width accounting (pi-tui
5
+ * crashes on an overwide line). Wiring (widget + ctx.ui.custom) lives in
6
+ * index.ts; this file owns layout only, mirroring workflow/viewer.ts.
7
+ */
8
+
9
+ import { cutPlainText as cut, formatDuration, splitCell, splitRow, visibleWidth } from "../lib/tui-render.ts";
10
+ import { hardWrapColumns } from "../lib/text-width.ts";
11
+ import { formatTokenCount } from "./usage.ts";
12
+ import { type LiveRun, type LiveStatus, streamingText, type TranscriptBlock } from "./live-runs.ts";
13
+
14
+ export interface Paint {
15
+ fg(color: string, text: string): string;
16
+ bold(text: string): string;
17
+ }
18
+
19
+ /** A strip row: main is row 0 (synthetic), then the live children as a tree. */
20
+ export interface PanelRow {
21
+ /** undefined = the synthetic `main` row. */
22
+ run?: LiveRun;
23
+ label: string;
24
+ activity: string;
25
+ status: LiveStatus;
26
+ startedAt?: number;
27
+ finishedAt?: number;
28
+ tokens: number;
29
+ /** Tree indent: 0 = spawned by main; 1+ = nested under the row above (CC's `└`). */
30
+ depth: number;
31
+ }
32
+
33
+ /**
34
+ * A finished run stays in the strip this long (its "Completed"/"Failed" beat),
35
+ * then drops out — Claude Code's tree only tracks live work. The run itself
36
+ * stays in the registry: the viewer, /agents, and SendMessage still reach it.
37
+ */
38
+ export const STRIP_LINGER_MS = 5000;
39
+
40
+ /**
41
+ * Build the row list: `main` first, then the live children as a TREE — roots
42
+ * newest-first, each root followed by its nested spawns (a child's own Agent
43
+ * calls) in spawn order, indented one level (CC's `└` rows). `mainBusy` drives
44
+ * main's dot. Running children always show; settled ones — done, failed, or an
45
+ * idle resident whose turn ended — only within STRIP_LINGER_MS of finishing
46
+ * (finishedAt is stamped by finish() AND settle()). A nested run whose parent
47
+ * already left the strip surfaces at root level rather than vanishing.
48
+ *
49
+ * `pinnedId` keeps one settled run in the strip past its linger window — the
50
+ * run whose transcript is currently open. Without it a finished child drops out
51
+ * from under an open viewer, the strip empties, and the panel clears focus,
52
+ * orphaning the overlay (no key then reaches read mode to close it).
53
+ */
54
+ export function buildRows(runs: LiveRun[], mainBusy: boolean, now: number, pinnedId?: string): PanelRow[] {
55
+ const rows: PanelRow[] = [
56
+ {
57
+ label: "main",
58
+ activity: "",
59
+ status: mainBusy ? "running" : "idle",
60
+ tokens: 0,
61
+ depth: 0,
62
+ },
63
+ ];
64
+ // `runs` arrives newest-first; children under a parent read best in spawn order.
65
+ const visible = runs.filter(
66
+ (run) => run.status === "running" || run.taskId === pinnedId || (run.finishedAt ?? now) > now - STRIP_LINGER_MS,
67
+ );
68
+ const visibleIds = new Set(visible.map((run) => run.taskId));
69
+ const byParent = new Map<string, LiveRun[]>();
70
+ const roots: LiveRun[] = [];
71
+ for (const run of visible) {
72
+ if (run.parentTaskId && visibleIds.has(run.parentTaskId)) {
73
+ const siblings = byParent.get(run.parentTaskId) ?? [];
74
+ siblings.unshift(run); // reverse the newest-first order → spawn order
75
+ byParent.set(run.parentTaskId, siblings);
76
+ } else {
77
+ roots.push(run);
78
+ }
79
+ }
80
+ const emit = (run: LiveRun, depth: number) => {
81
+ rows.push({
82
+ run,
83
+ label: run.agentType,
84
+ activity: run.activity,
85
+ status: run.status,
86
+ startedAt: run.startedAt,
87
+ finishedAt: run.finishedAt,
88
+ tokens: run.tokens.output,
89
+ depth,
90
+ });
91
+ for (const child of byParent.get(run.taskId) ?? []) emit(child, depth + 1);
92
+ };
93
+ for (const root of roots) emit(root, 0);
94
+ return rows;
95
+ }
96
+
97
+ /**
98
+ * The agent taskId after `taskId` in strip order, wrapping to the first and
99
+ * skipping the synthetic `main` row; undefined when there are no agent rows.
100
+ * Tab-to-next-agent in read mode uses this against the FULL row list, anchored
101
+ * to the viewed run — not the windowed strip selection, which `anchor()` can
102
+ * reassign once the viewed run scrolls past MAX_STRIP_ROWS (else Tab/x would
103
+ * act on the wrong agent).
104
+ */
105
+ export function nextAgentTaskId(rows: PanelRow[], taskId: string | undefined): string | undefined {
106
+ const ids = rows.filter((row) => row.run).map((row) => row.run!.taskId);
107
+ if (ids.length === 0) return undefined;
108
+ const at = taskId ? ids.indexOf(taskId) : -1;
109
+ return ids[(at + 1) % ids.length];
110
+ }
111
+
112
+ const STATUS_ROW_STYLE: Partial<Record<LiveStatus, string>> = { failed: "error" };
113
+
114
+ /** Terminal-status label + colour for the transcript view's bottom row (running handled separately). */
115
+ const TERMINAL_STATUS: Partial<Record<LiveStatus, [text: string, color: string]>> = {
116
+ failed: ["✗ Failed", "error"],
117
+ stopped: ["■ Stopped", "dim"],
118
+ idle: ["Idle — resident", "dim"],
119
+ };
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // The strip (below-editor, always-on while agents are alive)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export const MAX_STRIP_ROWS = 6;
126
+
127
+ export interface StripInput {
128
+ rows: PanelRow[];
129
+ /** Soft-focused row index; undefined when the strip is not focused. */
130
+ selected?: number;
131
+ /** A transcript view is open → the strip is in "read" mode (scroll hints). */
132
+ viewOpen?: boolean;
133
+ width: number;
134
+ now: number;
135
+ }
136
+
137
+ /**
138
+ * One line per row: `mark agentType activity elapsed · ↓ tokens`. The
139
+ * selected row is bold and marked `❯` (Claude Code's selection caret); others
140
+ * are unmarked. A focus hint precedes the list (only while focused) — its keys
141
+ * reflect the mode: navigating the strip vs. reading an open transcript, where
142
+ * `←` returns to selection so a different agent can be picked. Overflow past
143
+ * MAX_STRIP_ROWS collapses to "+N more".
144
+ */
145
+ export function renderStrip(input: StripInput, paint: Paint): string[] {
146
+ const width = Math.max(20, input.width);
147
+ const out: string[] = [];
148
+ if (input.selected !== undefined) {
149
+ const hint = input.viewOpen
150
+ ? "↑/↓ scroll · PgUp/PgDn page · ← agents · ⇥ next · x stop · esc back"
151
+ : "↑/↓ select · ⏎ view · x stop · ctrl+x ctrl+k stop all · esc back";
152
+ out.push(paint.fg("dim", cut(hint, width)));
153
+ }
154
+ const shown = input.rows.slice(0, MAX_STRIP_ROWS);
155
+ for (const [index, row] of shown.entries()) {
156
+ const selected = input.selected === index;
157
+ const mark = selected ? "❯" : " ";
158
+ const stats = [
159
+ formatDuration(row.startedAt, row.finishedAt, input.now),
160
+ row.tokens ? `↓ ${formatTokenCount(row.tokens)} tokens` : "",
161
+ ]
162
+ .filter(Boolean)
163
+ .join(" · ");
164
+ const elbow = row.depth > 0 ? `${" ".repeat(row.depth - 1)}└ ` : "";
165
+ const left = `${elbow}${mark} ${row.label}${row.activity ? ` ${row.activity}` : ""}`;
166
+ const line = splitCell(left, stats, width);
167
+ if (selected) out.push(paint.fg("accent", paint.bold(line)));
168
+ else if (STATUS_ROW_STYLE[row.status]) out.push(paint.fg(STATUS_ROW_STYLE[row.status]!, line));
169
+ else out.push(row.run ? line : paint.fg("dim", line));
170
+ }
171
+ if (input.rows.length > shown.length) {
172
+ out.push(paint.fg("dim", cut(` +${input.rows.length - shown.length} more — /agents`, width)));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // The child transcript viewer (Enter-to-view)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ const SPINNER_VERBS = ["Working", "Moseying", "Gusting", "Scampering", "Percolating", "Noodling", "Puttering", "Simmering"];
182
+
183
+ /** A stable-ish live verb: cycles by elapsed seconds so it animates without Math.random. */
184
+ export function spinnerVerb(startedAt: number, now: number): string {
185
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
186
+ return SPINNER_VERBS[Math.floor(seconds / 3) % SPINNER_VERBS.length];
187
+ }
188
+
189
+ /**
190
+ * Word-aware wrap for prose (task text, assistant text): breaks at spaces,
191
+ * hard-breaking only words longer than the width; preserves blank lines. Plain
192
+ * text in, plain lines out — painting happens after, per line.
193
+ */
194
+ export function wrapProse(text: string, width: number): string[] {
195
+ const columns = Math.max(1, width);
196
+ const out: string[] = [];
197
+ for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
198
+ if (raw.trim() === "") {
199
+ out.push("");
200
+ continue;
201
+ }
202
+ let line = "";
203
+ for (const word of raw.split(" ")) {
204
+ if (visibleWidth(word) > columns) {
205
+ // A word wider than the screen (path, hash): flush, then hard-break it.
206
+ // The last chunk becomes the running line so later words can join it.
207
+ if (line) out.push(line);
208
+ const chunks = hardWrapColumns(word, columns);
209
+ line = chunks.pop() ?? "";
210
+ out.push(...chunks);
211
+ continue;
212
+ }
213
+ const candidate = line ? `${line} ${word}` : word;
214
+ if (visibleWidth(candidate) > columns) {
215
+ out.push(line);
216
+ line = word;
217
+ } else {
218
+ line = candidate;
219
+ }
220
+ }
221
+ out.push(line);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Renders one prose unit (assistant text) to painted lines at width. `ref`
228
+ * identifies the unit across frames so implementations can cache: a settled
229
+ * block passes the block object, the streaming tail passes a per-run string
230
+ * key. The default is plain `wrapProse`; index.ts injects a pi-tui Markdown
231
+ * renderer (prose.ts) so the child's text matches the main transcript.
232
+ */
233
+ export type ProseRenderer = (ref: object | string, text: string, width: number) => string[];
234
+
235
+ const plainProse: ProseRenderer = (_ref, text, width) => wrapProse(text, width);
236
+
237
+ export interface TranscriptInput {
238
+ run: LiveRun;
239
+ width: number;
240
+ height: number;
241
+ /** Lines scrolled BACK from the tail; 0 follows the stream (Claude Code style). */
242
+ scroll: number;
243
+ now: number;
244
+ prose?: ProseRenderer;
245
+ }
246
+
247
+ export interface TranscriptOutput {
248
+ lines: string[];
249
+ /** Largest useful `scroll` for the current width/height — the caller's clamp. */
250
+ maxScroll: number;
251
+ }
252
+
253
+ /**
254
+ * The full child session view: a synthesized identity header, the transcript
255
+ * (wrapped, scrollable, tail-anchored so streamed text flows in like the main
256
+ * session), and a live spinner (or terminal status) as the bottom row — key
257
+ * hints live in the strip, like Claude Code's agent tree. Exactly `height`
258
+ * painted lines, blank-filled between body and status — as an overlay every
259
+ * row must paint, or the main transcript bleeds through the gap.
260
+ */
261
+ export function renderTranscript(input: TranscriptInput, paint: Paint): TranscriptOutput {
262
+ const { run } = input;
263
+ const width = Math.max(20, input.width);
264
+ const prose = input.prose ?? plainProse;
265
+ const out: string[] = [];
266
+
267
+ // Header: name + label chip, then identity, then a stats line.
268
+ out.push(splitPaint(paint, "accent", run.name, chip(run.label), width, paint.bold));
269
+ const identity = [run.agentType, run.model, run.thinking ? `${run.thinking} effort` : ""].filter(Boolean).join(" · ");
270
+ out.push(paint.fg("dim", cut(identity, width)));
271
+ const stats = [
272
+ `${run.toolCalls} tool call${run.toolCalls === 1 ? "" : "s"}`,
273
+ run.tokens.output ? `↓ ${formatTokenCount(run.tokens.output)} tokens` : "",
274
+ formatDuration(run.startedAt, run.finishedAt, input.now),
275
+ ]
276
+ .filter(Boolean)
277
+ .join(" · ");
278
+ out.push(paint.fg("dim", cut(stats, width)));
279
+ out.push("");
280
+
281
+ // Body: settled blocks then the in-flight assistant text, all wrapped, then
282
+ // windowed ANCHORED TO THE TAIL — scroll counts lines back from the end, so
283
+ // the default view follows streaming like Claude Code.
284
+ const bodyRows = Math.max(0, input.height - out.length - 1);
285
+ const blockLines = run.blocks.flatMap((block) => blockToLines(block, width, paint, prose));
286
+ const partial = streamingText(run.streaming).trimEnd();
287
+ if (partial) for (const line of prose(`stream:${run.taskId}`, partial, width)) blockLines.push(line);
288
+ while (blockLines.length && blockLines.at(-1) === "") blockLines.pop();
289
+ const maxScroll = Math.max(0, blockLines.length - bodyRows);
290
+ const start = maxScroll - Math.max(0, Math.min(input.scroll, maxScroll));
291
+ const window = blockLines.slice(start, start + bodyRows);
292
+ for (const line of window) out.push(line);
293
+ while (out.length < input.height - 1) out.push(""); // fill so the status sits at the bottom edge
294
+
295
+ // Bottom row: spinner (or terminal status) on the left; when the body
296
+ // overflows, a dim "↑/↓ scroll" affordance right-aligned so scrolling is
297
+ // discoverable right where the reader is looking (key hints otherwise live in
298
+ // the strip). splitPaint fuses to the left alone when the hint is "" (body
299
+ // fits) or the row is too narrow.
300
+ const scrollHint = maxScroll > 0 ? "↑/↓ scroll" : "";
301
+ const bottomRow = (text: string, color: string): string => splitPaint(paint, color, text, scrollHint, width, undefined, "dim", false);
302
+ if (run.status === "running") {
303
+ const verb = spinnerVerb(run.startedAt, input.now);
304
+ const spin = `${verb}… (${formatDuration(run.startedAt, undefined, input.now)}${
305
+ run.tokens.output ? ` · ↓ ${formatTokenCount(run.tokens.output)} tokens` : ""
306
+ }${run.thinking ? ` · thinking with ${run.thinking} effort` : ""})`;
307
+ out.push(bottomRow(spin, "accent"));
308
+ } else {
309
+ const [text, color] = TERMINAL_STATUS[run.status] ?? ["✔ Completed", "dim"];
310
+ out.push(bottomRow(text, color));
311
+ }
312
+ // The header alone exceeds a very small height; trim so the "exactly
313
+ // `height` painted lines" contract holds for every caller, not just the
314
+ // one that clamps height to ≥ 8.
315
+ return { lines: out.slice(0, Math.max(1, input.height)), maxScroll };
316
+ }
317
+
318
+ /**
319
+ * A block's painted lines: assistant text goes through the prose renderer,
320
+ * the task prompt stays plain-dim (it is input, not markdown output); both get
321
+ * a trailing blank for paragraph spacing. Call/result stay one line each, a
322
+ * blank after the result so tool groups read like the main transcript.
323
+ */
324
+ function blockToLines(block: TranscriptBlock, width: number, paint: Paint, prose: ProseRenderer): string[] {
325
+ if (block.kind === "task") return [...wrapProse(block.text, width).map((l) => paint.fg("dim", l)), ""];
326
+ if (block.kind === "text") return [...prose(block, block.text, width), ""];
327
+ if (block.kind === "call") return [`${paint.fg("accent", "●")} ${cut(`${block.tool}(${block.text})`, width - 2)}`];
328
+ const body = cut(block.text, width - 4);
329
+ return [` ${paint.fg("dim", "⎿")} ${block.isError ? paint.fg("error", body) : body}`, ""];
330
+ }
331
+
332
+ /** A small inverse-video chip for the label, degrading to `[label]` when narrow. */
333
+ function chip(label: string): string {
334
+ return label ? ` ${label} ` : "";
335
+ }
336
+
337
+ /**
338
+ * A `splitRow` layout with each half painted, dropping the right half (showing
339
+ * the left full-width) when the row fuses/narrows. The right half defaults to
340
+ * accent+bold (the header chip); callers pass `rightColor`/`boldRight` for other
341
+ * styles (e.g. the dim, unbold scroll hint on the transcript's bottom row).
342
+ */
343
+ function splitPaint(
344
+ paint: Paint,
345
+ leftColor: string,
346
+ left: string,
347
+ right: string,
348
+ width: number,
349
+ boldLeft?: (t: string) => string,
350
+ rightColor = "accent",
351
+ boldRight = true,
352
+ ): string {
353
+ const paintLeft = (t: string) => paint.fg(leftColor, boldLeft ? boldLeft(t) : t);
354
+ const row = splitRow(left, right, width);
355
+ if ("fused" in row) return paintLeft(cut(left, width));
356
+ return `${paintLeft(row.left)} ${paint.fg(rightColor, boldRight ? paint.bold(row.right) : row.right)}`;
357
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The persistent panel below the editor: Claude Code's agent tree PLUS its
3
+ * background-shell manager (chip → Background list → shell details, rendered
4
+ * by shell-panel.ts). Mirrors workflow/widget.ts: a component-overload
5
+ * setWidget so rows can right-align against the real width, debounced
6
+ * re-renders, and a 1s ticker to keep elapsed time moving. Also carries both
7
+ * soft-focus states for the down-arrow flow (wired in index.ts): the FIRST ↓
8
+ * lands on the shells chip when shells run, the next ↓ moves into the agent
9
+ * rows.
10
+ */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import type { BackgroundTask } from "../background/registry.ts";
14
+ import type { ShellTaskTracker } from "../lib/shell-tasks.ts";
15
+ import { linesComponent, liveUiCtx, safeThemeBold, safeThemeInverse, safeThemePaint } from "../lib/tui-render.ts";
16
+ import { buildRows, MAX_STRIP_ROWS, nextAgentTaskId, renderStrip, type PanelRow } from "./panel-render.ts";
17
+ import type { LiveRunRegistry } from "./live-runs.ts";
18
+ import {
19
+ anchorShellFocus,
20
+ renderShellSection,
21
+ type ShellFocus,
22
+ shellRows,
23
+ shellSectionVisible,
24
+ } from "./shell-panel.ts";
25
+
26
+ const WIDGET_KEY = "subagents";
27
+ const DEBOUNCE_MS = 200;
28
+
29
+ interface TuiLike {
30
+ getFocusedComponent?(): unknown;
31
+ }
32
+
33
+ export class SubagentWidget {
34
+ private timer: ReturnType<typeof setTimeout> | undefined;
35
+ private ticker: ReturnType<typeof setInterval> | undefined;
36
+ focusIndex: number | undefined;
37
+ /**
38
+ * The selected row's identity — "main" or a run's taskId. Rows shift as
39
+ * runs register (newest-first) and expire (linger), so every read
40
+ * re-anchors focusIndex to this id; Enter/x then act on the run the user
41
+ * actually selected, never on whatever slid into its slot.
42
+ */
43
+ private focusId: string | undefined;
44
+ /** The shell-manager side of the panel (chip → list → details). */
45
+ shellFocus: ShellFocus | undefined;
46
+ /**
47
+ * The taskId whose transcript is open, or undefined when none is (set by
48
+ * index.ts). Drives the strip's scroll hints AND pins that run in the strip
49
+ * so a finished child never drops out from under an open viewer.
50
+ */
51
+ private viewedId: string | undefined;
52
+ tui: TuiLike | undefined;
53
+ editorBaseline: unknown;
54
+ /**
55
+ * Set by dispose(). pi replaces the session (and this extension instance) on
56
+ * /clear, /new and /resume, then invalidates the old ctx: every getter on it
57
+ * throws. Children killed at that shutdown keep emitting registry changes for
58
+ * a moment, so schedule()/render() must become no-ops the instant we are
59
+ * disposed — a timer firing render() against the stale ctx crashed the whole
60
+ * process (SUBAGENT-REVIEW H1).
61
+ */
62
+ private disposed = false;
63
+ private readonly unsubscribes: Array<() => void> = [];
64
+
65
+ constructor(
66
+ private readonly registry: LiveRunRegistry,
67
+ private readonly getCtx: () => ExtensionContext | undefined,
68
+ private readonly shells?: ShellTaskTracker,
69
+ ) {
70
+ this.unsubscribes.push(registry.subscribe(() => this.schedule()));
71
+ if (shells) this.unsubscribes.push(shells.subscribe(() => this.schedule()));
72
+ }
73
+
74
+ /** The ctx to paint on: none once disposed, or once pi has invalidated it (lib/tui-render.ts liveUiCtx). */
75
+ private liveCtx(): ExtensionContext | undefined {
76
+ return this.disposed ? undefined : liveUiCtx(this.getCtx());
77
+ }
78
+
79
+ private rows(): PanelRow[] {
80
+ let mainBusy = false;
81
+ try {
82
+ const ctx = this.getCtx();
83
+ mainBusy = ctx ? !ctx.isIdle() : false;
84
+ } catch {
85
+ mainBusy = false;
86
+ }
87
+ return buildRows(this.registry.list(), mainBusy, Date.now(), this.viewedId);
88
+ }
89
+
90
+ rowCount(): number {
91
+ return Math.min(this.rows().length, MAX_STRIP_ROWS);
92
+ }
93
+
94
+ /** The focused row's run (undefined for the `main` row or when unfocused). */
95
+ selectedRun() {
96
+ if (this.focusIndex === undefined) return undefined;
97
+ const rows = this.rows();
98
+ this.anchor(rows);
99
+ return this.focusIndex === undefined ? undefined : rows[this.focusIndex]?.run;
100
+ }
101
+
102
+ setFocus(index: number | undefined): void {
103
+ this.focusIndex = index;
104
+ this.focusId = index === undefined ? undefined : (this.rows()[index]?.run?.taskId ?? "main");
105
+ this.render();
106
+ }
107
+
108
+ /** index.ts sets the open transcript's taskId (or undefined on close) so the
109
+ * strip switches to scroll hints and pins that run visible. Also parks the
110
+ * strip highlight on the viewed run while it is within the visible window, so
111
+ * the ❯ tracks what is on screen (read-mode actions target the viewed run
112
+ * regardless — see selectedRun's window caveat). */
113
+ setView(taskId: string | undefined): void {
114
+ if (this.viewedId === taskId) return;
115
+ this.viewedId = taskId;
116
+ if (taskId !== undefined) {
117
+ const rows = this.rows();
118
+ const idx = rows.findIndex((r) => r.run?.taskId === taskId);
119
+ if (idx >= 0 && idx < Math.min(rows.length, MAX_STRIP_ROWS)) {
120
+ this.focusIndex = idx;
121
+ this.focusId = taskId;
122
+ }
123
+ }
124
+ this.render();
125
+ }
126
+
127
+ /** The taskId whose transcript is open (undefined when none). Read-mode
128
+ * actions (stop, next-agent) anchor to this, not the windowed selection. */
129
+ viewedTaskId(): string | undefined {
130
+ return this.viewedId;
131
+ }
132
+
133
+ /** The agent to retarget to on Tab, relative to the viewed run in the FULL
134
+ * row list (see nextAgentTaskId — avoids the windowed-selection desync). */
135
+ nextAgentAfter(taskId: string | undefined): string | undefined {
136
+ return nextAgentTaskId(this.rows(), taskId);
137
+ }
138
+
139
+ // --- Shell-manager state (Claude Code's ↓-to-manage flow) ---
140
+
141
+ /** Live shell rows for the list/details views. */
142
+ shellTasks(): BackgroundTask[] {
143
+ return this.shells ? shellRows(this.shells.list(), Date.now()) : [];
144
+ }
145
+
146
+ shellIds(): string[] {
147
+ return this.shellTasks().map((t) => t.id);
148
+ }
149
+
150
+ private runningShellCount(): number {
151
+ return this.shells?.running().length ?? 0;
152
+ }
153
+
154
+ /** Whether ↓ from the editor should land on the chip first — running shells
155
+ * or finished ones still inside the linger window. */
156
+ shellChipAvailable(): boolean {
157
+ return this.shellTasks().length > 0;
158
+ }
159
+
160
+ /** `ids`, when the caller already derived the row order, skips re-deriving it. */
161
+ setShellFocus(focus: ShellFocus | undefined, ids?: string[]): void {
162
+ this.shellFocus = focus && anchorShellFocus(focus, ids ?? this.shellIds());
163
+ this.render();
164
+ }
165
+
166
+ /** The anchored current focus (the selected shell may have lingered out). */
167
+ anchoredShellFocus(ids?: string[]): ShellFocus | undefined {
168
+ if (this.shellFocus) this.shellFocus = anchorShellFocus(this.shellFocus, ids ?? this.shellIds());
169
+ return this.shellFocus;
170
+ }
171
+
172
+ /** Point lookup — no need for the linger-filtered, sorted row build. */
173
+ shellTaskById(id: string): BackgroundTask | undefined {
174
+ return this.shells?.list().find((t) => t.id === id);
175
+ }
176
+
177
+ selectedShellTask(): BackgroundTask | undefined {
178
+ const focus = this.anchoredShellFocus();
179
+ if (!focus || focus.stage === "chip") return undefined;
180
+ return this.shellTaskById(focus.selectedId);
181
+ }
182
+
183
+ private shellVisible(): boolean {
184
+ return shellSectionVisible(this.shellTasks().length, this.shellFocus);
185
+ }
186
+
187
+ /** Lines the shell section currently occupies (for overlay reserve math) —
188
+ * measured off the real renderer so layout changes can never desync it. */
189
+ shellLineCount(): number {
190
+ return this.shellVisible() ? this.shellSectionLines(80).length : 0;
191
+ }
192
+
193
+ /** Re-derive focusIndex from focusId against the given rows (see focusId doc). */
194
+ private anchor(rows: PanelRow[]): void {
195
+ if (this.focusIndex === undefined || this.focusId === undefined) return;
196
+ const shown = Math.min(rows.length, MAX_STRIP_ROWS);
197
+ const idx = this.focusId === "main" ? 0 : rows.findIndex((r) => r.run?.taskId === this.focusId);
198
+ if (idx >= 0 && idx < shown) {
199
+ this.focusIndex = idx;
200
+ } else {
201
+ // The selected run left the strip — fall back to the nearest row.
202
+ this.focusIndex = Math.max(0, Math.min(this.focusIndex, shown - 1));
203
+ this.focusId = rows[this.focusIndex]?.run?.taskId ?? "main";
204
+ }
205
+ }
206
+
207
+ editorFocused(): boolean {
208
+ return Boolean(this.tui && this.editorBaseline && this.tui.getFocusedComponent?.() === this.editorBaseline);
209
+ }
210
+
211
+ /** Whether dispose() has run (for tests and callers that hold a reference). */
212
+ get isDisposed(): boolean {
213
+ return this.disposed;
214
+ }
215
+
216
+ dispose(): void {
217
+ if (this.disposed) return;
218
+ // Order matters: the widget is removed through the ctx BEFORE the flag
219
+ // flips (liveCtx() refuses a disposed panel), and the subscriptions go
220
+ // so no later registry/shell event can re-arm a timer.
221
+ const ctx = this.liveCtx();
222
+ this.disposed = true;
223
+ if (this.timer) clearTimeout(this.timer);
224
+ if (this.ticker) clearInterval(this.ticker);
225
+ this.timer = this.ticker = undefined;
226
+ for (const unsubscribe of this.unsubscribes.splice(0)) unsubscribe();
227
+ try {
228
+ ctx?.ui.setWidget(WIDGET_KEY, undefined);
229
+ } catch {
230
+ // The ctx went stale between the check and the call; nothing to remove.
231
+ }
232
+ }
233
+
234
+ private schedule(): void {
235
+ if (this.disposed || this.timer) return;
236
+ this.timer = setTimeout(() => {
237
+ this.timer = undefined;
238
+ this.render();
239
+ }, DEBOUNCE_MS);
240
+ this.timer.unref?.();
241
+ }
242
+
243
+ /** The shell section's lines; identity paint when only counting. */
244
+ private shellSectionLines(
245
+ width: number,
246
+ paint = { fg: (_c: string, t: string) => t, bold: (t: string) => t, inverse: (t: string) => t },
247
+ ): string[] {
248
+ return renderShellSection(
249
+ {
250
+ rows: this.shellTasks(),
251
+ runningCount: this.runningShellCount(),
252
+ focus: this.shellFocus,
253
+ width,
254
+ now: Date.now(),
255
+ },
256
+ paint,
257
+ );
258
+ }
259
+
260
+ private render(): void {
261
+ const ctx = this.liveCtx();
262
+ if (!ctx) {
263
+ // No live UI to paint (headless, or the session was replaced): stop the
264
+ // ticker too, or it keeps waking a panel nobody can see.
265
+ this.syncTicker(false);
266
+ return;
267
+ }
268
+ const rows = this.rows();
269
+ const shellVisible = this.shellVisible();
270
+ // The chip is time-invariant while every shell runs (count changes arrive
271
+ // as tracker events), so the clock is needed only for the list (linger
272
+ // expiry) and details (runtime, live output) stages — or when finished
273
+ // shells are lingering, whose expiry is what hides the section.
274
+ const shellStage = this.shellFocus?.stage;
275
+ const lingering = shellVisible && this.shellTasks().length > this.runningShellCount();
276
+ this.syncTicker(rows.length > 1 || shellStage === "list" || shellStage === "details" || lingering);
277
+ // Only the synthetic `main` row exists and no shells → nothing to show yet.
278
+ // Never tear down while a transcript view is open (viewedId set): clearing
279
+ // focus here would orphan the overlay with no key path to close it.
280
+ if (rows.length <= 1 && !shellVisible && this.viewedId === undefined) {
281
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
282
+ this.focusIndex = undefined;
283
+ this.focusId = undefined;
284
+ this.shellFocus = undefined;
285
+ return;
286
+ }
287
+ this.anchor(rows);
288
+ if (this.shellFocus) this.shellFocus = anchorShellFocus(this.shellFocus, this.shellIds());
289
+ const selected = this.focusIndex;
290
+ const showStrip = rows.length > 1;
291
+ const now = Date.now();
292
+ ctx.ui.setWidget(
293
+ WIDGET_KEY,
294
+ (tui, theme) => {
295
+ this.tui = tui as TuiLike;
296
+ const focused = (tui as TuiLike).getFocusedComponent?.();
297
+ if (focused && typeof (focused as { getText?: unknown }).getText === "function") {
298
+ this.editorBaseline = focused;
299
+ }
300
+ const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme), inverse: safeThemeInverse(theme) };
301
+ return linesComponent((width) => {
302
+ const lines: string[] = [];
303
+ if (showStrip) lines.push(...renderStrip({ rows, selected, viewOpen: this.viewedId !== undefined, width: width - 1, now }, paint));
304
+ if (shellVisible) {
305
+ if (lines.length) lines.push("");
306
+ lines.push(...this.shellSectionLines(width - 1, paint));
307
+ }
308
+ return lines.map((line) => ` ${line}`);
309
+ });
310
+ },
311
+ { placement: "belowEditor" },
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Tick while any child row is shown or the shell manager is focused —
317
+ * elapsed times move, finished rows expire out of the strip/list (linger
318
+ * windows), and the details view's output box follows the live spool,
319
+ * all without needing another registry event.
320
+ */
321
+ private syncTicker(active: boolean): void {
322
+ if (active && !this.ticker && !this.disposed) {
323
+ this.ticker = setInterval(() => this.render(), 1000);
324
+ this.ticker.unref?.();
325
+ } else if (!active && this.ticker) {
326
+ clearInterval(this.ticker);
327
+ this.ticker = undefined;
328
+ }
329
+ }
330
+ }