one-code-extension 0.1.4 → 0.3.1
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/LICENSE +20 -0
- package/README.md +222 -177
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +128 -0
- package/docs/guide/configuration.md +173 -0
- package/docs/guide/differences-from-claude-code.md +152 -0
- package/docs/guide/doctor.md +88 -0
- package/docs/guide/hooks.md +111 -0
- package/docs/guide/installation.md +126 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
- package/docs/guide/providers-and-models.md +196 -0
- package/docs/guide/reference.md +226 -0
- package/docs/guide/sessions-and-context.md +192 -0
- package/docs/guide/skills-plugins-and-mcp.md +178 -0
- package/docs/guide/subagents-and-workflows.md +195 -0
- package/docs/guide/tasks-and-background-work.md +113 -0
- package/docs/guide/terminal-interface.md +157 -0
- package/docs/guide/tools.md +177 -0
- package/docs/guide/troubleshooting.md +177 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- 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
|
+
}
|