shariq-pi-extensions 0.2.28 → 0.2.30

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.
@@ -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`. 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.
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 bounded Active work dock, while workflow progress is intentionally omitted from the footer so persisted history cannot inflate or duplicate live status. 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
- 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.
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` opens the interactive role-configuration panel with the Searchable Model Picker. 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. 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/`.
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, and active runs share the full-screen Active work dock with explicit plan-ready, blocked, running, and paused states instead of duplicating workflow progress in the footer. The full-height framed dashboard provides scrollable multi-task inspection and runs editors and confirmations outside its 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
 
@@ -101,13 +101,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
101
101
  }
102
102
  if (next === lastStatus) return;
103
103
  lastStatus = next;
104
- if (!next) ui.setStatus(STATUS_KEY, undefined);
105
- else {
106
- ui.setStatus(
107
- STATUS_KEY,
108
- stateLabel(ui.theme, "active", next),
109
- );
110
- }
104
+ ui.setStatus(STATUS_KEY, undefined);
111
105
  }
112
106
 
113
107
  function flushResult(id: string): void {
@@ -16,7 +16,7 @@ The orchestrator sleeps while agents work and wakes automatically at execution b
16
16
 
17
17
  ## Roles and settings
18
18
 
19
- Use `/orchestration settings` to choose a global provider/model and thinking level for:
19
+ Use `/orchestration settings` (or `s` in the dashboard) to open the interactive role configuration panel. Navigate roles with `j`/`k`, press `Enter` to search and select models from all active providers in the Searchable Model Picker, press `t` or `Tab` to cycle thinking levels directly, and press `s` to save. Settings configure:
20
20
 
21
21
  - orchestrator
22
22
  - explorer
@@ -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 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.
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 a full-height framed operations dashboard with a bounded body and stable selection. Dashboard actions close the overlay before opening editors, settings, or confirmation prompts, then return to the live dashboard so nested UI cannot stall. **New** opens the objective editor before asking for missing role models, so cancelling a draft does not force configuration; cancelling unchanged settings does not claim they were saved.
35
35
 
36
36
  - `j` / `k`: select a run
37
37
  - `Enter`: inspect tasks and reviews
@@ -9,6 +9,7 @@ import {
9
9
  visibleWidth,
10
10
  type TUI,
11
11
  } from "@earendil-works/pi-tui";
12
+ import { frameBottom, frameTop, framedRow, joinSides } from "../shared/tui-dashboard.ts";
12
13
  import type { OrchestrationEngine } from "./engine.ts";
13
14
  import type { OrchestrationRun } from "./types.ts";
14
15
 
@@ -27,6 +28,7 @@ class OrchestrationDashboard {
27
28
  private selected = 0;
28
29
  private selectedId?: string;
29
30
  private detail = false;
31
+ private detailScroll = 0;
30
32
  private unsubscribe: () => void;
31
33
  private readonly tui: TUI;
32
34
  private readonly theme: Theme;
@@ -73,28 +75,45 @@ class OrchestrationDashboard {
73
75
  handleInput(data: string) {
74
76
  const runs = this.engine.list();
75
77
  this.reconcileSelection(runs);
78
+ const rows = this.tui.terminal.rows || 30;
79
+ const bodyHeight = Math.max(8, rows - 5);
80
+
76
81
  if (matchesKey(data, Key.escape)) {
77
- if (this.detail) this.detail = false;
78
- else {
82
+ if (this.detail) {
83
+ this.detail = false;
84
+ this.detailScroll = 0;
85
+ } else {
79
86
  this.close({ kind: "close" });
80
87
  return;
81
88
  }
82
- } else if (!this.detail && (this.keys.matches(data, "tui.select.up") || data === "k")) {
83
- this.selected = Math.max(0, this.selected - 1);
84
- this.selectedId = runs[this.selected]?.id;
85
- } else if (!this.detail && (this.keys.matches(data, "tui.select.down") || data === "j")) {
86
- this.selected = Math.min(Math.max(0, runs.length - 1), this.selected + 1);
87
- this.selectedId = runs[this.selected]?.id;
88
- } else if (!this.detail && this.keys.matches(data, "tui.select.confirm") && runs[this.selected]) {
89
- this.detail = true;
90
- } else if (data === "n") this.close({ kind: "create" });
91
- else if (data === "s") this.close({ kind: "settings" });
92
- else if (this.detail && runs[this.selected]) {
93
- const run = runs[this.selected];
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 });
89
+ } else if (this.detail) {
90
+ if (this.keys.matches(data, "tui.select.up") || data === "k" || matchesKey(data, Key.up)) {
91
+ this.detailScroll = Math.max(0, this.detailScroll - 1);
92
+ } else if (this.keys.matches(data, "tui.select.down") || data === "j" || matchesKey(data, Key.down)) {
93
+ this.detailScroll = this.detailScroll + 1;
94
+ } else if (matchesKey(data, Key.pageUp)) {
95
+ this.detailScroll = Math.max(0, this.detailScroll - Math.max(1, bodyHeight - 4));
96
+ } else if (matchesKey(data, Key.pageDown)) {
97
+ this.detailScroll = this.detailScroll + Math.max(1, bodyHeight - 4);
98
+ } else if (runs[this.selected]) {
99
+ const run = runs[this.selected];
100
+ if (data === "a" && run.status === "awaiting-approval") this.close({ kind: "approve", id: run.id });
101
+ else if (data === "f" && run.status === "awaiting-approval") this.close({ kind: "feedback", id: run.id });
102
+ else if (data === "p") this.close({ kind: "toggle-pause", id: run.id });
103
+ else if (data === "x") this.close({ kind: "cancel", id: run.id });
104
+ }
105
+ } else if (!this.detail) {
106
+ if (this.keys.matches(data, "tui.select.up") || data === "k" || matchesKey(data, Key.up)) {
107
+ this.selected = Math.max(0, this.selected - 1);
108
+ this.selectedId = runs[this.selected]?.id;
109
+ } else if (this.keys.matches(data, "tui.select.down") || data === "j" || matchesKey(data, Key.down)) {
110
+ this.selected = Math.min(Math.max(0, runs.length - 1), this.selected + 1);
111
+ this.selectedId = runs[this.selected]?.id;
112
+ } else if (this.keys.matches(data, "tui.select.confirm") && runs[this.selected]) {
113
+ this.detail = true;
114
+ this.detailScroll = 0;
115
+ } else if (data === "n") this.close({ kind: "create" });
116
+ else if (data === "s") this.close({ kind: "settings" });
98
117
  }
99
118
  this.tui.requestRender();
100
119
  }
@@ -102,9 +121,50 @@ class OrchestrationDashboard {
102
121
  render(width: number) {
103
122
  const runs = this.engine.list();
104
123
  this.reconcileSelection(runs);
105
- const lines = this.detail && runs[this.selected]
106
- ? this.renderDetail(runs[this.selected], width)
107
- : this.renderList(runs, width);
124
+ const selected = runs[this.selected];
125
+ const rows = this.tui.terminal.rows || 30;
126
+ const bodyHeight = Math.max(8, rows - 5);
127
+ const innerWidth = Math.max(1, width - 2);
128
+
129
+ let body: string[];
130
+ if (this.detail && selected) {
131
+ const allDetailLines = this.renderDetail(selected, innerWidth);
132
+ const maxScroll = Math.max(0, allDetailLines.length - bodyHeight);
133
+ this.detailScroll = Math.min(this.detailScroll, maxScroll);
134
+ const visible = allDetailLines.slice(this.detailScroll, this.detailScroll + bodyHeight);
135
+ if (this.detailScroll > 0 && visible.length > 0) {
136
+ visible[0] = this.theme.fg("dim", ` ↑ ${this.detailScroll} more lines`);
137
+ }
138
+ if (this.detailScroll + bodyHeight < allDetailLines.length && visible.length > 0) {
139
+ visible[visible.length - 1] = this.theme.fg("dim", ` ↓ ${allDetailLines.length - (this.detailScroll + bodyHeight)} more lines`);
140
+ }
141
+ body = visible;
142
+ } else {
143
+ const listContent = this.renderList(runs, innerWidth);
144
+ body = listContent.length > bodyHeight
145
+ ? [...listContent.slice(0, bodyHeight - 1), this.theme.fg("dim", "… more")]
146
+ : listContent;
147
+ }
148
+
149
+ const status = runs.length
150
+ ? `${runs.filter((run) => ["planning", "running"].includes(run.status)).length} active · ${runs.length} total`
151
+ : "idle";
152
+ const controls = this.detail && selected
153
+ ? [
154
+ selected.status === "awaiting-approval" ? "a approve · f feedback" : "",
155
+ ["paused", "interrupted", "blocked"].includes(selected.status) ? "p resume" : selected.status === "running" ? "p pause" : "",
156
+ !["completed", "cancelled"].includes(selected.status) ? "x cancel" : "",
157
+ "j/k scroll",
158
+ "esc back",
159
+ ].filter(Boolean).join(" · ")
160
+ : "j/k select · enter open · n new · s settings · esc close";
161
+ const lines = [
162
+ joinSides(` ${this.theme.fg("accent", this.theme.bold("◆ ORCHESTRATION"))}`, `${this.theme.fg("muted", status)} `, width),
163
+ frameTop(this.theme, width, this.detail && selected ? `${selected.id} · RUN DETAIL` : `${runs.length} RUN${runs.length === 1 ? "" : "S"} · CONTROL CENTER`),
164
+ ];
165
+ for (let row = 0; row < bodyHeight; row++) lines.push(framedRow(this.theme, body[row] ?? "", width));
166
+ lines.push(frameBottom(this.theme, width));
167
+ lines.push(truncateToWidth(this.theme.fg("dim", ` ${controls}`), width, ""));
108
168
  return lines.map((line) => truncateToWidth(line, width, ""));
109
169
  }
110
170
 
@@ -115,14 +175,7 @@ class OrchestrationDashboard {
115
175
  }
116
176
 
117
177
  private renderList(runs: OrchestrationRun[], width: number) {
118
- const lines = [
119
- this.split(
120
- this.theme.fg("accent", this.theme.bold("Orchestration")),
121
- this.theme.fg("dim", `${runs.length} run${runs.length === 1 ? "" : "s"}`),
122
- width,
123
- ),
124
- this.theme.fg("borderMuted", "─".repeat(Math.max(0, width))),
125
- ];
178
+ const lines: string[] = [];
126
179
  if (!runs.length) lines.push(this.theme.fg("muted", "No runs yet. Press n to create one."));
127
180
  const rows = this.tui.terminal.rows || 30;
128
181
  const visibleCount = Math.max(1, Math.floor((rows - 5) / 2));
@@ -142,13 +195,10 @@ class OrchestrationDashboard {
142
195
  );
143
196
  lines.push(` ${this.theme.fg("dim", `${run.id} · ${run.cwd}`)}`);
144
197
  }
145
- if (start > 0) lines.splice(2, 0, this.theme.fg("dim", ` ↑ ${start} more`));
198
+ if (start > 0) lines.unshift(this.theme.fg("dim", ` ↑ ${start} more`));
146
199
  const below = runs.length - start - visible.length;
147
200
  if (below > 0) lines.push(this.theme.fg("dim", ` ↓ ${below} more`));
148
- lines.push("", this.theme.fg("dim", "j/k select · enter open · n new · s settings · esc close"));
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)!];
201
+ return lines;
152
202
  }
153
203
 
154
204
  private renderDetail(run: OrchestrationRun, width: number) {
@@ -179,16 +229,7 @@ class OrchestrationDashboard {
179
229
  }
180
230
  if (run.finalReviewSummary) lines.push("", this.theme.fg("accent", `Final review: ${run.finalReviewSummary}`));
181
231
  if (run.error) lines.push("", this.theme.fg("error", run.error));
182
- const controls = [
183
- run.status === "awaiting-approval" ? "a approve · f feedback" : "",
184
- ["paused", "interrupted", "blocked"].includes(run.status) ? "p resume" : run.status === "running" ? "p pause" : "",
185
- !["completed", "cancelled"].includes(run.status) ? "x cancel" : "",
186
- "esc back",
187
- ].filter(Boolean).join(" · ");
188
- lines.push("", this.theme.fg("dim", controls));
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)!];
232
+ return lines;
192
233
  }
193
234
  }
194
235
 
@@ -7,7 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
  import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
9
9
  import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
10
- import { oneLine, stateLabel } from "../shared/tui-dashboard.ts";
10
+ import { oneLine } from "../shared/tui-dashboard.ts";
11
11
  import {
12
12
  requestSubagentCoordinator,
13
13
  type SubagentCoordinator,
@@ -55,18 +55,7 @@ export default function orchestration(pi: ExtensionAPI) {
55
55
  ui.setStatus("orchestration", undefined);
56
56
  return;
57
57
  }
58
- const awaiting = active.filter((run) => run.status === "awaiting-approval").length;
59
- const blocked = active.filter((run) => run.status === "blocked").length;
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}`));
58
+ ui.setStatus("orchestration", undefined);
70
59
  };
