shariq-pi-extensions 0.2.29 → 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.
- package/docs/EXTENSIONS.md +2 -2
- package/extensions/orchestration/README.md +1 -1
- package/extensions/orchestration/dashboard.ts +58 -24
- package/extensions/orchestration/settings-ui.ts +199 -29
- package/extensions/pi-memory/index.ts +15 -11
- package/extensions/shared/README.md +1 -0
- package/extensions/shared/model-picker.ts +288 -0
- package/extensions/smart-compaction/index.ts +13 -16
- package/package.json +1 -1
package/docs/EXTENSIONS.md
CHANGED
|
@@ -48,9 +48,9 @@ The extension supplies tools including `spawn_agent`, `task`, `check_agent`, `li
|
|
|
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`
|
|
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, 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 runs editors and confirmations outside its 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
|
|
|
@@ -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
|
|
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
|
|
@@ -28,6 +28,7 @@ class OrchestrationDashboard {
|
|
|
28
28
|
private selected = 0;
|
|
29
29
|
private selectedId?: string;
|
|
30
30
|
private detail = false;
|
|
31
|
+
private detailScroll = 0;
|
|
31
32
|
private unsubscribe: () => void;
|
|
32
33
|
private readonly tui: TUI;
|
|
33
34
|
private readonly theme: Theme;
|
|
@@ -74,28 +75,45 @@ class OrchestrationDashboard {
|
|
|
74
75
|
handleInput(data: string) {
|
|
75
76
|
const runs = this.engine.list();
|
|
76
77
|
this.reconcileSelection(runs);
|
|
78
|
+
const rows = this.tui.terminal.rows || 30;
|
|
79
|
+
const bodyHeight = Math.max(8, rows - 5);
|
|
80
|
+
|
|
77
81
|
if (matchesKey(data, Key.escape)) {
|
|
78
|
-
if (this.detail)
|
|
79
|
-
|
|
82
|
+
if (this.detail) {
|
|
83
|
+
this.detail = false;
|
|
84
|
+
this.detailScroll = 0;
|
|
85
|
+
} else {
|
|
80
86
|
this.close({ kind: "close" });
|
|
81
87
|
return;
|
|
82
88
|
}
|
|
83
|
-
} else if (
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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" });
|
|
99
117
|
}
|
|
100
118
|
this.tui.requestRender();
|
|
101
119
|
}
|
|
@@ -107,12 +125,27 @@ class OrchestrationDashboard {
|
|
|
107
125
|
const rows = this.tui.terminal.rows || 30;
|
|
108
126
|
const bodyHeight = Math.max(8, rows - 5);
|
|
109
127
|
const innerWidth = Math.max(1, width - 2);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
|
|
116
149
|
const status = runs.length
|
|
117
150
|
? `${runs.filter((run) => ["planning", "running"].includes(run.status)).length} active · ${runs.length} total`
|
|
118
151
|
: "idle";
|
|
@@ -121,6 +154,7 @@ class OrchestrationDashboard {
|
|
|
121
154
|
selected.status === "awaiting-approval" ? "a approve · f feedback" : "",
|
|
122
155
|
["paused", "interrupted", "blocked"].includes(selected.status) ? "p resume" : selected.status === "running" ? "p pause" : "",
|
|
123
156
|
!["completed", "cancelled"].includes(selected.status) ? "x cancel" : "",
|
|
157
|
+
"j/k scroll",
|
|
124
158
|
"esc back",
|
|
125
159
|
].filter(Boolean).join(" · ")
|
|
126
160
|
: "j/k select · enter open · n new · s settings · esc close";
|
|
@@ -1,43 +1,213 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
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
|
|
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
|
+
"",
|
|
122
|
+
];
|
|
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> {
|
|
11
164
|
const settings = loadOrchestrationSettings();
|
|
12
165
|
let changed = false;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.sort();
|
|
28
|
-
const model = await ctx.ui.select(`Model for ${role}`, models);
|
|
29
|
-
if (!model) continue;
|
|
30
|
-
const thinking = await ctx.ui.select(
|
|
31
|
-
`Thinking level for ${role}`,
|
|
32
|
-
[...THINKING],
|
|
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
|
+
},
|
|
33
180
|
);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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) {
|
|
38
209
|
saveOrchestrationSettings(settings);
|
|
39
|
-
|
|
210
|
+
ctx.ui.notify("Orchestration settings saved.", "info");
|
|
40
211
|
}
|
|
41
|
-
if (changed) ctx.ui.notify("Orchestration settings saved.", "info");
|
|
42
212
|
return changed;
|
|
43
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
193
|
-
|
|
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
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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");
|