pi-crew 0.9.68 → 0.10.2
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/CHANGELOG.md +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crew-editor.ts — the inline agent panel's keyboard half.
|
|
3
|
+
*
|
|
4
|
+
* A `CustomEditor` wrapper (pi-subtask's `SubtaskEditor` pattern): pressing
|
|
5
|
+
* `↓` on an empty prompt moves the cursor into the agent rows rendered by the
|
|
6
|
+
* crew widget; `↑`/`↓` navigate, `enter` opens the agent's live transcript
|
|
7
|
+
* pane, `x` cancels/dismisses, `escape` returns to typing.
|
|
8
|
+
*
|
|
9
|
+
* While an agent is viewed, typed text goes to the viewed agent (Claude
|
|
10
|
+
* Code's `@name` convention — the editor border is relabeled), pageUp/
|
|
11
|
+
* pageDown scroll, and `enter` steers. Normally the FULL-SCREEN overlay
|
|
12
|
+
* (agent-view-overlay.ts) owns the keyboard while open; this branch is the
|
|
13
|
+
* fallback for when the overlay could not spawn (e.g. a stale ctx between
|
|
14
|
+
* keypress and open), so the user is never stuck viewing an agent neither
|
|
15
|
+
* surface controls. Neither path is a session switch: the main conversation
|
|
16
|
+
* is never torn down to look at an agent.
|
|
17
|
+
*
|
|
18
|
+
* Every unhandled key falls through to `super.handleInput`, so the user is
|
|
19
|
+
* never trapped in a mode. All state lives in panel-store (shared with the
|
|
20
|
+
* widget renderer); this class only translates keys into state changes and
|
|
21
|
+
* host calls.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { type EditorTheme, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
27
|
+
|
|
28
|
+
import type { PanelKeys, PanelTarget } from "./panel-selection.ts";
|
|
29
|
+
import { dispatchPanelKey } from "./panel-selection.ts";
|
|
30
|
+
import { getPanelSelection, getViewedAgent, panelRows, setPanelSelection } from "./panel-store.ts";
|
|
31
|
+
|
|
32
|
+
export const AGENT_LABEL_MAX = 24;
|
|
33
|
+
|
|
34
|
+
export interface CrewEditorOptions {
|
|
35
|
+
/** Open the transcript pane on the given agent. */
|
|
36
|
+
onOpenPane: (target: PanelTarget) => void;
|
|
37
|
+
/** Close the pane and return to the main conversation. */
|
|
38
|
+
onClosePane: () => void;
|
|
39
|
+
/** Scroll the open pane by ±wrapped lines. */
|
|
40
|
+
onScrollPane: (delta: number) => void;
|
|
41
|
+
/** Steer the viewed agent with the typed message. */
|
|
42
|
+
onSteer: (target: PanelTarget, message: string) => void;
|
|
43
|
+
/** `x`: cancel a running agent's run, or dismiss a finished one. */
|
|
44
|
+
onAct: (target: PanelTarget, finished: boolean) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class CrewInlineEditor extends CustomEditor {
|
|
48
|
+
private readonly options: CrewEditorOptions;
|
|
49
|
+
|
|
50
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options: CrewEditorOptions) {
|
|
51
|
+
super(tui, theme, keybindings);
|
|
52
|
+
this.options = options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private panelKeys(data: string): PanelKeys {
|
|
56
|
+
return {
|
|
57
|
+
up: matchesKey(data, "up"),
|
|
58
|
+
down: matchesKey(data, "down"),
|
|
59
|
+
enter: matchesKey(data, "return"),
|
|
60
|
+
escape: matchesKey(data, "escape"),
|
|
61
|
+
act: matchesKey(data, "x"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply a dispatch result. `closePaneOnMain` is true while the pane is open:
|
|
67
|
+
* `enter` on the main row then closes the pane instead of doing nothing.
|
|
68
|
+
*/
|
|
69
|
+
private applyDispatch(data: string, rows: ReturnType<typeof panelRows>, keys: PanelKeys, closePaneOnMain: boolean): void {
|
|
70
|
+
const selection = getPanelSelection();
|
|
71
|
+
const result = dispatchPanelKey(keys, rows, selection, { holdAtMain: closePaneOnMain });
|
|
72
|
+
switch (result.action.kind) {
|
|
73
|
+
case "none": {
|
|
74
|
+
// Not a navigation key: hand the editor the exact key and clear
|
|
75
|
+
// the cursor so typing proceeds normally (pi-subtask §2.5).
|
|
76
|
+
setPanelSelection(null);
|
|
77
|
+
super.handleInput(data);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
case "consumed": {
|
|
81
|
+
setPanelSelection(result.selection);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
case "open": {
|
|
85
|
+
setPanelSelection(null);
|
|
86
|
+
const target = result.action.target;
|
|
87
|
+
if (target) this.options.onOpenPane(target);
|
|
88
|
+
else if (closePaneOnMain) this.options.onClosePane();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "act": {
|
|
92
|
+
setPanelSelection(result.selection);
|
|
93
|
+
const target = result.action.target;
|
|
94
|
+
const row = rows.find((r) => r.runId === target.runId && r.taskId === target.taskId);
|
|
95
|
+
this.options.onAct(target, row?.finished ?? false);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleInput(data: string): void {
|
|
102
|
+
const rows = panelRows();
|
|
103
|
+
const viewed = getViewedAgent();
|
|
104
|
+
|
|
105
|
+
// ── Pane open: typing goes to the viewed agent ─────────────────────
|
|
106
|
+
// Navigation still works inside the pane: move the cursor over the
|
|
107
|
+
// rows and `enter` switches the pane to another agent (or closes it
|
|
108
|
+
// on the main row) — in place, no session involved.
|
|
109
|
+
if (viewed) {
|
|
110
|
+
if (getPanelSelection() !== null) {
|
|
111
|
+
this.applyDispatch(data, rows, this.panelKeys(data), true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (matchesKey(data, "down") && this.getText() === "" && rows.length > 0) {
|
|
115
|
+
setPanelSelection("main");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (matchesKey(data, "escape")) {
|
|
119
|
+
this.options.onClosePane();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (matchesKey(data, "pageUp")) {
|
|
123
|
+
this.options.onScrollPane(10);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (matchesKey(data, "pageDown")) {
|
|
127
|
+
this.options.onScrollPane(-10);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (matchesKey(data, "return")) {
|
|
131
|
+
const text = (this.getExpandedText?.() ?? this.getText()).trim();
|
|
132
|
+
if (!text) {
|
|
133
|
+
// Empty enter: hand it back to the editor rather than
|
|
134
|
+
// swallowing the key (keeps the "never stick" contract).
|
|
135
|
+
super.handleInput(data);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (text.startsWith("/")) {
|
|
139
|
+
// Slash commands still act on the main session's command
|
|
140
|
+
// executor, like Claude Code's transcript view.
|
|
141
|
+
super.handleInput(data);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Shift+Enter (newline) never reaches here: matchesKey
|
|
145
|
+
// distinguishes the shifted sequence from plain return.
|
|
146
|
+
this.setText("");
|
|
147
|
+
this.options.onSteer(viewed, text);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
super.handleInput(data);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Idle: `↓` on an empty prompt enters the panel ──────────────────
|
|
155
|
+
if (getPanelSelection() === null) {
|
|
156
|
+
if (matchesKey(data, "down") && this.getText() === "" && rows.length > 0) {
|
|
157
|
+
// dispatch enters at the MAIN row; the widget renders that row
|
|
158
|
+
// with its own ❯ marker so the very first press is visible
|
|
159
|
+
// (pi-subtask's selectRow(rows, 0)).
|
|
160
|
+
const result = dispatchPanelKey(this.panelKeys(data), rows, null);
|
|
161
|
+
setPanelSelection(result.selection);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
super.handleInput(data);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Navigating: consume or fall through ────────────────────────────
|
|
169
|
+
this.applyDispatch(data, rows, this.panelKeys(data), false);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Relabel the editor's top border with the viewed agent, so it is
|
|
174
|
+
* unambiguous where typed text lands — pi-subtask's `@name` marker.
|
|
175
|
+
*/
|
|
176
|
+
render(width: number): string[] {
|
|
177
|
+
const lines = super.render(width);
|
|
178
|
+
const viewed = getViewedAgent();
|
|
179
|
+
if (!viewed) return lines;
|
|
180
|
+
const rows = panelRows();
|
|
181
|
+
const row = rows.find((r) => r.runId === viewed.runId && r.taskId === viewed.taskId);
|
|
182
|
+
const name = row?.name ?? viewed.taskId.slice(-AGENT_LABEL_MAX);
|
|
183
|
+
if (name && lines.length > 0) {
|
|
184
|
+
const label = ` @${truncateToWidth(name.replace(/\s+/g, " "), AGENT_LABEL_MAX)} `;
|
|
185
|
+
const labelWidth = visibleWidth(label);
|
|
186
|
+
if (visibleWidth(lines[0]) >= labelWidth + 4) {
|
|
187
|
+
lines[0] = truncateToWidth(lines[0], width - labelWidth - 2, "") + label + "──";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inline-panel/index.ts — install/uninstall of the inline agent panel.
|
|
3
|
+
*
|
|
4
|
+
* Owns the two half-mounted surfaces:
|
|
5
|
+
* - the `CrewInlineEditor` wrapper (installed via `setEditorComponent` when no
|
|
6
|
+
* other extension owns the editor), which translates ↓/↑/enter/x/escape
|
|
7
|
+
* into panel-store changes and host calls;
|
|
8
|
+
* - the FULL-SCREEN agent view overlay (`ctx.ui.custom` with `overlay:
|
|
9
|
+
* true`, width "100%"), the LIVE transcript of the viewed agent.
|
|
10
|
+
*
|
|
11
|
+
* Entering an agent row takes over the whole terminal with that agent's live
|
|
12
|
+
* transcript — a separate view, not content appended under the main session.
|
|
13
|
+
* The overlay tails the agent's on-disk event log (events.jsonl, appended in
|
|
14
|
+
* real time by the running child pi worker) and renders through pi's own
|
|
15
|
+
* transcript components. The main session is never switched, resumed, or torn
|
|
16
|
+
* down to look at an agent, so viewing can never kill a run or strand the
|
|
17
|
+
* editor. (The previous design — copy the worker's session file and
|
|
18
|
+
* `switchSession` to it, re-switching every few seconds — cancelled live runs
|
|
19
|
+
* on teardown, froze at the copy timestamp, and crashed on stale extension
|
|
20
|
+
* ctxs; see the "fix(view)" chain in git history.)
|
|
21
|
+
*
|
|
22
|
+
* All heavy team-tool interaction (steer, cancel) is lazy-imported so this
|
|
23
|
+
* module never pulls the runtime chain at startup (AGENTS.md lazy boundary).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
|
|
28
|
+
import type { CrewUiConfig } from "../../config/types.ts";
|
|
29
|
+
import { isToolError, type PiTeamsToolResult, textFromToolResult } from "../../extension/tool-result.ts";
|
|
30
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
31
|
+
import { requestRender } from "../pi-ui-compat.ts";
|
|
32
|
+
import type { CrewAgentPane } from "./agent-pane.ts";
|
|
33
|
+
import { resetAllAgentTranscriptCursors } from "./agent-transcript.ts";
|
|
34
|
+
import { CrewAgentOverlay } from "./agent-view-overlay.ts";
|
|
35
|
+
import { CrewInlineEditor } from "./crew-editor.ts";
|
|
36
|
+
import type { PanelTarget } from "./panel-selection.ts";
|
|
37
|
+
import { resetPanelStore, setViewedAgent } from "./panel-store.ts";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Live repaint cadence while the view is open. The pane re-reads the agent's
|
|
41
|
+
* event log during render() (throttled internally), so a periodic
|
|
42
|
+
* requestRender keeps it following the worker even when pi's own repaints
|
|
43
|
+
* are idle (no spinner, no typing). pi-subtask drives this from child RPC
|
|
44
|
+
* events; our workers write to disk, so the pane is disk-tailed instead.
|
|
45
|
+
*/
|
|
46
|
+
const PANE_LIVE_TICK_MS = 700;
|
|
47
|
+
|
|
48
|
+
let livePane: CrewAgentPane | undefined;
|
|
49
|
+
let liveOverlay: CrewAgentOverlay | undefined;
|
|
50
|
+
let paneTickTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
|
+
let editorInstalled = false;
|
|
52
|
+
/** Guards the one-time per-process event hooks. */
|
|
53
|
+
let hooksRegistered = false;
|
|
54
|
+
|
|
55
|
+
function startPaneTicker(): void {
|
|
56
|
+
if (paneTickTimer) return;
|
|
57
|
+
paneTickTimer = setInterval(() => livePane?.requestRender(), PANE_LIVE_TICK_MS);
|
|
58
|
+
paneTickTimer.unref?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stopPaneTicker(): void {
|
|
62
|
+
if (paneTickTimer) {
|
|
63
|
+
clearInterval(paneTickTimer);
|
|
64
|
+
paneTickTimer = undefined;
|
|
65
|
+
}
|
|
66
|
+
livePane = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runTeamTool(params: Record<string, unknown>, ctx: ExtensionContext): Promise<PiTeamsToolResult> {
|
|
70
|
+
// LAZY: team-tool.ts pulls in the entire runtime chain (same boundary as
|
|
71
|
+
// run-action-dispatcher.ts).
|
|
72
|
+
// LAZY: (marker on the import line's previous line, per check-lazy-imports)
|
|
73
|
+
const { handleTeamTool } = await import("../../extension/team-tool.ts");
|
|
74
|
+
return handleTeamTool(params as never, ctx);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function notifyResult(ctx: ExtensionContext, result: PiTeamsToolResult): void {
|
|
78
|
+
const text = textFromToolResult(result);
|
|
79
|
+
ctx.ui.notify(isToolError(result) ? `panel: ${text}` : text, isToolError(result) ? "error" : "info");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Open the viewed agent's LIVE transcript as a FULL-SCREEN overlay (or, when
|
|
84
|
+
* one is already open, just re-target it — the pane follows the panel store).
|
|
85
|
+
* Pure overlay wiring — no session is touched, so there is nothing to settle,
|
|
86
|
+
* detach, or guard: the view works in every session state, for foreground and
|
|
87
|
+
* async runs alike, and closing it never affects the run.
|
|
88
|
+
*/
|
|
89
|
+
function openPane(ctx: ExtensionContext, target: PanelTarget): void {
|
|
90
|
+
setViewedAgent(target);
|
|
91
|
+
if (!liveOverlay) {
|
|
92
|
+
try {
|
|
93
|
+
void ctx.ui
|
|
94
|
+
.custom(
|
|
95
|
+
(tui, theme, _keybindings, done) => {
|
|
96
|
+
const overlay = new CrewAgentOverlay(tui as never, theme as never, ctx.cwd, {
|
|
97
|
+
close: () => {
|
|
98
|
+
liveOverlay = undefined;
|
|
99
|
+
livePane = undefined;
|
|
100
|
+
setViewedAgent(undefined);
|
|
101
|
+
stopPaneTicker();
|
|
102
|
+
try {
|
|
103
|
+
done(undefined);
|
|
104
|
+
} catch {
|
|
105
|
+
/* already closed by the host */
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
steer: (steerTarget, message) => void steerAgent(ctx, steerTarget, message),
|
|
109
|
+
});
|
|
110
|
+
liveOverlay = overlay;
|
|
111
|
+
livePane = overlay.pane;
|
|
112
|
+
return overlay as never;
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
overlay: true,
|
|
116
|
+
overlayOptions: { width: "100%", margin: 0, maxHeight: "100%", anchor: "top-left" },
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
// Factory throw or the host tearing the overlay down: drop the
|
|
121
|
+
// view state so the dock does not point at a view that no
|
|
122
|
+
// longer exists.
|
|
123
|
+
liveOverlay = undefined;
|
|
124
|
+
livePane = undefined;
|
|
125
|
+
setViewedAgent(undefined);
|
|
126
|
+
stopPaneTicker();
|
|
127
|
+
logInternalError("view.openOverlay", error, target.taskId);
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// A stale ctx (session replaced between keypress and here) drops the
|
|
131
|
+
// overlay spawn; the fallback editor path still closes cleanly.
|
|
132
|
+
logInternalError("view.openPane", error, target.taskId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
startPaneTicker();
|
|
136
|
+
try {
|
|
137
|
+
requestRender(ctx);
|
|
138
|
+
} catch {
|
|
139
|
+
/* stale ctx — nothing to repaint */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Close the view and return to the main conversation. Never touches the run. */
|
|
144
|
+
function closePane(_ctx: ExtensionContext): void {
|
|
145
|
+
if (liveOverlay) {
|
|
146
|
+
// The overlay owns its teardown (state reset + host `done()`); the
|
|
147
|
+
// requestClose path is idempotent.
|
|
148
|
+
liveOverlay.requestClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
setViewedAgent(undefined);
|
|
152
|
+
stopPaneTicker();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function steerAgent(ctx: ExtensionContext, target: PanelTarget, message: string): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
notifyResult(ctx, await runTeamTool({ action: "steer", runId: target.runId, taskId: target.taskId, message }, ctx));
|
|
158
|
+
// Delivery is at the child's next turn boundary, not mid-tool-call;
|
|
159
|
+
// say so explicitly so the user does not read silence as a dropped message.
|
|
160
|
+
ctx.ui.notify("Will be delivered at the worker's next turn boundary.", "info");
|
|
161
|
+
} catch (error) {
|
|
162
|
+
ctx.ui.notify(`panel: steer failed — ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function actOnAgent(ctx: ExtensionContext, target: PanelTarget, finished: boolean): Promise<void> {
|
|
167
|
+
if (finished) {
|
|
168
|
+
// Finished rows age out of the linger window on their own. `x` here
|
|
169
|
+
// only returns focus to typing; resume/inspect stays in the dashboard
|
|
170
|
+
// so a single keystroke never mutates anything.
|
|
171
|
+
ctx.ui.notify("Agent is finished — use /team-dashboard to inspect or resume.", "info");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Cancel is run-level in the team tool, so a single-keystroke `x` must not
|
|
175
|
+
// silently destroy a whole run: confirm first.
|
|
176
|
+
const confirmed = await ctx.ui.confirm(
|
|
177
|
+
"Cancel entire run?",
|
|
178
|
+
`This cancels run ${target.runId} and all its workers. Ongoing work stops; resume re-queues what was left.`,
|
|
179
|
+
);
|
|
180
|
+
if (!confirmed) {
|
|
181
|
+
ctx.ui.notify("Cancel aborted.", "info");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
notifyResult(ctx, await runTeamTool({ action: "cancel", runId: target.runId }, ctx));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
ctx.ui.notify(`panel: cancel failed — ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Agent view commands ────────────────────────────────────────────────
|
|
192
|
+
//
|
|
193
|
+
// `/crew-view <runId> <taskId>` and `/crew-back` are thin aliases for the
|
|
194
|
+
// pane wiring above (open / close). They never switch sessions — the dock's
|
|
195
|
+
// enter key takes the same path.
|
|
196
|
+
|
|
197
|
+
async function handleCrewViewCommand(args: string, ctx: ExtensionContext): Promise<void> {
|
|
198
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
199
|
+
if (tokens.length < 2) {
|
|
200
|
+
ctx.ui.notify("Usage: /crew-view <runId> <taskId>", "error");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const [runId, taskId] = tokens;
|
|
204
|
+
// LAZY: state-store is runtime state, not a UI dependency.
|
|
205
|
+
// LAZY: (marker on the import line's previous line, per check-lazy-imports)
|
|
206
|
+
const { loadRunManifestById } = await import("../../state/stores/state-store.ts");
|
|
207
|
+
if (!loadRunManifestById(ctx.cwd, runId)) {
|
|
208
|
+
ctx.ui.notify(`No run ${runId} in this project.`, "error");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
openPane(ctx, { runId, taskId });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleCrewBackCommand(_args: string, ctx: ExtensionContext): Promise<void> {
|
|
215
|
+
closePane(ctx);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Install the panel for a session. Call from session_start AFTER the widget
|
|
220
|
+
* has been registered, with the already-loaded UI config.
|
|
221
|
+
*
|
|
222
|
+
* Yields to any other extension that owns the editor component (pi-subtask
|
|
223
|
+
* rule §2.6): without the editor wrapper there is no keyboard access, so the
|
|
224
|
+
* panel stays display-only and /team-dashboard remains the full path.
|
|
225
|
+
*/
|
|
226
|
+
export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiConfig?: CrewUiConfig): void {
|
|
227
|
+
if (!ctx.hasUI) return;
|
|
228
|
+
|
|
229
|
+
const enabled = uiConfig?.inlinePanel !== false;
|
|
230
|
+
try {
|
|
231
|
+
if (enabled && !editorInstalled && !ctx.ui.getEditorComponent()) {
|
|
232
|
+
ctx.ui.setEditorComponent((tui, theme, kb) => {
|
|
233
|
+
// Fresh instance per session; options close over the current ctx.
|
|
234
|
+
return new CrewInlineEditor(tui, theme, kb, {
|
|
235
|
+
onOpenPane: (target) => openPane(ctx, target),
|
|
236
|
+
onClosePane: () => closePane(ctx),
|
|
237
|
+
onScrollPane: (delta) => livePane?.scrollBy(delta),
|
|
238
|
+
onSteer: (target, message) => void steerAgent(ctx, target, message),
|
|
239
|
+
onAct: (target, finished) => void actOnAgent(ctx, target, finished),
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
editorInstalled = true;
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
/* editor context can be transient across session replacement */
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Commands must be re-registered for EVERY session: pi rebuilds the
|
|
249
|
+
// extension command table on session replacement. registerCommand is
|
|
250
|
+
// idempotent on the current session's runner.
|
|
251
|
+
pi.registerCommand("crew-view", {
|
|
252
|
+
description: "Open an agent's live full-screen transcript view (usage: crew-view <runId> <taskId>)",
|
|
253
|
+
handler: handleCrewViewCommand,
|
|
254
|
+
});
|
|
255
|
+
pi.registerCommand("crew-back", {
|
|
256
|
+
description: "Close the agent transcript view and return to the main conversation",
|
|
257
|
+
handler: handleCrewBackCommand,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!hooksRegistered) {
|
|
261
|
+
hooksRegistered = true;
|
|
262
|
+
pi.on("session_shutdown", () => {
|
|
263
|
+
stopPaneTicker();
|
|
264
|
+
livePane = undefined;
|
|
265
|
+
liveOverlay = undefined;
|
|
266
|
+
resetPanelStore();
|
|
267
|
+
resetAllAgentTranscriptCursors();
|
|
268
|
+
});
|
|
269
|
+
pi.on("session_start", () => {
|
|
270
|
+
// pi may have replaced or dropped its editor between sessions; the
|
|
271
|
+
// "installed" flag is reset so the next install re-registers the
|
|
272
|
+
// factory. The open overlay outlives the session swap (it belongs
|
|
273
|
+
// to the interactive-mode UI, not the session's ctx) — keep it, but
|
|
274
|
+
// stop ticking until it is re-targeted.
|
|
275
|
+
editorInstalled = false;
|
|
276
|
+
stopPaneTicker();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Test hook: force the next install to re-attempt the editor. */
|
|
282
|
+
export function __resetInlinePanelForTest(): void {
|
|
283
|
+
editorInstalled = false;
|
|
284
|
+
liveOverlay = undefined;
|
|
285
|
+
stopPaneTicker();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Test seam for openPane (the panel wires it as the dock's Enter action). */
|
|
289
|
+
/** Test seam for closePane. */
|
|
290
|
+
export { closePane as __test__closePane, openPane as __test__openPane };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel-rows.ts — project the widget's run list into navigable panel rows.
|
|
3
|
+
*
|
|
4
|
+
* The order here MUST match what the widget paints, otherwise the cursor index
|
|
5
|
+
* drifts from the visible rows and `enter`/`x` act on the wrong agent. Both
|
|
6
|
+
* sides therefore call the same `orderWidgetAgents` helper; this module only
|
|
7
|
+
* flattens the per-run sections into one list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isFinishedRunStatus } from "../../runtime/process-status.ts";
|
|
11
|
+
import { orderWidgetAgents } from "../widget/widget-renderer.ts";
|
|
12
|
+
import type { WidgetRun } from "../widget/widget-types.ts";
|
|
13
|
+
import type { PanelRow } from "./panel-selection.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Flatten runs → rows in paint order: per run, active agents (running > queued >
|
|
17
|
+
* waiting) followed by the finished agents still inside their linger window.
|
|
18
|
+
*/
|
|
19
|
+
export function panelRowsFromRuns(runs: readonly WidgetRun[], now = Date.now()): PanelRow[] {
|
|
20
|
+
const rows: PanelRow[] = [];
|
|
21
|
+
for (const entry of runs) {
|
|
22
|
+
const { active, finished } = orderWidgetAgents(entry, now);
|
|
23
|
+
for (const agent of active) {
|
|
24
|
+
rows.push({ runId: entry.run.runId, taskId: agent.taskId, finished: false, name: agent.agent });
|
|
25
|
+
}
|
|
26
|
+
for (const agent of finished) {
|
|
27
|
+
rows.push({ runId: entry.run.runId, taskId: agent.taskId, finished: true, name: agent.agent });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when the row's run has reached a terminal status (nothing left to cancel). */
|
|
34
|
+
export function isRunFinished(runs: readonly WidgetRun[], runId: string): boolean {
|
|
35
|
+
const entry = runs.find((item) => item.run.runId === runId);
|
|
36
|
+
return entry ? isFinishedRunStatus(entry.run.status) : true;
|
|
37
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel-selection.ts — pure cursor state machine for the inline agent panel.
|
|
3
|
+
*
|
|
4
|
+
* No I/O, no TUI types: the editor wrapper feeds it a key and the current row
|
|
5
|
+
* list, and gets back an action to perform. That keeps the whole navigation
|
|
6
|
+
* contract unit-testable against a synthetic row list.
|
|
7
|
+
*
|
|
8
|
+
* Row 0 is always `main` (the conversation itself); agents occupy 1..n.
|
|
9
|
+
*
|
|
10
|
+
* The cursor is stored as an **identity**, not an index. Rows reorder while the
|
|
11
|
+
* user is navigating — a cancelled agent sinks from the active section into the
|
|
12
|
+
* finished one — and an index cursor would silently retarget whichever agent
|
|
13
|
+
* took the vacated row. Storing the taskId means a second keystroke acts on the
|
|
14
|
+
* same agent the first one did.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A concrete agent the panel can act on. */
|
|
18
|
+
export interface PanelTarget {
|
|
19
|
+
runId: string;
|
|
20
|
+
taskId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One navigable agent row, as projected from the widget's run list. */
|
|
24
|
+
export interface PanelRow extends PanelTarget {
|
|
25
|
+
/** Terminal statuses are dismissed by `x`; live ones are cancelled. */
|
|
26
|
+
finished: boolean;
|
|
27
|
+
/** Agent name, used for the editor's `@<name>` label while its pane is open. */
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `null` means the editor owns the cursor (normal typing). `"main"` is the
|
|
33
|
+
* conversation row. Anything else is an agent.
|
|
34
|
+
*/
|
|
35
|
+
export type PanelSelection = "main" | PanelTarget | null;
|
|
36
|
+
|
|
37
|
+
export type PanelAction =
|
|
38
|
+
/** Not a navigation key — the caller must forward it to the editor. */
|
|
39
|
+
| { kind: "none" }
|
|
40
|
+
/** Cursor moved or selection cleared; repaint and swallow the key. */
|
|
41
|
+
| { kind: "consumed" }
|
|
42
|
+
/** `enter`: open the target's pane, or return to `main` when undefined. */
|
|
43
|
+
| { kind: "open"; target: PanelTarget | undefined }
|
|
44
|
+
/** `x`: cancel a running agent, or dismiss a finished one. */
|
|
45
|
+
| { kind: "act"; target: PanelTarget };
|
|
46
|
+
|
|
47
|
+
export interface PanelKeys {
|
|
48
|
+
up: boolean;
|
|
49
|
+
down: boolean;
|
|
50
|
+
enter: boolean;
|
|
51
|
+
escape: boolean;
|
|
52
|
+
act: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DispatchOptions {
|
|
56
|
+
/**
|
|
57
|
+
* When true, `up` at the main row keeps the cursor there instead of handing
|
|
58
|
+
* focus back to the editor. Used while a transcript pane is open, so one
|
|
59
|
+
* `down` + `enter` is always a reliable way back to the conversation.
|
|
60
|
+
*/
|
|
61
|
+
holdAtMain?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DispatchResult {
|
|
65
|
+
action: PanelAction;
|
|
66
|
+
selection: PanelSelection;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameTarget(a: PanelTarget, b: PanelTarget): boolean {
|
|
70
|
+
return a.runId === b.runId && a.taskId === b.taskId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when `selection` points at a concrete agent (not `main`/editor). */
|
|
74
|
+
export function isAgentSelection(selection: PanelSelection): selection is PanelTarget {
|
|
75
|
+
return selection !== null && selection !== "main";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Numeric cursor position for the current rows, or `null` when the editor owns
|
|
80
|
+
* the cursor. A selection whose agent has disappeared (dismissed, aged out of
|
|
81
|
+
* the linger window) resolves to the main row rather than to a stranger.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveIndex(rows: readonly PanelRow[], selection: PanelSelection): number | null {
|
|
84
|
+
if (selection === null) return null;
|
|
85
|
+
if (selection === "main") return 0;
|
|
86
|
+
const found = rows.findIndex((row) => sameTarget(row, selection));
|
|
87
|
+
return found >= 0 ? found + 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Selection for a numeric position, clamped into range. Index 0 is `main`. */
|
|
91
|
+
export function selectionAtIndex(rows: readonly PanelRow[], index: number): PanelSelection {
|
|
92
|
+
const clamped = Math.max(0, Math.min(rows.length, index));
|
|
93
|
+
if (clamped === 0) return "main";
|
|
94
|
+
const row = rows[clamped - 1];
|
|
95
|
+
return row ? { runId: row.runId, taskId: row.taskId } : "main";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The row a selection currently points at, if it is still present. */
|
|
99
|
+
export function rowFor(rows: readonly PanelRow[], selection: PanelSelection): PanelRow | undefined {
|
|
100
|
+
if (!isAgentSelection(selection)) return undefined;
|
|
101
|
+
return rows.find((row) => sameTarget(row, selection));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Apply one keypress.
|
|
106
|
+
*
|
|
107
|
+
* Any key that is not a navigation key returns `{ kind: "none" }` with the
|
|
108
|
+
* selection cleared, so the caller can forward it to the editor and the user is
|
|
109
|
+
* never trapped in a mode.
|
|
110
|
+
*/
|
|
111
|
+
export function dispatchPanelKey(
|
|
112
|
+
keys: PanelKeys,
|
|
113
|
+
rows: readonly PanelRow[],
|
|
114
|
+
selection: PanelSelection,
|
|
115
|
+
options: DispatchOptions = {},
|
|
116
|
+
): DispatchResult {
|
|
117
|
+
// Editor owns the cursor: the only panel key that makes sense is `down`,
|
|
118
|
+
// entering at the main row (pi-subtask: selectRow(rows, 0)). The main row
|
|
119
|
+
// is a real rendered row with its own ❯ marker, so this first press is
|
|
120
|
+
// visible immediately — no dead-key feeling. Everything else falls
|
|
121
|
+
// through untouched.
|
|
122
|
+
if (selection === null) {
|
|
123
|
+
if (keys.down) return { action: { kind: "consumed" }, selection: "main" };
|
|
124
|
+
return { action: { kind: "none" }, selection: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const index = resolveIndex(rows, selection) ?? 0;
|
|
128
|
+
|
|
129
|
+
if (keys.up) {
|
|
130
|
+
if (index === 0 && options.holdAtMain !== true) return { action: { kind: "consumed" }, selection: null };
|
|
131
|
+
return { action: { kind: "consumed" }, selection: selectionAtIndex(rows, index - 1) };
|
|
132
|
+
}
|
|
133
|
+
if (keys.down) {
|
|
134
|
+
return { action: { kind: "consumed" }, selection: selectionAtIndex(rows, index + 1) };
|
|
135
|
+
}
|
|
136
|
+
if (keys.escape) {
|
|
137
|
+
return { action: { kind: "consumed" }, selection: null };
|
|
138
|
+
}
|
|
139
|
+
if (keys.enter) {
|
|
140
|
+
const target = index > 0 ? rows[index - 1] : undefined;
|
|
141
|
+
return {
|
|
142
|
+
action: { kind: "open", target: target ? { runId: target.runId, taskId: target.taskId } : undefined },
|
|
143
|
+
selection: null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (keys.act && index > 0) {
|
|
147
|
+
const target = rows[index - 1];
|
|
148
|
+
if (!target) return { action: { kind: "consumed" }, selection: selectionAtIndex(rows, 0) };
|
|
149
|
+
// Selection stays on the SAME agent so a follow-up keystroke acts on it
|
|
150
|
+
// again rather than on whichever row the reorder promoted.
|
|
151
|
+
return {
|
|
152
|
+
action: { kind: "act", target: { runId: target.runId, taskId: target.taskId } },
|
|
153
|
+
selection: { runId: target.runId, taskId: target.taskId },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { action: { kind: "none" }, selection: null };
|
|
157
|
+
}
|