shariq-pi-extensions 0.2.26 → 0.2.28

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.
@@ -17,7 +17,7 @@ docs/ package-level maintenance documentation
17
17
  scripts/ repository validation helpers
18
18
  ```
19
19
 
20
- Extension entrypoints register tools, commands, providers, events, or UI. Shared code stays in `extensions/shared` only when more than one extension owns the behavior. Provider-specific protocol and authentication code remains inside its provider directory. The background-terminal, orchestration, and subagent skills ship beside their extensions so a new machine receives the required lifecycle guidance with the tools.
20
+ Extension entrypoints register tools, commands, providers, events, or UI. Shared code stays in `extensions/shared` only when more than one extension owns the behavior. The shared activity dock combines active Task List, terminal, subagent, and orchestration rows under one bounded widget; shared tool cards keep lifecycle operations compact in the timeline while preserving expanded output. Provider-specific protocol and authentication code remains inside its provider directory. The background-terminal, orchestration, and subagent skills ship beside their extensions so a new machine receives the required lifecycle guidance with the tools.
21
21
 
22
22
  ## Dependency model
23
23
 
@@ -44,13 +44,13 @@ State is stored in Pi session entries and reconstructed on resume, reload, and t
44
44
 
45
45
  The subagent extension runs flat Pi child agents with profiles, capability policies, continuation, result delivery, optional worktrees, pre-warmed task dispatch, instant cascading cancellation, cross-session persistence, and a dashboard. Configuration lives in `<agent-dir>/subagents.json`; trusted projects may override it through their Pi config directory. The configured concurrency ceiling is 50.
46
46
 
47
- The extension supplies tools including `spawn_agent`, `task`, `check_agent`, `list_agents`, `wait_agent`, `send_message`, `close_agent`, `reply_question`, and `apply_agent_changes`. All completed subagent snapshots and transcripts persist across Pi restarts (`<agent-dir>/subagents/runs/`), allowing `resume_from` to resume completed workers at any point. Interruption immediately cascades across all child fibers in `<10ms`. Child settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with the summary guaranteed in model context and never rendered as user-authored or follow-up input; status tools are for explicit inspection, not waiting.
47
+ The extension supplies tools including `spawn_agent`, `task`, `check_agent`, `list_agents`, `wait_agent`, `send_message`, `close_agent`, `reply_question`, and `apply_agent_changes`. These lifecycle operations render as compact expandable main-chat cards; only active manager entries contribute to the footer and bounded Active work dock, so persisted history never inflates live failure counts. Cancellation and reload interruption use a distinct `cancelled` state rather than `error`. All completed and cancelled production subagent snapshots and transcripts persist across Pi restarts (`<agent-dir>/subagents/runs/`), allowing `resume_from` to resume completed workers at any point. Interruption immediately cascades across all child fibers in `<10ms`. Child settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with the summary guaranteed in model context and never rendered as user-authored or follow-up input; status tools are for explicit inspection, not waiting.
48
48
 
49
49
  ### [Orchestration](../extensions/orchestration/README.md)
50
50
 
51
51
  The Orchestration extension coordinates explicitly requested large tasks through a dedicated orchestrator plus configurable explorer, frontend, backend, general-worker, and reviewer models. `/orchestration` opens the dashboard and `/orchestration settings` configures global role models. It reuses the canonical Subagents runtime, maintains a 10-worker pool per project, isolates Git writers in task worktrees, resumes the original worker for substantial review fixes, and applies final reviewed changes without committing or pushing.
52
52
 
53
- The model-facing `create_orchestration` tool starts planning only after an explicit orchestration request. `get_orchestration` reports status without advancing work. Interrupted runs recover paused under `<agent-dir>/orchestration/`.
53
+ The model-facing `create_orchestration` tool starts planning only after an explicit orchestration request. `get_orchestration` reports status without advancing work. Both use compact expandable timeline cards, active runs share the Active work dock, and footer states distinguish plan-ready, blocked, running, and paused work. Dashboard editors and confirmations run outside the live overlay to avoid nested-input stalls. Interrupted runs recover paused under `<agent-dir>/orchestration/`.
54
54
 
55
55
  ### [Smart Compaction](../extensions/smart-compaction/README.md)
56
56
 
@@ -71,7 +71,7 @@ Key capabilities include:
71
71
 
72
72
  Managed PTYs support servers, watchers, long builds, downloads, and interactive processes. The extension tracks up to eight concurrent terminals, retains bounded output, stores full logs in restrictive temporary directories, and stops process groups during shutdown or reload.
73
73
 
74
- Its tools are `start_terminal`, `read_terminal`, `write_terminal`, `list_terminals`, and `stop_terminal`. A model-started terminal keeps completion or failure in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input. Reading a terminal does not suppress that delivery; agents should inspect only for explicit progress requests or immediate interaction.
74
+ Its tools are `start_terminal`, `read_terminal`, `write_terminal`, `list_terminals`, and `stop_terminal`. All use compact expandable main-chat cards, while running PTYs share the bounded Active work dock with elapsed time and a latest-output preview. A model-started terminal keeps completion or failure in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input. Reading a terminal does not suppress that delivery; agents should inspect only for explicit progress requests or immediate interaction.
75
75
 
76
76
  ## Web access
77
77
 
@@ -91,7 +91,7 @@ Displays the estimated prompt and session contribution to the active context win
91
91
 
92
92
  ### [Performance status](../extensions/performance-status/README.md)
93
93
 
94
- Adds a responsive footer-area status row for each assistant message. Live TPS/output are marked as estimates; final TPS uses conventional decode throughput from first streamed token to message completion. TTFT and total elapsed time separately expose provider latency, prefill, and hidden reasoning; output tokens and active tools remain separate.
94
+ Adds a responsive footer-area status row while each assistant message is active and for an eight-second completion linger, then clears it to avoid permanent footer noise. Live TPS/output are marked as estimates; final TPS uses conventional decode throughput from first streamed token to message completion. TTFT and total elapsed time separately expose provider latency, prefill, and hidden reasoning; output tokens and active tools remain separate.
95
95
 
96
96
  ### [Git info](../extensions/git-info/README.md)
97
97
 
@@ -10,7 +10,7 @@ Session-scoped background pseudo-terminals for Pi. The extension combines Codex-
10
10
  - `list_terminals` — list running and settled terminals.
11
11
  - `stop_terminal` — stop complete process groups with TERM-to-KILL escalation.
12
12
 
13
- Each output response carries a byte cursor. Pass it to the next read/write operation to avoid repeating output. Long or uncertain commands should use `start_terminal` instead of a large blocking `bash` timeout. Settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input, so the parent can continue other work or end its turn rather than poll.
13
+ Each output response carries a byte cursor. Pass it to the next read/write operation to avoid repeating output. Terminal lifecycle tools render as compact main-chat cards with expandable output, and running terminals appear in the shared bounded **Active work** dock with elapsed time and the latest output line. Long or uncertain commands should use `start_terminal` instead of a large blocking `bash` timeout. Settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input, so the parent can continue other work or end its turn rather than poll.
14
14
 
15
15
  ## User interface
16
16
 
@@ -3,23 +3,27 @@ import * as path from "node:path";
3
3
  import type {
4
4
  ExtensionAPI,
5
5
  ExtensionCommandContext,
6
+ ExtensionContext,
6
7
  ExtensionUIContext,
7
8
  } from "@earendil-works/pi-coding-agent";
8
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
+ import { formatSize, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
10
  import { Markdown, Text } from "@earendil-works/pi-tui";
10
11
  import { Type } from "typebox";
12
+ import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
11
13
  import { settlementDelivery } from "../shared/settlement-delivery.ts";
12
- import { oneLine, sanitizeTerminalText, stateLabel } from "../shared/tui-dashboard.ts";
14
+ import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
15
+ import { formatDurationMs, oneLine, sanitizeTerminalText, stateLabel } from "../shared/tui-dashboard.ts";
13
16
  import { TerminalManager, MAX_RUNNING_TERMINALS } from "./src/manager.ts";
14
17
  import {
15
18
  formatCompletion,
16
19
  formatReadResult,
17
20
  formatTerminal,
18
21
  } from "./src/presentation.ts";
19
- import type { TerminalReadResult, TerminalSnapshot } from "./src/types.ts";
22
+ import { terminalElapsedMs, type TerminalReadResult, type TerminalSnapshot } from "./src/types.ts";
20
23
  import { openTerminalDashboard } from "./src/ui.ts";
21
24
 
22
25
  const STATUS_KEY = "background-terminals";
26
+ const ACTIVITY_SOURCE = "background-terminals";
23
27
 
24
28
  function titleFrom(command: string, title?: string): string {
25
29
  const requested = oneLine(title ?? "").slice(0, 100);
@@ -39,6 +43,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
39
43
  const deliverSettlement = settlementDelivery(pi);
40
44
  let manager: TerminalManager | undefined;
41
45
  let ui: ExtensionUIContext | undefined;
46
+ let activityContext: ExtensionContext | undefined;
42
47
  let unsubscribe: (() => void) | undefined;
43
48
  let lastStatus = "";
44
49
  const pendingResults = new Map<string, TerminalSnapshot>();
@@ -79,6 +84,21 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
79
84
  const next = running > 0
80
85
  ? `${running} background terminal${running === 1 ? "" : "s"} · /term`
81
86
  : "";
87
+ if (activityContext) {
88
+ setActivitySource(activityContext, ACTIVITY_SOURCE, terminals
89
+ .filter((terminal) => terminal.status === "running")
90
+ .map((terminal) => {
91
+ const latest = sanitizeTerminalText(terminal.output.text).split(/\r?\n/).filter(Boolean).at(-1);
92
+ return {
93
+ id: terminal.id,
94
+ label: "Terminal",
95
+ title: terminal.title,
96
+ detail: `${formatDurationMs(terminalElapsedMs(terminal))}${latest ? ` · ${oneLine(latest)}` : ""}`,
97
+ state: "active" as const,
98
+ priority: 50,
99
+ };
100
+ }));
101
+ }
82
102
  if (next === lastStatus) return;
83
103
  lastStatus = next;
84
104
  if (!next) ui.setStatus(STATUS_KEY, undefined);
@@ -108,7 +128,11 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
108
128
  }
109
129
 
110
130
  pi.on("session_start", (_event, ctx) => {
111
- if (ctx.hasUI) ui = ctx.ui;
131
+ if (ctx.hasUI) {
132
+ ui = ctx.ui;
133
+ activityContext = ctx;
134
+ updateStatus();
135
+ }
112
136
  });
113
137
 
114
138
  pi.on("session_shutdown", async () => {
@@ -118,6 +142,8 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
118
142
  unsubscribe?.();
119
143
  unsubscribe = undefined;
120
144
  ui?.setStatus(STATUS_KEY, undefined);
145
+ if (activityContext) clearActivitySource(activityContext, ACTIVITY_SOURCE);
146
+ activityContext = undefined;
121
147
  ui = undefined;
122
148
  lastStatus = "";
123
149
  const closing = manager;
@@ -236,6 +262,15 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
236
262
  },
237
263
  };
238
264
  },
265
+ renderCall(args, theme) {
266
+ return new Text(toolCallCard(theme, "terminal read", args.id, `cursor ${args.cursor ?? "tail"}`), 0, 0);
267
+ },
268
+ renderResult(result, { expanded, isPartial }, theme) {
269
+ if (isPartial) return new Text(theme.fg("warning", "Reading terminal…"), 0, 0);
270
+ const details = result.details as { id?: string; status?: string; cursor?: number; omittedBytes?: number } | undefined;
271
+ const state = details?.status === "failed" ? "error" : details?.status === "running" ? "active" : "success";
272
+ return new Text(toolResultCard(result, expanded, theme, state, details?.id ?? "terminal", `${details?.status ?? "unknown"} · cursor ${details?.cursor ?? "?"}${details?.omittedBytes ? ` · ${formatSize(details.omittedBytes)} omitted` : ""}`), 0, 0);
273
+ },
239
274
  });
240
275
 
241
276
  pi.registerTool({
@@ -273,6 +308,16 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
273
308
  },
274
309
  };
275
310
  },
311
+ renderCall(args, theme) {
312
+ const input = args.input === "\u0003" ? "Ctrl+C" : `${Array.from(args.input ?? "").length} chars${args.press_enter === false ? "" : " + Enter"}`;
313
+ return new Text(toolCallCard(theme, "terminal input", args.id, input), 0, 0);
314
+ },
315
+ renderResult(result, { expanded, isPartial }, theme) {
316
+ if (isPartial) return new Text(theme.fg("warning", "Sending terminal input…"), 0, 0);
317
+ const details = result.details as { id?: string; status?: string; cursor?: number } | undefined;
318
+ const state = details?.status === "failed" ? "error" : details?.status === "running" ? "active" : "success";
319
+ return new Text(toolResultCard(result, expanded, theme, state, details?.id ?? "terminal", `${details?.status ?? "unknown"} · cursor ${details?.cursor ?? "?"}`), 0, 0);
320
+ },
276
321
  });
277
322
 
278
323
  pi.registerTool({
@@ -287,6 +332,15 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
287
332
  details: { terminals: terminals.map((terminal) => ({ id: terminal.id, title: terminal.title, status: terminal.status, pid: terminal.pid })) },
288
333
  };
289
334
  },
335
+ renderCall(_args, theme) {
336
+ return new Text(toolCallCard(theme, "terminals", "list managed PTYs"), 0, 0);
337
+ },
338
+ renderResult(result, { expanded }, theme) {
339
+ const details = result.details as { terminals?: Array<{ status?: string }> } | undefined;
340
+ const terminals = details?.terminals ?? [];
341
+ const running = terminals.filter((terminal) => terminal.status === "running").length;
342
+ return new Text(toolResultCard(result, expanded, theme, running ? "active" : "muted", "terminals", `${terminals.length} tracked · ${running} running`), 0, 0);
343
+ },
290
344
  });
291
345
 
292
346
  pi.registerTool({
@@ -305,6 +359,16 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
305
359
  details: { terminals: terminals.map((terminal) => ({ id: terminal.id, status: terminal.status, exitCode: terminal.exitCode })) },
306
360
  };
307
361
  },
362
+ renderCall(args, theme) {
363
+ return new Text(toolCallCard(theme, "terminal stop", `${args.ids?.length ?? 0} terminal${args.ids?.length === 1 ? "" : "s"}`, (args.ids ?? []).join(", ")), 0, 0);
364
+ },
365
+ renderResult(result, { expanded, isPartial }, theme) {
366
+ if (isPartial) return new Text(theme.fg("warning", "Stopping terminals…"), 0, 0);
367
+ const details = result.details as { terminals?: Array<{ status?: string }> } | undefined;
368
+ const terminals = details?.terminals ?? [];
369
+ const failed = terminals.some((terminal) => terminal.status === "failed");
370
+ return new Text(toolResultCard(result, expanded, theme, failed ? "error" : "success", "terminals stopped", `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`), 0, 0);
371
+ },
308
372
  });
309
373
 
310
374
  pi.registerMessageRenderer(
@@ -18,15 +18,13 @@ const REFRESH_DEBOUNCE_MS = 150;
18
18
  function statusText(ui: ExtensionUIContext, summary: GitSummary): string | undefined {
19
19
  if (!summary.isRepository || !summary.branch) return undefined;
20
20
  const changed = summary.changedFiles === 0
21
- ? ui.theme.fg("muted", "clean")
22
- : ui.theme.fg(
23
- "warning",
24
- `${summary.changedFiles} changed ${summary.changedFiles === 1 ? "file" : "files"}`,
25
- );
21
+ ? ui.theme.fg("muted", "")
22
+ : ui.theme.fg("warning", `+${summary.changedFiles}`);
26
23
  const pr = summary.pullRequest
27
24
  ? ` · ${ui.theme.fg("accent", `PR #${summary.pullRequest.number}${summary.pullRequest.draft ? " draft" : ""}`)}`
28
25
  : "";
29
- return `${ui.theme.fg("muted", "git")} ${ui.theme.fg("accent", summary.branch)} · ${changed}${pr} · ${ui.theme.fg("dim", "/lg")}`;
26
+ const command = summary.changedFiles > 0 || summary.pullRequest ? ` · ${ui.theme.fg("dim", "/lg")}` : "";
27
+ return `${ui.theme.fg("muted", "git")} ${ui.theme.fg("accent", summary.branch)} ${changed}${pr}${command}`;
30
28
  }
31
29
 
32
30
  export default function gitInfoExtension(pi: ExtensionAPI) {
@@ -31,7 +31,7 @@ Each project has up to 10 concurrent worker, explorer, or reviewer sessions. The
31
31
 
32
32
  ## Dashboard
33
33
 
34
- `/orchestration` opens the full-screen operations dashboard.
34
+ Orchestration creation and inspection render as compact main-chat cards with expandable detail. Active runs appear in the shared bounded **Active work** dock; plan-ready and blocked runs are prioritized as attention states. `/orchestration` opens the full-screen operations dashboard. Dashboard actions close the overlay before opening editors, settings, or confirmation prompts, then return to the live dashboard so nested UI cannot stall.
35
35
 
36
36
  - `j` / `k`: select a run
37
37
  - `Enter`: inspect tasks and reviews
@@ -40,7 +40,7 @@ Each project has up to 10 concurrent worker, explorer, or reviewer sessions. The
40
40
  - `a`: approve the initial plan
41
41
  - `f`: give plan feedback
42
42
  - `p`: pause, resume, or recover
43
- - `x`: cancel while preserving artifacts
43
+ - `x`: leave the dashboard and confirm cancellation while preserving artifacts
44
44
  - `Esc`: go back or close
45
45
 
46
46
  Interrupted runs recover in a paused state and require explicit resume. Run state is machine-local under Pi's active agent directory at `orchestration/runs/<run-id>/`.
@@ -12,81 +12,96 @@ import {
12
12
  import type { OrchestrationEngine } from "./engine.ts";
13
13
  import type { OrchestrationRun } from "./types.ts";
14
14
 
15
- interface DashboardActions {
16
- create(): void;
17
- settings(): void;
18
- feedback(run: OrchestrationRun): void;
19
- approve(run: OrchestrationRun): void;
20
- togglePause(run: OrchestrationRun): void;
21
- cancel(run: OrchestrationRun): void;
22
- }
15
+ export type OrchestrationDashboardAction =
16
+ | { kind: "close" }
17
+ | { kind: "create" }
18
+ | { kind: "settings" }
19
+ | { kind: "feedback"; id: string }
20
+ | { kind: "approve"; id: string }
21
+ | { kind: "toggle-pause"; id: string }
22
+ | { kind: "cancel"; id: string };
23
23
 
24
24
  type Theme = ExtensionContext["ui"]["theme"];
25
25
 
26
26
  class OrchestrationDashboard {
27
27
  private selected = 0;
28
+ private selectedId?: string;
28
29
  private detail = false;
29
30
  private unsubscribe: () => void;
30
31
  private readonly tui: TUI;
31
32
  private readonly theme: Theme;
32
33
  private readonly keys: KeybindingsManager;
33
34
  private readonly engine: OrchestrationEngine;
34
- private readonly actions: DashboardActions;
35
- private readonly close: () => void;
35
+ private readonly done: (action: OrchestrationDashboardAction) => void;
36
+ private closed = false;
36
37
 
37
38
  constructor(
38
39
  tui: TUI,
39
40
  theme: Theme,
40
41
  keys: KeybindingsManager,
41
42
  engine: OrchestrationEngine,
42
- actions: DashboardActions,
43
- close: () => void,
43
+ done: (action: OrchestrationDashboardAction) => void,
44
44
  ) {
45
45
  this.tui = tui;
46
46
  this.theme = theme;
47
47
  this.keys = keys;
48
48
  this.engine = engine;
49
- this.actions = actions;
50
- this.close = close;
49
+ this.done = done;
51
50
  this.unsubscribe = engineChangeSubscription(engine, () => tui.requestRender());
52
51
  }
53
52
 
54
- dispose() {
53
+ private close(action: OrchestrationDashboardAction) {
54
+ if (this.closed) return;
55
+ this.closed = true;
55
56
  this.unsubscribe();
57
+ this.done(action);
58
+ }
59
+
60
+ dispose() {
61
+ if (!this.closed) this.unsubscribe();
62
+ this.closed = true;
56
63
  }
57
64
 
58
65
  invalidate() {}
59
66
 
67
+ private reconcileSelection(runs: OrchestrationRun[]) {
68
+ const stable = this.selectedId ? runs.findIndex((run) => run.id === this.selectedId) : -1;
69
+ this.selected = stable >= 0 ? stable : Math.min(this.selected, Math.max(0, runs.length - 1));
70
+ this.selectedId = runs[this.selected]?.id;
71
+ }
72
+
60
73
  handleInput(data: string) {
61
74
  const runs = this.engine.list();
75
+ this.reconcileSelection(runs);
62
76
  if (matchesKey(data, Key.escape)) {
63
77
  if (this.detail) this.detail = false;
64
78
  else {
65
- this.dispose();
66
- this.close();
79
+ this.close({ kind: "close" });
67
80
  return;
68
81
  }
69
82
  } else if (!this.detail && (this.keys.matches(data, "tui.select.up") || data === "k")) {
70
83
  this.selected = Math.max(0, this.selected - 1);
84
+ this.selectedId = runs[this.selected]?.id;
71
85
  } else if (!this.detail && (this.keys.matches(data, "tui.select.down") || data === "j")) {
72
86
  this.selected = Math.min(Math.max(0, runs.length - 1), this.selected + 1);
87
+ this.selectedId = runs[this.selected]?.id;
73
88
  } else if (!this.detail && this.keys.matches(data, "tui.select.confirm") && runs[this.selected]) {
74
89
  this.detail = true;
75
- } else if (data === "n") this.actions.create();
76
- else if (data === "s") this.actions.settings();
90
+ } else if (data === "n") this.close({ kind: "create" });
91
+ else if (data === "s") this.close({ kind: "settings" });
77
92
  else if (this.detail && runs[this.selected]) {
78
93
  const run = runs[this.selected];
79
- if (data === "a" && run.status === "awaiting-approval") this.actions.approve(run);
80
- else if (data === "f" && run.status === "awaiting-approval") this.actions.feedback(run);
81
- else if (data === "p") this.actions.togglePause(run);
82
- else if (data === "x") this.actions.cancel(run);
94
+ if (data === "a" && run.status === "awaiting-approval") this.close({ kind: "approve", id: run.id });
95
+ else if (data === "f" && run.status === "awaiting-approval") this.close({ kind: "feedback", id: run.id });
96
+ else if (data === "p") this.close({ kind: "toggle-pause", id: run.id });
97
+ else if (data === "x") this.close({ kind: "cancel", id: run.id });
83
98
  }
84
99
  this.tui.requestRender();
85
100
  }
86
101
 
87
102
  render(width: number) {
88
103
  const runs = this.engine.list();
89
- this.selected = Math.min(this.selected, Math.max(0, runs.length - 1));
104
+ this.reconcileSelection(runs);
90
105
  const lines = this.detail && runs[this.selected]
91
106
  ? this.renderDetail(runs[this.selected], width)
92
107
  : this.renderList(runs, width);
@@ -109,7 +124,12 @@ class OrchestrationDashboard {
109
124
  this.theme.fg("borderMuted", "─".repeat(Math.max(0, width))),
110
125
  ];
111
126
  if (!runs.length) lines.push(this.theme.fg("muted", "No runs yet. Press n to create one."));
112
- for (const [index, run] of runs.entries()) {
127
+ const rows = this.tui.terminal.rows || 30;
128
+ const visibleCount = Math.max(1, Math.floor((rows - 5) / 2));
129
+ const start = Math.min(Math.max(0, this.selected - Math.floor(visibleCount / 2)), Math.max(0, runs.length - visibleCount));
130
+ const visible = runs.slice(start, start + visibleCount);
131
+ for (const [offset, run] of visible.entries()) {
132
+ const index = start + offset;
113
133
  const marker = index === this.selected ? this.theme.fg("accent", "❯") : " ";
114
134
  const complete = run.tasks.filter((task) => task.status === "completed").length;
115
135
  const color = run.status === "completed" ? "success" : run.status === "blocked" ? "error" : run.status === "running" ? "warning" : "muted";
@@ -122,8 +142,13 @@ class OrchestrationDashboard {
122
142
  );
123
143
  lines.push(` ${this.theme.fg("dim", `${run.id} · ${run.cwd}`)}`);
124
144
  }
145
+ if (start > 0) lines.splice(2, 0, this.theme.fg("dim", ` ↑ ${start} more`));
146
+ const below = runs.length - start - visible.length;
147
+ if (below > 0) lines.push(this.theme.fg("dim", ` ↓ ${below} more`));
125
148
  lines.push("", this.theme.fg("dim", "j/k select · enter open · n new · s settings · esc close"));
126
- return lines;
149
+ const maxRows = Math.max(8, rows);
150
+ if (lines.length <= maxRows) return lines;
151
+ return [...lines.slice(0, maxRows - 2), this.theme.fg("dim", "… more runs"), lines.at(-1)!];
127
152
  }
128
153
 
129
154
  private renderDetail(run: OrchestrationRun, width: number) {
@@ -161,7 +186,9 @@ class OrchestrationDashboard {
161
186
  "esc back",
162
187
  ].filter(Boolean).join(" · ");
163
188
  lines.push("", this.theme.fg("dim", controls));
164
- return lines;
189
+ const maxRows = Math.max(8, (this.tui.terminal.rows || 30) - 2);
190
+ if (lines.length <= maxRows) return lines;
191
+ return [...lines.slice(0, maxRows - 2), this.theme.fg("dim", "… more tasks in run state"), lines.at(-1)!];
165
192
  }
166
193
  }
167
194
 
@@ -179,11 +206,10 @@ function engineChangeSubscription(engine: OrchestrationEngine, listener: () => v
179
206
  export async function openOrchestrationDashboard(
180
207
  ctx: ExtensionContext,
181
208
  engine: OrchestrationEngine,
182
- actions: DashboardActions,
183
209
  ) {
184
- await ctx.ui.custom<void>(
210
+ return ctx.ui.custom<OrchestrationDashboardAction>(
185
211
  (tui, theme, keys, done) =>
186
- new OrchestrationDashboard(tui, theme, keys, engine, actions, () => done(undefined)),
212
+ new OrchestrationDashboard(tui, theme, keys, engine, done),
187
213
  {
188
214
  overlay: true,
189
215
  overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
@@ -3,7 +3,11 @@ import type {
3
3
  ExtensionContext,
4
4
  ExtensionUIContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { Text } from "@earendil-works/pi-tui";
6
7
  import { Type } from "typebox";
8
+ import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
9
+ import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
10
+ import { oneLine, stateLabel } from "../shared/tui-dashboard.ts";
7
11
  import {
8
12
  requestSubagentCoordinator,
9
13
  type SubagentCoordinator,
@@ -33,16 +37,36 @@ export default function orchestration(pi: ExtensionAPI) {
33
37
  const updateStatus = () => {
34
38
  if (!ui || !engine) return;
35
39
  const active = engine.list().filter((run) => !["completed", "cancelled"].includes(run.status));
40
+ if (context) {
41
+ setActivitySource(context, "orchestration", active.map((run) => {
42
+ const done = run.tasks.filter((task) => task.status === "completed").length;
43
+ const attention = run.status === "awaiting-approval";
44
+ return {
45
+ id: run.id,
46
+ label: "Orchestration",
47
+ title: run.objective,
48
+ detail: `${attention ? "plan ready for review" : run.status} · ${done}/${run.tasks.length}`,
49
+ state: run.status === "blocked" ? "error" as const : attention ? "warning" as const : ["planning", "running"].includes(run.status) ? "active" as const : "muted" as const,
50
+ priority: attention ? 120 : run.status === "blocked" ? 110 : 45,
51
+ };
52
+ }));
53
+ }
36
54
  if (!active.length) {
37
55
  ui.setStatus("orchestration", undefined);
38
56
  return;
39
57
  }
40
- const running = active.filter((run) => run.status === "running" || run.status === "planning").length;
58
+ const awaiting = active.filter((run) => run.status === "awaiting-approval").length;
41
59
  const blocked = active.filter((run) => run.status === "blocked").length;
42
- ui.setStatus(
43
- "orchestration",
44
- `${ui.theme.fg(blocked ? "error" : running ? "warning" : "muted", "■")} orchestration ${running} running${blocked ? ` · ${blocked} blocked` : ""}`,
45
- );
60
+ const running = active.filter((run) => run.status === "running" || run.status === "planning").length;
61
+ const paused = active.filter((run) => ["paused", "interrupted"].includes(run.status)).length;
62
+ const text = awaiting
63
+ ? `${awaiting} plan${awaiting === 1 ? "" : "s"} ready · /orchestration`
64
+ : blocked
65
+ ? `${blocked} blocked · /orchestration`
66
+ : running
67
+ ? `${running} running · /orchestration`
68
+ : `${paused} paused · /orchestration`;
69
+ ui.setStatus("orchestration", stateLabel(ui.theme, awaiting || blocked ? "warning" : running ? "active" : "muted", `Orchestration ${text}`));
46
70
  };
47
71
 
48
72
  const requireEngine = () => {
@@ -81,38 +105,41 @@ export default function orchestration(pi: ExtensionAPI) {
81
105
  }
82
106
  };
83
107
 
84
- const runAction = (operation: () => Promise<unknown>) => {
85
- void operation().catch((error) =>
86
- ui?.notify(error instanceof Error ? error.message : String(error), "error"),
87
- );
88
- };
89
-
90
108
  const openDashboard = async (ctx: ExtensionContext) => {
91
109
  const current = requireEngine();
92
- await openOrchestrationDashboard(ctx, current, {
93
- create: () => runAction(() => createFromUi(ctx)),
94
- settings: () => runAction(() => openOrchestrationSettings(ctx)),
95
- feedback: (run) =>
96
- runAction(async () => {
110
+ while (true) {
111
+ const action = await openOrchestrationDashboard(ctx, current);
112
+ if (action.kind === "close") return;
113
+ try {
114
+ if (action.kind === "create") {
115
+ await createFromUi(ctx);
116
+ continue;
117
+ }
118
+ if (action.kind === "settings") {
119
+ await openOrchestrationSettings(ctx);
120
+ continue;
121
+ }
122
+ const run = current.get(action.id);
123
+ if (!run) {
124
+ ctx.ui.notify(`Orchestration ${action.id} is no longer available.`, "warning");
125
+ continue;
126
+ }
127
+ if (action.kind === "feedback") {
97
128
  const feedback = await ctx.ui.editor("Plan feedback", "Tell the orchestrator what to change…");
98
129
  if (feedback?.trim()) await current.feedback(run.id, feedback);
99
- }),
100
- approve: (run) => runAction(() => current.approve(run.id)),
101
- togglePause: (run) =>
102
- runAction(() =>
103
- ["paused", "interrupted", "blocked"].includes(run.status)
104
- ? current.resume(run.id)
105
- : current.pause(run.id),
106
- ),
107
- cancel: (run) =>
108
- runAction(async () => {
109
- const confirmed = await ctx.ui.confirm(
110
- "Cancel orchestration?",
111
- `Stop ${run.id} and preserve its run artifacts?`,
112
- );
130
+ } else if (action.kind === "approve") {
131
+ await current.approve(run.id);
132
+ } else if (action.kind === "toggle-pause") {
133
+ if (["paused", "interrupted", "blocked"].includes(run.status)) await current.resume(run.id);
134
+ else await current.pause(run.id);
135
+ } else if (action.kind === "cancel") {
136
+ const confirmed = await ctx.ui.confirm("Cancel orchestration?", `Stop ${run.id} and preserve its run artifacts?`);
113
137
  if (confirmed) await current.cancel(run.id);
114
- }),
115
- });
138
+ }
139
+ } catch (error) {
140
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
141
+ }
142
+ }
116
143
  };
117
144
 
118
145
  pi.on("session_start", async (_event, ctx) => {
@@ -150,8 +177,9 @@ export default function orchestration(pi: ExtensionAPI) {
150
177
  engine?.stop();
151
178
  engine = undefined;
152
179
  coordinator = undefined;
153
- context = undefined;
154
180
  ui?.setStatus("orchestration", undefined);
181
+ if (context) clearActivitySource(context, "orchestration");
182
+ context = undefined;
155
183
  ui = undefined;
156
184
  });
157
185
 
@@ -209,6 +237,14 @@ export default function orchestration(pi: ExtensionAPI) {
209
237
  details: { id: run.id, status: run.status, cwd: run.cwd },
210
238
  };
211
239
  },
240
+ renderCall(args, theme) {
241
+ return new Text(toolCallCard(theme, "orchestration create", oneLine(args.objective).slice(0, 100), args.cwd ?? "current workspace"), 0, 0);
242
+ },
243
+ renderResult(result, { expanded, isPartial }, theme) {
244
+ if (isPartial) return new Text(theme.fg("warning", "Starting orchestration…"), 0, 0);
245
+ const details = result.details as { id?: string; status?: string; cwd?: string } | undefined;
246
+ return new Text(toolResultCard(result, expanded, theme, "active", details?.id ?? "orchestration", `${details?.status ?? "planning"} · ${details?.cwd ?? "workspace"} · /orchestration`), 0, 0);
247
+ },
212
248
  });
213
249
 
214
250
  pi.registerTool({
@@ -239,5 +275,16 @@ export default function orchestration(pi: ExtensionAPI) {
239
275
  details: { runs },
240
276
  };
241
277
  },
278
+ renderCall(args, theme) {
279
+ return new Text(toolCallCard(theme, "orchestration inspect", args.id ?? "all runs"), 0, 0);
280
+ },
281
+ renderResult(result, { expanded }, theme) {
282
+ const details = result.details as { runs?: Array<{ status?: string; tasks?: Array<{ status?: string }> }> } | undefined;
283
+ const runs = details?.runs ?? [];
284
+ const attention = runs.filter((run) => run.status === "awaiting-approval" || run.status === "blocked").length;
285
+ const done = runs.reduce((total, run) => total + (run.tasks?.filter((task) => task.status === "completed").length ?? 0), 0);
286
+ const total = runs.reduce((count, run) => count + (run.tasks?.length ?? 0), 0);
287
+ return new Text(toolResultCard(result, expanded, theme, attention ? "warning" : runs.some((run) => run.status === "running" || run.status === "planning") ? "active" : "muted", "orchestration", `${runs.length} run${runs.length === 1 ? "" : "s"} · ${done}/${total} tasks${attention ? ` · ${attention} need attention` : ""}`), 0, 0);
288
+ },
242
289
  });
243
290
  }