71
60
 
72
61
  const requireEngine = () => {
@@ -91,12 +80,12 @@ export default function orchestration(pi: ExtensionAPI) {
91
80
  };
92
81
 
93
82
  const createFromUi = async (ctx: ExtensionContext) => {
94
- await ensureModels(ctx);
95
83
  const objective = await ctx.ui.editor(
96
84
  "New orchestration objective",
97
85
  "Describe the complete outcome, boundaries, and requirements…",
98
86
  );
99
87
  if (!objective?.trim()) return;
88
+ await ensureModels(ctx);
100
89
  try {
101
90
  const run = await requireEngine().create(objective, ctx.cwd);
102
91
  ctx.ui.notify(`Started dedicated planning agent for ${run.id}.`, "info");
@@ -1,41 +1,213 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { ORCHESTRATION_ROLES } from "./types.ts";
1
+ import type {
2
+ ExtensionContext,
3
+ KeybindingsManager,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ type TUI,
12
+ } from "@earendil-works/pi-tui";
13
+ import { openModelPicker } from "../shared/model-picker.ts";
14
+ import { frameBottom, frameTop, framedRow, joinSides, padLine } from "../shared/tui-dashboard.ts";
3
15
  import {
4
16
  loadOrchestrationSettings,
5
17
  saveOrchestrationSettings,
6
18
  } from "./settings.ts";
19
+ import {
20
+ ORCHESTRATION_ROLES,
21
+ type OrchestrationRole,
22
+ type OrchestrationSettings,
23
+ } from "./types.ts";
7
24
 
8
25
  const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
9
26
 
10
- export async function openOrchestrationSettings(ctx: ExtensionContext) {
11
- const settings = loadOrchestrationSettings();
12
- for (;;) {
13
- const choices = [
14
- ...ORCHESTRATION_ROLES.map((role) =>
15
- `${role}: ${settings.roles[role].model ?? "not configured"} · ${settings.roles[role].thinking}`,
16
- ),
17
- "done",
27
+ export type OrchestrationSettingsAction =
28
+ | { kind: "close" }
29
+ | { kind: "save" }
30
+ | { kind: "pick-model"; role: OrchestrationRole };
31
+
32
+ export class OrchestrationSettingsComponent {
33
+ private readonly tui: TUI;
34
+ private readonly theme: Theme;
35
+ private readonly keys: KeybindingsManager;
36
+ private readonly settings: OrchestrationSettings;
37
+ private readonly done: (action: OrchestrationSettingsAction) => void;
38
+ private selected = 0;
39
+ private closed = false;
40
+ public changed = false;
41
+
42
+ constructor(
43
+ tui: TUI,
44
+ theme: Theme,
45
+ keys: KeybindingsManager,
46
+ settings: OrchestrationSettings,
47
+ done: (action: OrchestrationSettingsAction) => void,
48
+ ) {
49
+ this.tui = tui;
50
+ this.theme = theme;
51
+ this.keys = keys;
52
+ this.settings = settings;
53
+ this.done = done;
54
+ }
55
+
56
+ private close(action: OrchestrationSettingsAction) {
57
+ if (this.closed) return;
58
+ this.closed = true;
59
+ this.done(action);
60
+ }
61
+
62
+ dispose() {
63
+ this.closed = true;
64
+ }
65
+
66
+ invalidate() {}
67
+
68
+ private cycleThinking(role: OrchestrationRole) {
69
+ const current = this.settings.roles[role].thinking;
70
+ const currentIndex = THINKING.indexOf(current as (typeof THINKING)[number]);
71
+ const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % THINKING.length : 0;
72
+ this.settings.roles[role].thinking = THINKING[nextIndex];
73
+ this.changed = true;
74
+ this.tui.requestRender();
75
+ }
76
+
77
+ handleInput(data: string) {
78
+ if (matchesKey(data, Key.escape)) {
79
+ this.close({ kind: "close" });
80
+ return;
81
+ }
82
+
83
+ if (this.keys.matches(data, "tui.select.up") || data === "k" || matchesKey(data, Key.up)) {
84
+ this.selected = Math.max(0, this.selected - 1);
85
+ this.tui.requestRender();
86
+ return;
87
+ }
88
+
89
+ if (this.keys.matches(data, "tui.select.down") || data === "j" || matchesKey(data, Key.down)) {
90
+ this.selected = Math.min(ORCHESTRATION_ROLES.length - 1, this.selected + 1);
91
+ this.tui.requestRender();
92
+ return;
93
+ }
94
+
95
+ if (matchesKey(data, Key.return) || matchesKey(data, Key.enter) || data === "e") {
96
+ const role = ORCHESTRATION_ROLES[this.selected];
97
+ if (role) this.close({ kind: "pick-model", role });
98
+ return;
99
+ }
100
+
101
+ if (data === "t" || matchesKey(data, Key.tab) || data === " ") {
102
+ const role = ORCHESTRATION_ROLES[this.selected];
103
+ if (role) this.cycleThinking(role);
104
+ return;
105
+ }
106
+
107
+ if (data === "s") {
108
+ this.close({ kind: "save" });
109
+ return;
110
+ }
111
+ }
112
+
113
+ render(width: number): string[] {
114
+ const rows = this.tui.terminal.rows || 30;
115
+ const bodyHeight = Math.max(8, rows - 5);
116
+ const innerWidth = Math.max(1, width - 2);
117
+
118
+ const bodyLines: string[] = [
119
+ ` ${this.theme.fg("muted", "Configure role models and reasoning effort.")}`,
120
+ this.theme.fg("borderMuted", "─".repeat(innerWidth)),
121
+ "",
18
122
  ];
19
- const selected = await ctx.ui.select("Orchestration role models", choices);
20
- if (!selected || selected === "done") break;
21
- const role = ORCHESTRATION_ROLES.find((candidate) => selected.startsWith(`${candidate}:`));
22
- if (!role) continue;
23
- const models = ctx.modelRegistry
24
- .getAll()
25
- .map((model) => `${model.provider}/${model.id}`)
26
- .sort();
27
- const model = await ctx.ui.select(`Model for ${role}`, models);
28
- if (!model) continue;
29
- const thinking = await ctx.ui.select(
30
- `Thinking level for ${role}`,
31
- [...THINKING],
123
+
124
+ for (const [index, role] of ORCHESTRATION_ROLES.entries()) {
125
+ const isSel = index === this.selected;
126
+ const config = this.settings.roles[role] ?? { thinking: "medium" as const };
127
+ const marker = isSel ? this.theme.fg("accent", "❯") : " ";
128
+
129
+ const roleLabel = isSel
130
+ ? this.theme.fg("accent", this.theme.bold(role.padEnd(14)))
131
+ : this.theme.fg("text", role.padEnd(14));
132
+
133
+ const modelText = config.model
134
+ ? this.theme.fg("accent", config.model)
135
+ : this.theme.fg("error", "not configured (press Enter to choose)");
136
+
137
+ const thinkingText = this.theme.fg("dim", `⚡ ${config.thinking ?? "medium"}`);
138
+
139
+ const left = ` ${marker} ${roleLabel} ${modelText}`;
140
+ const row = joinSides(left, `${thinkingText} `, innerWidth);
141
+ bodyLines.push(isSel ? this.theme.bg("selectedBg", row) : row);
142
+ }
143
+
144
+ bodyLines.push("");
145
+ bodyLines.push(` ${this.theme.fg("dim", "Tips: Press Enter to open Searchable Model Picker · Press t / Tab to cycle thinking level")}`);
146
+
147
+ const lines = [
148
+ joinSides(` ${this.theme.fg("accent", this.theme.bold("◆ ORCHESTRATION SETTINGS"))}`, `${this.theme.fg("muted", "5 roles")} `, width),
149
+ frameTop(this.theme, width, "ROLE CONFIGURATION"),
150
+ ];
151
+
152
+ for (let row = 0; row < bodyHeight; row++) {
153
+ lines.push(framedRow(this.theme, bodyLines[row] ?? "", width));
154
+ }
155
+
156
+ lines.push(frameBottom(this.theme, width));
157
+ lines.push(truncateToWidth(this.theme.fg("dim", " j/k navigate · enter pick model · t cycle thinking · s save · esc close"), width, ""));
158
+
159
+ return lines.map((line) => truncateToWidth(line, width, ""));
160
+ }
161
+ }
162
+
163
+ export async function openOrchestrationSettings(ctx: ExtensionContext): Promise<boolean> {
164
+ const settings = loadOrchestrationSettings();
165
+ let changed = false;
166
+
167
+ if (!ctx.hasUI) return false;
168
+
169
+ while (true) {
170
+ let component: OrchestrationSettingsComponent | undefined;
171
+ const action = await ctx.ui.custom<OrchestrationSettingsAction>(
172
+ (tui, theme, keys, done) => {
173
+ component = new OrchestrationSettingsComponent(tui, theme, keys, settings, done);
174
+ return component;
175
+ },
176
+ {
177
+ overlay: true,
178
+ overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
179
+ },
32
180
  );
33
- settings.roles[role] = {
34
- model,
35
- thinking: (thinking as (typeof THINKING)[number] | undefined) ?? settings.roles[role].thinking,
36
- };
181
+
182
+ if (component?.changed) changed = true;
183
+
184
+ if (!action || action.kind === "close") {
185
+ break;
186
+ }
187
+
188
+ if (action.kind === "save") {
189
+ if (changed) {
190
+ saveOrchestrationSettings(settings);
191
+ ctx.ui.notify("Orchestration settings saved.", "info");
192
+ }
193
+ return changed;
194
+ }
195
+
196
+ if (action.kind === "pick-model") {
197
+ const selectedModel = await openModelPicker(ctx, {
198
+ title: `Model for ${action.role}`,
199
+ currentModel: settings.roles[action.role].model,
200
+ });
201
+ if (selectedModel && selectedModel !== settings.roles[action.role].model) {
202
+ settings.roles[action.role].model = selectedModel;
203
+ changed = true;
204
+ }
205
+ }
206
+ }
207
+
208
+ if (changed) {
37
209
  saveOrchestrationSettings(settings);
210
+ ctx.ui.notify("Orchestration settings saved.", "info");
38
211
  }
39
- saveOrchestrationSettings(settings);
40
- ctx.ui.notify("Orchestration settings saved.", "info");
212
+ return changed;
41
213
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
+ import { openModelPicker } from "../shared/model-picker.ts";
4
5
  import {
5
6
  defaultConfig,
6
7
  MEMORY_REASONING_LEVELS,
@@ -181,22 +182,25 @@ export default function piMemory(pi: ExtensionAPI) {
181
182
  return;
182
183
  }
183
184
  const current = config.extractionModel;
184
- const providers = [...new Set(available.map((model) => model.provider))].sort();
185
- providers.sort((a, b) => Number(b === current.provider) - Number(a === current.provider));
186
- const provider = await ctx.ui.select("Pi Memory extraction provider", providers);
187
- if (!provider) return;
185
+ const picked = await openModelPicker(ctx, {
186
+ title: "Pi Memory Extraction Model",
187
+ currentModel: `${current.provider}/${current.model}`,
188
+ });
189
+ if (!picked) return;
188
190
 
189
- const providerModels = available.filter((model) => model.provider === provider);
190
- providerModels.sort((a, b) => Number(b.id === current.model && provider === current.provider) - Number(a.id === current.model && provider === current.provider) || a.id.localeCompare(b.id));
191
- const modelId = await ctx.ui.select("Pi Memory extraction model", providerModels.map((model) => model.id));
192
- if (!modelId) return;
193
- const model = providerModels.find((candidate) => candidate.id === modelId)!;
191
+ const [provider, ...rest] = picked.split("/");
192
+ const modelId = rest.join("/");
193
+ const model = available.find((candidate) => candidate.provider === provider && candidate.id === modelId);
194
+ if (!model) {
195
+ ctx.ui.notify(`Model ${picked} is not available.`, "error");
196
+ return;
197
+ }
194
198
 
195
199
  const levels = model.reasoning ? [...MEMORY_REASONING_LEVELS] : ["off" as const];
196
200
  levels.sort((a, b) => Number(b === current.reasoning) - Number(a === current.reasoning));
197
- const reasoning = await ctx.ui.select("Pi Memory extraction reasoning", levels);
201
+ const reasoning = levels.length > 1 ? await ctx.ui.select("Pi Memory extraction reasoning", levels) : "off";
198
202
  if (!reasoning) return;
199
- selected = { provider, model: modelId, reasoning: reasoning as MemoryReasoningLevel };
203
+ selected = { provider: provider!, model: modelId, reasoning: reasoning as MemoryReasoningLevel };
200
204
  }
201
205
 
202
206
  config.extractionModel = selected;
@@ -9,6 +9,7 @@ Internal runtime utilities used by more than one extension. This directory is no
9
9
  - `child-session.ts` owns trust-aware child resources and bounded session shutdown.
10
10
  - `context-utilization.ts` formats model-context usage and capacity.
11
11
  - `dashboard-state.ts` keeps list selection stable as live rows change.
12
+ - `model-picker.ts` provides a searchable, fuzzy-filtered model selection overlay with provider grouping, display metadata, context sizes, and keyboard navigation.
12
13
  - `settlement-delivery.ts` coordinates asynchronous output in a private package-wide queue and starts one custom-result turn at Pi's safe idle edge, guaranteeing model-visible context without user-authored or follow-up rendering.
13
14
  - `tool-call-timeout.ts` applies cancellation-aware execution limits to registered tools.
14
15
  - `tool-card.ts` provides compact lifecycle call/result cards with expandable tool output.
@@ -0,0 +1,288 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ fuzzyFilter,
4
+ Input,
5
+ Key,
6
+ matchesKey,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ type TUI,
10
+ } from "@earendil-works/pi-tui";
11
+ import { frameBottom, frameTop, framedRow, joinSides, padLine } from "./tui-dashboard.ts";
12
+
13
+ export type ModelPickerItem = {
14
+ provider: string;
15
+ id: string;
16
+ name?: string;
17
+ contextWindow?: number;
18
+ reasoning?: boolean;
19
+ isCurrent?: boolean;
20
+ description?: string;
21
+ value?: string;
22
+ };
23
+
24
+ export type ModelPickerOptions = {
25
+ title?: string;
26
+ currentModel?: string;
27
+ items?: ModelPickerItem[];
28
+ extraChoices?: { id: string; label: string; description?: string; value?: string }[];
29
+ };
30
+
31
+ function formatContextSize(tokens?: number): string {
32
+ if (!tokens || tokens <= 0) return "";
33
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M ctx`;
34
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k ctx`;
35
+ return `${tokens} ctx`;
36
+ }
37
+
38
+ export class ModelPickerComponent {
39
+ private readonly tui: TUI;
40
+ private readonly theme: Theme;
41
+ private readonly keys: KeybindingsManager;
42
+ private readonly done: (result: string | undefined) => void;
43
+ private readonly allItems: ModelPickerItem[];
44
+ private readonly title: string;
45
+ private readonly searchInput: Input;
46
+
47
+ private filteredItems: ModelPickerItem[];
48
+ private selected = 0;
49
+ private closed = false;
50
+
51
+ constructor(
52
+ tui: TUI,
53
+ theme: Theme,
54
+ keys: KeybindingsManager,
55
+ options: ModelPickerOptions,
56
+ items: ModelPickerItem[],
57
+ done: (result: string | undefined) => void,
58
+ ) {
59
+ this.tui = tui;
60
+ this.theme = theme;
61
+ this.keys = keys;
62
+ this.done = done;
63
+ this.allItems = items;
64
+ this.filteredItems = [...items];
65
+ this.title = options.title ?? "Select Model";
66
+ this.searchInput = new Input();
67
+
68
+ // If current model is present, highlight its index initially
69
+ const currentIndex = this.filteredItems.findIndex((item) => item.isCurrent);
70
+ if (currentIndex >= 0) this.selected = currentIndex;
71
+ }
72
+
73
+ private close(result: string | undefined) {
74
+ if (this.closed) return;
75
+ this.closed = true;
76
+ this.done(result);
77
+ }
78
+
79
+ dispose() {
80
+ this.closed = true;
81
+ }
82
+
83
+ invalidate() {}
84
+
85
+ private refilter() {
86
+ const query = this.searchInput.getValue();
87
+ if (!query.trim()) {
88
+ this.filteredItems = [...this.allItems];
89
+ } else {
90
+ this.filteredItems = fuzzyFilter(this.allItems, query, (item) =>
91
+ `${item.provider} ${item.id} ${item.name ?? ""} ${item.description ?? ""}`,
92
+ );
93
+ }
94
+ this.selected = Math.max(0, Math.min(this.selected, this.filteredItems.length - 1));
95
+ }
96
+
97
+ handleInput(data: string) {
98
+ if (matchesKey(data, Key.escape)) {
99
+ this.close(undefined);
100
+ return;
101
+ }
102
+
103
+ if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) {
104
+ const selectedItem = this.filteredItems[this.selected];
105
+ if (selectedItem) {
106
+ this.close(selectedItem.value ?? (selectedItem.provider === "special" ? selectedItem.id : `${selectedItem.provider}/${selectedItem.id}`));
107
+ } else {
108
+ this.close(undefined);
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
114
+ this.selected = Math.max(0, this.selected - 1);
115
+ this.tui.requestRender();
116
+ return;
117
+ }
118
+
119
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
120
+ this.selected = Math.min(Math.max(0, this.filteredItems.length - 1), this.selected + 1);
121
+ this.tui.requestRender();
122
+ return;
123
+ }
124
+
125
+ if (matchesKey(data, Key.pageUp)) {
126
+ const pageSize = Math.max(1, (this.tui.terminal.rows || 30) - 8);
127
+ this.selected = Math.max(0, this.selected - pageSize);
128
+ this.tui.requestRender();
129
+ return;
130
+ }
131
+
132
+ if (matchesKey(data, Key.pageDown)) {
133
+ const pageSize = Math.max(1, (this.tui.terminal.rows || 30) - 8);
134
+ this.selected = Math.min(Math.max(0, this.filteredItems.length - 1), this.selected + pageSize);
135
+ this.tui.requestRender();
136
+ return;
137
+ }
138
+
139
+ // Pass character/editing input to the search box
140
+ const previous = this.searchInput.getValue();
141
+ this.searchInput.handleInput(data);
142
+ if (this.searchInput.getValue() !== previous) {
143
+ this.refilter();
144
+ }
145
+ this.tui.requestRender();
146
+ }
147
+
148
+ render(width: number): string[] {
149
+ const rows = this.tui.terminal.rows || 30;
150
+ const bodyHeight = Math.max(8, rows - 6);
151
+ const innerWidth = Math.max(1, width - 2);
152
+
153
+ const lines: string[] = [];
154
+
155
+ // Search header line
156
+ const searchVal = this.searchInput.getValue();
157
+ const prompt = this.theme.fg("accent", "❯ ");
158
+ const placeholder = searchVal ? "" : this.theme.fg("dim", "type to filter models…");
159
+ const inputContent = `${prompt}${searchVal}${placeholder}`;
160
+
161
+ // Compute scroll window for items
162
+ const listHeight = Math.max(4, bodyHeight - 2); // 2 rows for search bar and separator
163
+ const total = this.filteredItems.length;
164
+ let start = 0;
165
+ if (total > listHeight) {
166
+ start = Math.min(
167
+ Math.max(0, this.selected - Math.floor(listHeight / 2)),
168
+ total - listHeight,
169
+ );
170
+ }
171
+ const visible = this.filteredItems.slice(start, start + listHeight);
172
+
173
+ const bodyLines: string[] = [
174
+ ` ${padLine(inputContent, innerWidth - 2)}`,
175
+ this.theme.fg("borderMuted", "─".repeat(innerWidth)),
176
+ ];
177
+
178
+ if (!visible.length) {
179
+ bodyLines.push(` ${this.theme.fg("muted", "No models match your search.")}`);
180
+ } else {
181
+ for (const [offset, item] of visible.entries()) {
182
+ const index = start + offset;
183
+ const isSel = index === this.selected;
184
+ const marker = isSel ? this.theme.fg("accent", "❯") : item.isCurrent ? this.theme.fg("success", "✓") : " ";
185
+
186
+ const providerBadge = item.provider === "special"
187
+ ? ""
188
+ : `${this.theme.fg("dim", "[")}${this.theme.fg("muted", item.provider)}${this.theme.fg("dim", "]")} `;
189
+
190
+ const nameText = item.name && item.name !== item.id
191
+ ? `${item.name} ${this.theme.fg("dim", `(${item.id})`)}`
192
+ : item.id;
193
+
194
+ const mainText = isSel
195
+ ? this.theme.fg("accent", this.theme.bold(nameText))
196
+ : this.theme.fg("text", nameText);
197
+
198
+ const metaParts: string[] = [];
199
+ if (item.contextWindow) metaParts.push(formatContextSize(item.contextWindow));
200
+ if (item.reasoning) metaParts.push("⚡ thinking");
201
+ if (item.description) metaParts.push(item.description);
202
+ const metaText = metaParts.length ? this.theme.fg("dim", metaParts.join(" · ")) : "";
203
+
204
+ const rowLeft = ` ${marker} ${providerBadge}${mainText}`;
205
+ const row = joinSides(rowLeft, metaText ? `${metaText} ` : " ", innerWidth);
206
+ bodyLines.push(isSel ? this.theme.bg("selectedBg", row) : row);
207
+ }
208
+ }
209
+
210
+ // Add scroll indicators if list overflows
211
+ if (start > 0 && bodyLines.length > 2) {
212
+ bodyLines[2] = ` ${this.theme.fg("dim", `↑ ${start} more`)}`;
213
+ }
214
+ const below = total - start - visible.length;
215
+ if (below > 0 && bodyLines.length > 2) {
216
+ bodyLines[bodyLines.length - 1] = ` ${this.theme.fg("dim", `↓ ${below} more`)}`;
217
+ }
218
+
219
+ // Top status line and framing
220
+ const status = `${this.filteredItems.length} model${this.filteredItems.length === 1 ? "" : "s"}`;
221
+ lines.push(
222
+ joinSides(` ${this.theme.fg("accent", this.theme.bold("◆ " + this.title.toUpperCase()))}`, `${this.theme.fg("muted", status)} `, width),
223
+ frameTop(this.theme, width, this.title),
224
+ );
225
+
226
+ // Frame each line
227
+ for (let i = 0; i < bodyHeight; i++) {
228
+ lines.push(framedRow(this.theme, bodyLines[i] ?? "", width));
229
+ }
230
+
231
+ lines.push(frameBottom(this.theme, width));
232
+ lines.push(truncateToWidth(this.theme.fg("dim", " ↑/↓ navigate · enter select · esc cancel"), width, ""));
233
+
234
+ return lines.map((line) => truncateToWidth(line, width, ""));
235
+ }
236
+ }
237
+
238
+ export function buildModelPickerItems(
239
+ ctx: ExtensionContext,
240
+ options: ModelPickerOptions = {},
241
+ ): ModelPickerItem[] {
242
+ const items: ModelPickerItem[] = [];
243
+
244
+ if (options.extraChoices) {
245
+ for (const choice of options.extraChoices) {
246
+ items.push({
247
+ provider: "special",
248
+ id: choice.id,
249
+ name: choice.label,
250
+ description: choice.description,
251
+ value: choice.value ?? choice.id,
252
+ isCurrent: options.currentModel === choice.id || options.currentModel === choice.value,
253
+ });
254
+ }
255
+ }
256
+
257
+ const all = ctx.modelRegistry.getAll();
258
+ for (const model of all) {
259
+ const fullId = `${model.provider}/${model.id}`;
260
+ items.push({
261
+ provider: model.provider,
262
+ id: model.id,
263
+ name: model.name,
264
+ contextWindow: model.contextWindow,
265
+ reasoning: model.reasoning,
266
+ value: fullId,
267
+ isCurrent: options.currentModel === fullId || options.currentModel === model.id,
268
+ });
269
+ }
270
+
271
+ return items;
272
+ }
273
+
274
+ export async function openModelPicker(
275
+ ctx: ExtensionContext,
276
+ options: ModelPickerOptions = {},
277
+ ): Promise<string | undefined> {
278
+ const items = options.items ?? buildModelPickerItems(ctx, options);
279
+
280
+ return ctx.ui.custom<string | undefined>(
281
+ (tui, theme, keys, done) =>
282
+ new ModelPickerComponent(tui, theme, keys, options, items, done),
283
+ {
284
+ overlay: true,
285
+ overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
286
+ },
287
+ );
288
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  ExtensionUIContext,
6
6
  SessionBeforeCompactEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { openModelPicker } from "../shared/model-picker.ts";
8
9
  import {
9
10
  loadSmartCompactionConfig,
10
11
  saveSmartCompactionConfig,
@@ -110,25 +111,21 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
110
111
  }
111
112
 
112
113
  if (cmdCtx.hasUI) {
113
- const available = cmdCtx.modelRegistry.getAvailable();
114
- const choices = [
115
- `inherit (active session model: ${cmdCtx.model ? `${cmdCtx.model.provider}/${cmdCtx.model.id}` : "none"})`,
116
- ...available.map((m) => `${m.provider}/${m.id}`),
117
- ];
118
-
119
- const selected = await cmdCtx.ui.select(
120
- `Select Compaction Model (current: ${config.model})`,
121
- choices,
122
- );
114
+ const selected = await openModelPicker(cmdCtx as never, {
115
+ title: `Compaction Model (current: ${config.model})`,
116
+ currentModel: config.model,
117
+ extraChoices: [
118
+ {
119
+ id: "inherit",
120
+ label: "inherit",
121
+ description: `Use active session model (${cmdCtx.model ? `${cmdCtx.model.provider}/${cmdCtx.model.id}` : "none"})`,
122
+ },
123
+ ],
124
+ });
123
125
 
124
126
  if (!selected) return;
125
127
 
126
- if (selected.startsWith("inherit")) {
127
- config.model = "inherit";
128
- } else {
129
- config.model = selected;
130
- }
131
-
128
+ config.model = selected;
132
129
  saveSmartCompactionConfig(config, options.configFile);
133
130
  updateStatus();
134
131
  cmdCtx.ui.notify(`Compaction model set to: ${config.model}`, "info");
@@ -39,10 +39,7 @@ import {
39
39
  type SubagentOrigin,
40
40
  type SubagentSnapshot,
41
41
  } from "./src/domain.ts";
42
- import {
43
- formatActivityStatus,
44
- formatContextUtilization,
45
- } from "./src/format.ts";
42
+ import { formatContextUtilization } from "./src/format.ts";
46
43
  import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
47
44
  import {
48
45
  buildSubagentResultMessage,
@@ -514,7 +511,6 @@ export default function (pi: ExtensionAPI) {
514
511
  if (!ui) return;
515
512
  const subs = manager.view.active();
516
513
  const runningSnapshots = subs.filter((snap) => snap.status === "running");
517
- const failed = subs.filter((snap) => snap.status === "error").length;
518
514
  if (activityContext) {
519
515
  setActivitySource(activityContext, "subagents", runningSnapshots.map((snap) => {
520
516
  const activeTool = snap.liveTools.find((tool) => !tool.done)?.name;
@@ -528,14 +524,7 @@ export default function (pi: ExtensionAPI) {
528
524
  };
529
525
  }));
530
526
  }
531
- if (runningSnapshots.length === 0 && failed === 0) {
532
- ui.setStatus("subagents", undefined);
533
- return;
534
- }
535
- ui.setStatus(
536
- "subagents",
537
- formatActivityStatus(ui.theme, { running: runningSnapshots.length, done: 0, failed }),
538
- );
527
+ ui.setStatus("subagents", undefined);
539
528
  };
540
529
 
541
530
  const deliverResult = (snap: SubagentSnapshot) => {
@@ -130,15 +130,9 @@ export default function taskListExtension(pi: ExtensionAPI) {
130
130
  state: (task.status === "in_progress" ? "active" : task.status === "completed" ? "success" : task.status === "blocked" ? "error" : "muted") as ActivityState,
131
131
  priority: task.status === "blocked" ? 100 : task.status === "in_progress" ? 60 : 10,
132
132
  })));
133
- if (active) {
134
- const status = counts.blocked > 0
135
- ? `Tasks ${counts.completed}/${counts.total} · ${counts.blocked} blocked`
136
- : `Tasks ${counts.completed}/${counts.total} · ${counts.inProgress} active`;
137
- ctx.ui.setStatus(ACTIVITY_SOURCE, ctx.ui.theme.fg(counts.blocked > 0 ? "warning" : "accent", status));
138
- return;
139
- }
133
+ ctx.ui.setStatus(ACTIVITY_SOURCE, undefined);
134
+ if (active) return;
140
135
 
141
- ctx.ui.setStatus(ACTIVITY_SOURCE, ctx.ui.theme.fg("success", `Tasks complete ${counts.completed}/${counts.total}`));
142
136
  finishedTimer = setTimeout(() => {
143
137
  finishedTimer = undefined;
144
138
  if (lastCtx !== ctx || hasActiveTasks(state)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",