pi-delegation-policy 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added live fuzzy model search by provider, model ID, and display name.
10
+
11
+ ### Changed
12
+
13
+ - Replaced the chain of unbounded selectors with one responsive, keyboard-first settings panel.
14
+ - Made session edits explicit drafts with visible sources, bounded scrolling, and safe discard confirmation.
15
+
16
+ ## 0.2.1 - 2026-08-26
17
+
18
+ ### Fixed
19
+
20
+ - Replaced `Ctrl+Alt+D` with the terminal-safe, conflict-free `Alt+G` shortcut.
21
+
5
22
  ## 0.2.0 - 2026-08-26
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A local Pi extension that lets you choose delegation intensity and exact model references for Small, Medium, Large, and an optional UI Design role. It guides the main agent; it does not run, route, or enforce delegated work.
4
4
 
5
- > **Status:** Version 0.2.0 is available from npm.
5
+ > **Status:** Version 0.3.0 is available from npm.
6
6
  >
7
7
  > **Documentation:** Read the [documentation site](https://yivas.github.io/pi-delegation-policy/).
8
8
 
@@ -39,7 +39,11 @@ It supports Pi `0.84.1`. Restart Pi or run `/reload` after installation. To inst
39
39
  /delegate reset Reset this session branch to off
40
40
  ```
41
41
 
42
- `Ctrl+Alt+D` opens the selector when the shortcut is available. There is no separate off shortcut; use `/delegate off` or choose `off` in the selector. Changes apply to the next agent run. An agent already running keeps the system prompt it started with.
42
+ `Alt+G` opens the same editor when the shortcut is available. There is no separate off shortcut; use `/delegate off` or choose `off` in the editor. Changes apply to the next agent run. An agent already running keeps the system prompt it started with.
43
+
44
+ The interactive editor requires Pi's TUI mode; quick `/delegate` arguments remain available in other modes. The editor is one bounded, keyboard-first panel. It shows the effective value and the built-in, global, and session value for each setting. Model fields open a live fuzzy search over provider, model ID, and display name; long catalogs scroll within the terminal instead of extending past the screen. **Use global default** remains available while searching, and UI Design also offers **Disable for this session**.
45
+
46
+ Edits stay in a draft until **Apply changes** is selected or `A` is pressed. **Save effective configuration as defaults** updates the global file without applying the session draft. **Reset draft to off** remains local until Apply. Escape returns from a field editor; closing a modified draft requires explicit discard confirmation.
43
47
 
44
48
  The footer shows `D:OFF`, `D:NORM`, `D:AGG`, or `D:ERR` without replacing Pi's own status.
45
49
 
package/SECURITY.md CHANGED
@@ -14,4 +14,4 @@ Include the affected version or commit, operating system, Pi version, reproducti
14
14
 
15
15
  ## Supported versions
16
16
 
17
- Only the latest published version is supported. Version 0.2.0 is the current supported release.
17
+ Only the latest published version is supported. Version 0.3.0 is the current supported release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-delegation-policy",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "A Pi extension for configurable delegation intensity and exact subagent role model references.",
6
6
  "type": "module",
@@ -0,0 +1,761 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ fuzzyFilter,
5
+ Input,
6
+ matchesKey,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ wrapTextWithAnsi,
10
+ type Component,
11
+ type Focusable,
12
+ type TUI,
13
+ } from "@earendil-works/pi-tui";
14
+ import { resolveDelegateState } from "./config.ts";
15
+ import {
16
+ INTENSITIES,
17
+ PREFERENCES,
18
+ type GlobalDefaults,
19
+ type Intensity,
20
+ type ModelConfigKey,
21
+ type ModelRef,
22
+ type Preference,
23
+ type SessionDelegateState,
24
+ } from "./types.ts";
25
+
26
+ const USE_GLOBAL_DEFAULT = "Use global default";
27
+ const DISABLE_FOR_SESSION = "Disable for this session";
28
+
29
+ const FIELD_IDS = ["intensity", "preference", "small", "medium", "large", "uiDesign"] as const;
30
+ type DelegateField = (typeof FIELD_IDS)[number];
31
+ type EnumField = "intensity" | "preference";
32
+ type PanelAction = "apply" | "save-defaults" | "reset" | "cancel";
33
+ type SettingsItem = DelegateField | PanelAction;
34
+
35
+ type PanelMode =
36
+ | { kind: "settings" }
37
+ | { kind: "enum"; field: EnumField; selected: number }
38
+ | { kind: "model"; field: ModelConfigKey; selected: number; query: string }
39
+ | { kind: "discard-confirm"; selected: number };
40
+
41
+ type ModelChoice =
42
+ | { kind: "global"; key: "global"; label: string; description?: string }
43
+ | { kind: "disabled"; key: "disabled"; label: string; description?: string }
44
+ | { kind: "model"; key: string; label: string; description?: string; reference: ModelRef };
45
+
46
+ export type DelegatePanelResult = "applied" | "cancelled";
47
+
48
+ export interface DelegatePanelOptions {
49
+ tui: TUI;
50
+ theme: Theme;
51
+ global: GlobalDefaults;
52
+ session: SessionDelegateState;
53
+ candidates: Model<Api>[];
54
+ diagnostics: string[];
55
+ onApply: (draft: SessionDelegateState) => Promise<boolean>;
56
+ onSaveDefaults: (draft: SessionDelegateState) => Promise<GlobalDefaults | undefined>;
57
+ onDone: (result: DelegatePanelResult) => void;
58
+ }
59
+
60
+ const SETTINGS_ITEMS: readonly SettingsItem[] = [
61
+ ...FIELD_IDS,
62
+ "apply",
63
+ "save-defaults",
64
+ "reset",
65
+ "cancel",
66
+ ];
67
+
68
+ const FIELD_LABELS: Record<DelegateField, string> = {
69
+ intensity: "Intensity",
70
+ preference: "Preference",
71
+ small: "Small model",
72
+ medium: "Medium model",
73
+ large: "Large model",
74
+ uiDesign: "UI Design",
75
+ };
76
+
77
+ const ACTION_LABELS: Record<PanelAction, string> = {
78
+ apply: "Apply changes",
79
+ "save-defaults": "Save effective configuration as defaults",
80
+ reset: "Reset draft to off",
81
+ cancel: "Cancel",
82
+ };
83
+
84
+ function cloneSession(value: SessionDelegateState): SessionDelegateState {
85
+ return structuredClone(value);
86
+ }
87
+
88
+ function sameModel(left: ModelRef | null | undefined, right: ModelRef | null | undefined): boolean {
89
+ if (left === right) return true;
90
+ if (!left || !right) return false;
91
+ return left.provider === right.provider && left.model === right.model;
92
+ }
93
+
94
+ export function sameSessionState(left: SessionDelegateState, right: SessionDelegateState): boolean {
95
+ return (
96
+ left.intensity === right.intensity &&
97
+ left.preference === right.preference &&
98
+ sameModel(left.small, right.small) &&
99
+ sameModel(left.medium, right.medium) &&
100
+ sameModel(left.large, right.large) &&
101
+ sameModel(left.uiDesign, right.uiDesign)
102
+ );
103
+ }
104
+
105
+ function modelText(reference: ModelRef | undefined): string {
106
+ return reference ? `${reference.provider}/${reference.model}` : "not configured";
107
+ }
108
+
109
+ function rawModelText(reference: ModelRef | null | undefined, missing: string): string {
110
+ if (reference === null) return "disabled";
111
+ return reference ? modelText(reference) : missing;
112
+ }
113
+
114
+ function pad(text: string, width: number): string {
115
+ const clipped = truncateToWidth(text, Math.max(1, width), "");
116
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
117
+ }
118
+
119
+ function selectedLine(theme: Theme, text: string, width: number, selected: boolean): string {
120
+ const line = pad(`${selected ? ">" : " "} ${text}`, width);
121
+ return selected ? theme.bg("selectedBg", theme.fg("accent", line)) : line;
122
+ }
123
+
124
+ function modelKey(reference: ModelRef): string {
125
+ return `${reference.provider}\u0000${reference.model}`;
126
+ }
127
+
128
+ function sortedModels(models: readonly Model<Api>[]): Model<Api>[] {
129
+ return [...models].sort((left, right) => {
130
+ const provider = left.provider.localeCompare(right.provider);
131
+ return provider === 0 ? left.id.localeCompare(right.id) : provider;
132
+ });
133
+ }
134
+
135
+ function visibleBlockRange(blocks: string[][], selected: number, budget: number): [number, number] {
136
+ if (blocks.length === 0) return [0, 0];
137
+ const safeSelected = Math.max(0, Math.min(selected, blocks.length - 1));
138
+ const selectedSize = Math.min(blocks[safeSelected]?.length ?? 1, Math.max(1, budget));
139
+ let start = safeSelected;
140
+ let end = safeSelected + 1;
141
+ let used = selectedSize;
142
+
143
+ while (used < budget && (start > 0 || end < blocks.length)) {
144
+ const below = end < blocks.length ? (blocks[end]?.length ?? 1) : Number.POSITIVE_INFINITY;
145
+ const above = start > 0 ? (blocks[start - 1]?.length ?? 1) : Number.POSITIVE_INFINITY;
146
+ if (below <= above && used + below <= budget) {
147
+ used += below;
148
+ end += 1;
149
+ continue;
150
+ }
151
+ if (used + above <= budget) {
152
+ used += above;
153
+ start -= 1;
154
+ continue;
155
+ }
156
+ if (used + below <= budget) {
157
+ used += below;
158
+ end += 1;
159
+ continue;
160
+ }
161
+ break;
162
+ }
163
+
164
+ return [start, end];
165
+ }
166
+
167
+ export class DelegatePanel implements Component, Focusable {
168
+ private readonly tui: TUI;
169
+ private readonly theme: Theme;
170
+ private readonly candidates: Model<Api>[];
171
+ private readonly diagnostics: string[];
172
+ private readonly onApply: DelegatePanelOptions["onApply"];
173
+ private readonly onSaveDefaults: DelegatePanelOptions["onSaveDefaults"];
174
+ private readonly onDone: DelegatePanelOptions["onDone"];
175
+ private readonly original: SessionDelegateState;
176
+ private readonly searchInput = new Input();
177
+ private global: GlobalDefaults;
178
+ private draft: SessionDelegateState;
179
+ private mode: PanelMode = { kind: "settings" };
180
+ private settingsIndex = 0;
181
+ private working: string | undefined;
182
+ private message: { kind: "info" | "error"; text: string } | undefined;
183
+ private renderWidth = 80;
184
+ private _focused = false;
185
+
186
+ constructor(options: DelegatePanelOptions) {
187
+ this.tui = options.tui;
188
+ this.theme = options.theme;
189
+ this.global = structuredClone(options.global);
190
+ this.original = cloneSession(options.session);
191
+ this.draft = cloneSession(options.session);
192
+ this.candidates = sortedModels(options.candidates);
193
+ this.diagnostics = [...options.diagnostics];
194
+ this.onApply = options.onApply;
195
+ this.onSaveDefaults = options.onSaveDefaults;
196
+ this.onDone = options.onDone;
197
+ }
198
+
199
+ get focused(): boolean {
200
+ return this._focused;
201
+ }
202
+
203
+ set focused(value: boolean) {
204
+ this._focused = value;
205
+ this.syncInputFocus();
206
+ }
207
+
208
+ getDraft(): SessionDelegateState {
209
+ return cloneSession(this.draft);
210
+ }
211
+
212
+ isDirty(): boolean {
213
+ return !sameSessionState(this.original, this.draft);
214
+ }
215
+
216
+ handleInput(data: string): void {
217
+ if (this.working) return;
218
+ this.message = undefined;
219
+
220
+ if (this.isCompact()) {
221
+ this.handleCompactInput(data);
222
+ this.tui.requestRender();
223
+ return;
224
+ }
225
+
226
+ if (this.mode.kind === "settings") this.handleSettingsInput(data);
227
+ else if (this.mode.kind === "enum") this.handleEnumInput(data);
228
+ else if (this.mode.kind === "model") this.handleModelInput(data);
229
+ else this.handleDiscardInput(data);
230
+
231
+ this.syncInputFocus();
232
+ this.tui.requestRender();
233
+ }
234
+
235
+ render(width: number): string[] {
236
+ const safeWidth = Math.max(1, width);
237
+ this.renderWidth = safeWidth;
238
+ const rows = Math.max(1, this.tui.terminal.rows);
239
+ if (safeWidth < 24 || rows < 9) return this.renderCompact(safeWidth, rows);
240
+
241
+ const title = this.renderTitle(safeWidth);
242
+ const footer = this.renderFooter(safeWidth);
243
+ const bodyBudget = Math.max(1, rows - title.length - footer.length);
244
+ const body =
245
+ this.mode.kind === "settings"
246
+ ? this.renderSettings(safeWidth, bodyBudget)
247
+ : this.mode.kind === "enum"
248
+ ? this.renderEnum(safeWidth, bodyBudget, this.mode)
249
+ : this.mode.kind === "model"
250
+ ? this.renderModel(safeWidth, bodyBudget, this.mode)
251
+ : this.renderDiscard(safeWidth, bodyBudget);
252
+ return [...title, ...body, ...footer].slice(0, rows);
253
+ }
254
+
255
+ invalidate(): void {
256
+ this.searchInput.invalidate();
257
+ }
258
+
259
+ private isCompact(): boolean {
260
+ return this.renderWidth < 24 || this.tui.terminal.rows < 9;
261
+ }
262
+
263
+ private handleCompactInput(data: string): void {
264
+ if (this.mode.kind === "discard-confirm") {
265
+ if (this.canRenderDiscardChoices(this.renderWidth, this.tui.terminal.rows)) {
266
+ this.handleDiscardInput(data);
267
+ } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
268
+ this.mode = { kind: "settings" };
269
+ }
270
+ return;
271
+ }
272
+ if (!matchesKey(data, "escape") && !matchesKey(data, "ctrl+c")) return;
273
+ if (this.mode.kind === "settings") this.requestClose();
274
+ else this.mode = { kind: "settings" };
275
+ }
276
+
277
+ private canRenderDiscardChoices(width: number, rows: number): boolean {
278
+ return width >= 17 && rows >= 2;
279
+ }
280
+
281
+ private renderCompact(width: number, rows: number): string[] {
282
+ if (this.mode.kind === "discard-confirm") {
283
+ if (!this.canRenderDiscardChoices(width, rows)) {
284
+ return ["Resize to review changes.", "Esc keeps editing."]
285
+ .map((line) => truncateToWidth(line, width, ""))
286
+ .slice(0, rows);
287
+ }
288
+ const choices = [
289
+ selectedLine(this.theme, "Keep editing", width, this.mode.selected === 0),
290
+ selectedLine(this.theme, "Discard changes", width, this.mode.selected === 1),
291
+ ];
292
+ const lines =
293
+ rows === 2
294
+ ? choices
295
+ : rows === 3
296
+ ? ["Discard changes?", ...choices]
297
+ : [
298
+ this.theme.fg("accent", "Delegation policy"),
299
+ "Discard changes?",
300
+ ...choices,
301
+ this.theme.fg("dim", "↑↓ choose · Enter · Esc keep"),
302
+ ];
303
+ return lines.map((line) => truncateToWidth(line, width, "")).slice(0, rows);
304
+ }
305
+
306
+ const action = this.mode.kind === "settings" ? "Esc reviews changes." : "Esc returns.";
307
+ return [
308
+ this.theme.fg("accent", "Delegation policy"),
309
+ ...(this.isDirty() ? [this.theme.fg("warning", "Modified")] : []),
310
+ "Terminal too small.",
311
+ "Resize to continue editing.",
312
+ action,
313
+ ]
314
+ .map((line) => truncateToWidth(line, width, ""))
315
+ .slice(0, rows);
316
+ }
317
+
318
+ private renderTitle(width: number): string[] {
319
+ const modeLabel =
320
+ this.mode.kind === "settings" || this.mode.kind === "discard-confirm"
321
+ ? "Delegation policy"
322
+ : `Delegation policy / ${FIELD_LABELS[this.mode.field]}`;
323
+ const status = this.working ?? (this.isDirty() ? "Modified" : "");
324
+ const gap = Math.max(1, width - visibleWidth(modeLabel) - visibleWidth(status));
325
+ const statusText = this.theme.fg("warning", status);
326
+ const lines = [
327
+ truncateToWidth(
328
+ this.theme.fg("accent", this.theme.bold(modeLabel)) + " ".repeat(gap) + statusText,
329
+ width,
330
+ "",
331
+ ),
332
+ ];
333
+ const notice =
334
+ this.message ??
335
+ (this.diagnostics.length > 0
336
+ ? { kind: "error" as const, text: "Warning: global defaults are invalid." }
337
+ : undefined);
338
+ if (notice) {
339
+ lines.push(
340
+ truncateToWidth(
341
+ this.theme.fg(notice.kind === "error" ? "error" : "success", notice.text),
342
+ width,
343
+ "",
344
+ ),
345
+ );
346
+ }
347
+ lines.push(this.theme.fg("borderMuted", "─".repeat(width)));
348
+ return lines;
349
+ }
350
+
351
+ private renderFooter(width: number): string[] {
352
+ const hint =
353
+ this.mode.kind === "settings"
354
+ ? "↑↓ move · Enter edit · A apply · Esc close"
355
+ : this.mode.kind === "model"
356
+ ? "Type search · ↑↓ move · PgUp/PgDn · Enter choose · Esc back"
357
+ : this.mode.kind === "discard-confirm"
358
+ ? "↑↓ move · Enter choose · Esc keep editing"
359
+ : "↑↓ move · Enter choose · Esc back";
360
+ return [
361
+ this.theme.fg("borderMuted", "─".repeat(width)),
362
+ truncateToWidth(this.theme.fg("dim", hint), width, ""),
363
+ ];
364
+ }
365
+
366
+ private renderSettings(width: number, budget: number): string[] {
367
+ const effective = resolveDelegateState(this.global, this.draft);
368
+ const blocks = SETTINGS_ITEMS.map((item, index) => {
369
+ const selected = index === this.settingsIndex;
370
+ if (FIELD_IDS.includes(item as DelegateField)) {
371
+ const field = item as DelegateField;
372
+ const value =
373
+ field === "intensity" || field === "preference"
374
+ ? effective[field]
375
+ : field === "uiDesign"
376
+ ? effective.uiDesign
377
+ ? modelText(effective.uiDesign)
378
+ : "disabled"
379
+ : modelText(effective[field]);
380
+ const details = this.sourceDetails(field);
381
+ if (width < 48) {
382
+ return [
383
+ selectedLine(this.theme, FIELD_LABELS[field], width, selected),
384
+ ...wrapTextWithAnsi(` ${value}`, width),
385
+ ...details.flatMap((line) =>
386
+ wrapTextWithAnsi(this.theme.fg("dim", ` ${line}`), width),
387
+ ),
388
+ ];
389
+ }
390
+ const labelWidth = 16;
391
+ const first = `${FIELD_LABELS[field].padEnd(labelWidth)}${value}`;
392
+ return [
393
+ selectedLine(this.theme, first, width, selected),
394
+ ...wrapTextWithAnsi(this.theme.fg("dim", ` ${details.join(" · ")}`), width),
395
+ ];
396
+ }
397
+ const action = item as PanelAction;
398
+ const disabled = action === "apply" && !this.isDirty();
399
+ const label = disabled ? `${ACTION_LABELS[action]} (no changes)` : ACTION_LABELS[action];
400
+ const line = selectedLine(this.theme, label, width, selected);
401
+ return [disabled ? this.theme.fg("dim", line) : line];
402
+ });
403
+
404
+ return this.renderBlockViewport(blocks, this.settingsIndex, width, budget);
405
+ }
406
+
407
+ private sourceDetails(field: DelegateField): string[] {
408
+ if (field === "intensity") {
409
+ return [
410
+ "built-in off",
411
+ `global ${this.global.intensity ?? "—"}`,
412
+ `session ${this.draft.intensity ?? "inherit"}`,
413
+ ];
414
+ }
415
+ if (field === "preference") {
416
+ return [
417
+ "built-in standard",
418
+ `global ${this.global.preference ?? "—"}`,
419
+ `session ${this.draft.preference ?? "inherit"}`,
420
+ ];
421
+ }
422
+ if (field === "uiDesign") {
423
+ return [
424
+ "built-in disabled",
425
+ `global ${rawModelText(this.global.uiDesign, "—")}`,
426
+ `session ${rawModelText(this.draft.uiDesign, "inherit")}`,
427
+ ];
428
+ }
429
+ return [
430
+ "built-in —",
431
+ `global ${rawModelText(this.global[field], "—")}`,
432
+ `session ${rawModelText(this.draft[field], "inherit")}`,
433
+ ];
434
+ }
435
+
436
+ private renderEnum(
437
+ width: number,
438
+ budget: number,
439
+ mode: Extract<PanelMode, { kind: "enum" }>,
440
+ ): string[] {
441
+ const values = mode.field === "intensity" ? INTENSITIES : PREFERENCES;
442
+ const options = [
443
+ `${USE_GLOBAL_DEFAULT} (${this.global[mode.field] ?? (mode.field === "intensity" ? "off" : "standard")})`,
444
+ ...values,
445
+ ];
446
+ const blocks = options.map((option, index) => [
447
+ selectedLine(this.theme, option, width, index === mode.selected),
448
+ ]);
449
+ return this.renderBlockViewport(blocks, mode.selected, width, budget);
450
+ }
451
+
452
+ private renderModel(
453
+ width: number,
454
+ budget: number,
455
+ mode: Extract<PanelMode, { kind: "model" }>,
456
+ ): string[] {
457
+ const inputWidth = Math.max(1, width - 8);
458
+ const [input = ""] = this.searchInput.render(inputWidth);
459
+ const inputText = input.startsWith("> ") ? input.slice(2) : input;
460
+ const choices = this.modelChoices(mode.field, mode.query);
461
+ const pinnedCount = mode.field === "uiDesign" ? 2 : 1;
462
+ const pinned = choices.slice(0, pinnedCount);
463
+ const models = choices.slice(pinnedCount);
464
+ const pinnedLines = pinned.map((choice, index) =>
465
+ selectedLine(
466
+ this.theme,
467
+ choice.description ? `${choice.label} (${choice.description})` : choice.label,
468
+ width,
469
+ mode.selected === index,
470
+ ),
471
+ );
472
+ const dividerRows = budget > pinnedLines.length + 3 ? 1 : 0;
473
+ const listBudget = Math.max(0, budget - 2 - pinnedLines.length - dividerRows);
474
+ const showDescriptions = listBudget >= 3;
475
+ const modelBlocks = models.map((choice, index) => {
476
+ const combinedIndex = index + pinnedCount;
477
+ const lines = [
478
+ selectedLine(this.theme, choice.label, width, mode.selected === combinedIndex),
479
+ ];
480
+ if (showDescriptions && choice.description) {
481
+ lines.push(truncateToWidth(this.theme.fg("muted", ` ${choice.description}`), width, ""));
482
+ }
483
+ return lines;
484
+ });
485
+ const modelSelected = Math.max(0, mode.selected - pinnedCount);
486
+ const modelLines =
487
+ listBudget > 0 && modelBlocks.length > 0
488
+ ? this.renderBlockViewport(modelBlocks, modelSelected, width, listBudget)
489
+ : [];
490
+ if (models.length === 0 && modelLines.length < listBudget) {
491
+ modelLines.push(
492
+ truncateToWidth(
493
+ this.theme.fg(
494
+ "warning",
495
+ mode.query ? `No models match “${mode.query}”.` : "No available models.",
496
+ ),
497
+ width,
498
+ "",
499
+ ),
500
+ );
501
+ }
502
+ return [
503
+ truncateToWidth(`Search: ${inputText}`, width, ""),
504
+ "",
505
+ ...pinnedLines,
506
+ ...(dividerRows ? [this.theme.fg("borderMuted", "─".repeat(width))] : []),
507
+ ...modelLines,
508
+ ].slice(0, budget);
509
+ }
510
+
511
+ private renderDiscard(width: number, budget: number): string[] {
512
+ const options = ["Keep editing", "Discard changes"];
513
+ const selected = this.mode.kind === "discard-confirm" ? this.mode.selected : 0;
514
+ const lines = [truncateToWidth("Discard unapplied changes?", width, ""), ""];
515
+ for (const [index, option] of options.entries()) {
516
+ lines.push(selectedLine(this.theme, option, width, index === selected));
517
+ }
518
+ return lines.slice(0, budget);
519
+ }
520
+
521
+ private renderBlockViewport(
522
+ blocks: string[][],
523
+ selected: number,
524
+ width: number,
525
+ budget: number,
526
+ ): string[] {
527
+ const totalRows = blocks.reduce((sum, block) => sum + block.length, 0);
528
+ const reserveIndicator = totalRows > budget ? 1 : 0;
529
+ const contentBudget = Math.max(1, budget - reserveIndicator);
530
+ const [start, end] = visibleBlockRange(blocks, selected, contentBudget);
531
+ const lines = blocks
532
+ .slice(start, end)
533
+ .flat()
534
+ .map((line) => truncateToWidth(line, width, ""));
535
+ if (reserveIndicator && lines.length < budget) {
536
+ lines.push(
537
+ truncateToWidth(
538
+ this.theme.fg("dim", ` ${start + 1}–${end} of ${blocks.length}`),
539
+ width,
540
+ "",
541
+ ),
542
+ );
543
+ }
544
+ return lines.slice(0, budget);
545
+ }
546
+
547
+ private handleSettingsInput(data: string): void {
548
+ if (matchesKey(data, "up")) this.moveSettings(-1);
549
+ else if (matchesKey(data, "down")) this.moveSettings(1);
550
+ else if (matchesKey(data, "home")) this.settingsIndex = 0;
551
+ else if (matchesKey(data, "end")) this.settingsIndex = SETTINGS_ITEMS.length - 1;
552
+ else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.requestClose();
553
+ else if (matchesKey(data, "enter") || matchesKey(data, "space")) this.activateSettingsItem();
554
+ else if (data.toLowerCase() === "a") void this.applyDraft();
555
+ }
556
+
557
+ private moveSettings(delta: number): void {
558
+ this.settingsIndex =
559
+ (this.settingsIndex + delta + SETTINGS_ITEMS.length) % SETTINGS_ITEMS.length;
560
+ }
561
+
562
+ private activateSettingsItem(): void {
563
+ const item = SETTINGS_ITEMS[this.settingsIndex];
564
+ if (!item) return;
565
+ if (item === "intensity" || item === "preference") {
566
+ const values = item === "intensity" ? INTENSITIES : PREFERENCES;
567
+ const current = this.draft[item];
568
+ this.mode = {
569
+ kind: "enum",
570
+ field: item,
571
+ selected: current ? values.indexOf(current as never) + 1 : 0,
572
+ };
573
+ return;
574
+ }
575
+ if (item === "small" || item === "medium" || item === "large" || item === "uiDesign") {
576
+ this.openModelSelector(item);
577
+ return;
578
+ }
579
+ if (item === "apply") void this.applyDraft();
580
+ else if (item === "save-defaults") void this.saveDefaults();
581
+ else if (item === "reset") this.draft = { schemaVersion: 2, intensity: "off" };
582
+ else this.requestClose();
583
+ }
584
+
585
+ private handleEnumInput(data: string): void {
586
+ if (this.mode.kind !== "enum") return;
587
+ const values = this.mode.field === "intensity" ? INTENSITIES : PREFERENCES;
588
+ const count = values.length + 1;
589
+ if (matchesKey(data, "up")) this.mode.selected = (this.mode.selected - 1 + count) % count;
590
+ else if (matchesKey(data, "down")) this.mode.selected = (this.mode.selected + 1) % count;
591
+ else if (matchesKey(data, "home")) this.mode.selected = 0;
592
+ else if (matchesKey(data, "end")) this.mode.selected = count - 1;
593
+ else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
594
+ this.mode = { kind: "settings" };
595
+ else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
596
+ const selected = this.mode.selected;
597
+ const field = this.mode.field;
598
+ if (selected === 0) delete this.draft[field];
599
+ else if (field === "intensity") this.draft.intensity = values[selected - 1] as Intensity;
600
+ else this.draft.preference = values[selected - 1] as Preference;
601
+ this.mode = { kind: "settings" };
602
+ }
603
+ }
604
+
605
+ private openModelSelector(field: ModelConfigKey): void {
606
+ this.searchInput.setValue("");
607
+ const choices = this.modelChoices(field, "");
608
+ const current = this.draft[field];
609
+ let selected = 0;
610
+ if (current === null) selected = choices.findIndex((choice) => choice.kind === "disabled");
611
+ else if (current) selected = choices.findIndex((choice) => choice.key === modelKey(current));
612
+ this.mode = { kind: "model", field, selected: Math.max(0, selected), query: "" };
613
+ }
614
+
615
+ private handleModelInput(data: string): void {
616
+ if (this.mode.kind !== "model") return;
617
+ const choices = this.modelChoices(this.mode.field, this.mode.query);
618
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
619
+ this.mode = { kind: "settings" };
620
+ return;
621
+ }
622
+ if (matchesKey(data, "up")) this.moveModelSelection(-1, choices.length);
623
+ else if (matchesKey(data, "down")) this.moveModelSelection(1, choices.length);
624
+ else if (matchesKey(data, "pageUp"))
625
+ this.moveModelSelection(-this.modelPageSize(), choices.length);
626
+ else if (matchesKey(data, "pageDown"))
627
+ this.moveModelSelection(this.modelPageSize(), choices.length);
628
+ else if (matchesKey(data, "enter")) this.chooseModel(choices[this.mode.selected]);
629
+ else {
630
+ const previous = choices[this.mode.selected];
631
+ this.searchInput.handleInput(data);
632
+ this.mode.query = this.searchInput.getValue();
633
+ const nextChoices = this.modelChoices(this.mode.field, this.mode.query);
634
+ const preserved =
635
+ previous?.kind === "model"
636
+ ? nextChoices.findIndex((choice) => choice.key === previous.key)
637
+ : -1;
638
+ this.mode.selected = preserved >= 0 ? preserved : 0;
639
+ }
640
+ }
641
+
642
+ private moveModelSelection(delta: number, count: number): void {
643
+ if (this.mode.kind !== "model" || count === 0) return;
644
+ this.mode.selected = Math.max(0, Math.min(count - 1, this.mode.selected + delta));
645
+ }
646
+
647
+ private modelPageSize(): number {
648
+ return Math.max(1, Math.floor((this.tui.terminal.rows - 8) / 2));
649
+ }
650
+
651
+ private chooseModel(choice: ModelChoice | undefined): void {
652
+ if (!choice || this.mode.kind !== "model") return;
653
+ const field = this.mode.field;
654
+ if (choice.kind === "global") delete this.draft[field];
655
+ else if (choice.kind === "disabled" && field === "uiDesign") this.draft.uiDesign = null;
656
+ else if (choice.kind === "model") this.draft[field] = { ...choice.reference };
657
+ this.mode = { kind: "settings" };
658
+ }
659
+
660
+ private modelChoices(field: ModelConfigKey, query: string): ModelChoice[] {
661
+ const global = this.global[field];
662
+ const pinned: ModelChoice[] = [
663
+ {
664
+ kind: "global",
665
+ key: "global",
666
+ label: USE_GLOBAL_DEFAULT,
667
+ ...(global ? { description: modelText(global) } : {}),
668
+ },
669
+ ];
670
+ if (field === "uiDesign") {
671
+ pinned.push({ kind: "disabled", key: "disabled", label: DISABLE_FOR_SESSION });
672
+ }
673
+ const filtered = query
674
+ ? fuzzyFilter(this.candidates, query, (model) =>
675
+ `${model.provider}/${model.id} ${model.name ?? ""}`.trim(),
676
+ )
677
+ : this.candidates;
678
+ return [
679
+ ...pinned,
680
+ ...filtered.map<ModelChoice>((model) => ({
681
+ kind: "model",
682
+ key: modelKey({ provider: model.provider, model: model.id }),
683
+ label: `${model.provider}/${model.id}`,
684
+ ...(model.name && model.name !== model.id ? { description: model.name } : {}),
685
+ reference: { provider: model.provider, model: model.id },
686
+ })),
687
+ ];
688
+ }
689
+
690
+ private handleDiscardInput(data: string): void {
691
+ if (this.mode.kind !== "discard-confirm") return;
692
+ if (matchesKey(data, "up") || matchesKey(data, "down"))
693
+ this.mode.selected = this.mode.selected === 0 ? 1 : 0;
694
+ else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
695
+ this.mode = { kind: "settings" };
696
+ else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
697
+ if (this.mode.selected === 0) this.mode = { kind: "settings" };
698
+ else this.onDone("cancelled");
699
+ }
700
+ }
701
+
702
+ private requestClose(): void {
703
+ if (this.isDirty()) this.mode = { kind: "discard-confirm", selected: 0 };
704
+ else this.onDone("cancelled");
705
+ }
706
+
707
+ private async applyDraft(): Promise<void> {
708
+ if (!this.isDirty()) {
709
+ this.message = { kind: "info", text: "No changes to apply." };
710
+ this.tui.requestRender();
711
+ return;
712
+ }
713
+ this.working = "Applying changes…";
714
+ this.tui.requestRender();
715
+ try {
716
+ if (await this.onApply(cloneSession(this.draft))) {
717
+ this.onDone("applied");
718
+ return;
719
+ }
720
+ this.message = { kind: "error", text: "Could not apply session settings. Try again." };
721
+ } catch {
722
+ this.message = { kind: "error", text: "Could not apply session settings. Try again." };
723
+ } finally {
724
+ this.working = undefined;
725
+ this.tui.requestRender();
726
+ }
727
+ }
728
+
729
+ private async saveDefaults(): Promise<void> {
730
+ this.working = "Saving defaults…";
731
+ this.tui.requestRender();
732
+ try {
733
+ const saved = await this.onSaveDefaults(cloneSession(this.draft));
734
+ if (saved) {
735
+ this.global = structuredClone(saved);
736
+ this.diagnostics.length = 0;
737
+ this.message = {
738
+ kind: "info",
739
+ text: "Saved effective delegation settings as global defaults.",
740
+ };
741
+ } else {
742
+ this.message = {
743
+ kind: "error",
744
+ text: "Could not save global defaults. Session settings were not changed.",
745
+ };
746
+ }
747
+ } catch {
748
+ this.message = {
749
+ kind: "error",
750
+ text: "Could not save global defaults. Session settings were not changed.",
751
+ };
752
+ } finally {
753
+ this.working = undefined;
754
+ this.tui.requestRender();
755
+ }
756
+ }
757
+
758
+ private syncInputFocus(): void {
759
+ this.searchInput.focused = this._focused && this.mode.kind === "model";
760
+ }
761
+ }
package/src/index.ts CHANGED
@@ -125,7 +125,7 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
125
125
  },
126
126
  });
127
127
 
128
- pi.registerShortcut("ctrl+alt+d", {
128
+ pi.registerShortcut("alt+g", {
129
129
  description: "Open delegation policy",
130
130
  handler: async (ctx) => openEditor(pi, ctx),
131
131
  });
package/src/ui.ts CHANGED
@@ -5,209 +5,57 @@ import {
5
5
  resolveDelegateState,
6
6
  writeConfig,
7
7
  } from "./config.ts";
8
- import {
9
- formatModelRef,
10
- loadRuntime,
11
- modelCandidates,
12
- sessionEntry,
13
- type RuntimeState,
14
- } from "./runtime.ts";
15
- import {
16
- INTENSITIES,
17
- MODEL_ROLES,
18
- PREFERENCES,
19
- ROLE_LABELS,
20
- type Intensity,
21
- type ModelRef,
22
- type ModelRole,
23
- type Preference,
24
- type SessionDelegateState,
25
- type ValueSource,
26
- } from "./types.ts";
27
-
28
- const USE_GLOBAL_DEFAULT = "Use global default";
29
- const DISABLE_FOR_SESSION = "Disable for this session";
30
- const APPLY_TO_SESSION = "Apply changes to this session";
31
- const SAVE_AS_DEFAULTS = "Save effective configuration as defaults";
32
- const RESET_SESSION = "Reset draft to off";
33
- const CANCEL = "Cancel";
34
-
35
- type ModelSelection =
36
- { kind: "global" } | { kind: "disabled" } | { kind: "model"; reference: ModelRef } | undefined;
37
-
38
- function clone<T>(value: T): T {
39
- return structuredClone(value);
40
- }
41
-
42
- function sourceLabel(source: ValueSource): string {
43
- if (source === "session") return "session";
44
- if (source === "global") return "global";
45
- return "built-in";
46
- }
47
-
48
- function modelOption(reference: ModelRef): string {
49
- return `${reference.provider}/${reference.model}`;
50
- }
51
-
52
- async function selectOrCancel(
53
- ctx: ExtensionContext,
54
- title: string,
55
- options: string[],
56
- ): Promise<string | undefined> {
57
- if (!ctx.hasUI) return undefined;
58
- return ctx.ui.select(title, options);
59
- }
60
-
61
- async function selectModel(
62
- ctx: ExtensionContext,
63
- title: string,
64
- options: { includeDisable: boolean },
65
- ): Promise<ModelSelection> {
66
- const candidateOptions = new Map<string, ModelRef>();
67
- for (const [index, model] of modelCandidates(ctx)
68
- .sort((left, right) => {
69
- const provider = left.provider.localeCompare(right.provider);
70
- return provider === 0 ? left.id.localeCompare(right.id) : provider;
71
- })
72
- .entries()) {
73
- const option = `${index + 1}. ${model.provider}/${model.id}${
74
- model.name ? ` (${model.name})` : ""
75
- }`;
76
- candidateOptions.set(option, { provider: model.provider, model: model.id });
77
- }
78
-
79
- const selected = await selectOrCancel(ctx, title, [
80
- USE_GLOBAL_DEFAULT,
81
- ...(options.includeDisable ? [DISABLE_FOR_SESSION] : []),
82
- ...candidateOptions.keys(),
83
- ]);
84
- if (!selected) return undefined;
85
- if (selected === USE_GLOBAL_DEFAULT) return { kind: "global" };
86
- if (selected === DISABLE_FOR_SESSION) return { kind: "disabled" };
87
- const reference = candidateOptions.get(selected);
88
- return reference ? { kind: "model", reference } : undefined;
89
- }
90
-
91
- async function selectRole(
92
- ctx: ExtensionContext,
93
- draft: SessionDelegateState,
94
- role: ModelRole,
95
- ): Promise<void> {
96
- const selection = await selectModel(ctx, `${ROLE_LABELS[role]} model`, { includeDisable: false });
97
- if (!selection) return;
98
- if (selection.kind === "global") delete draft[role];
99
- else if (selection.kind === "model") draft[role] = selection.reference;
100
- }
101
-
102
- async function selectUiDesign(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
103
- const selection = await selectModel(ctx, "UI Design model", { includeDisable: true });
104
- if (!selection) return;
105
- if (selection.kind === "global") delete draft.uiDesign;
106
- else if (selection.kind === "disabled") draft.uiDesign = null;
107
- else draft.uiDesign = selection.reference;
108
- }
109
-
110
- async function selectIntensity(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
111
- const selected = await selectOrCancel(ctx, "Delegation intensity", [
112
- USE_GLOBAL_DEFAULT,
113
- ...INTENSITIES,
114
- ]);
115
- if (!selected) return;
116
- if (selected === USE_GLOBAL_DEFAULT) delete draft.intensity;
117
- else draft.intensity = selected as Intensity;
118
- }
119
-
120
- async function selectPreference(ctx: ExtensionContext, draft: SessionDelegateState): Promise<void> {
121
- const selected = await selectOrCancel(ctx, "Model preference", [
122
- USE_GLOBAL_DEFAULT,
123
- ...PREFERENCES,
124
- ]);
125
- if (!selected) return;
126
- if (selected === USE_GLOBAL_DEFAULT) delete draft.preference;
127
- else draft.preference = selected as Preference;
128
- }
129
-
130
- function menuOptions(state: RuntimeState, draft: SessionDelegateState): string[] {
131
- const effective = resolveDelegateState(state.global, draft);
132
- const options = [
133
- `Intensity: ${effective.intensity} (${sourceLabel(effective.source.intensity)})`,
134
- `Preference: ${effective.preference} (${sourceLabel(effective.source.preference)})`,
135
- ...MODEL_ROLES.map(
136
- (role) =>
137
- `${ROLE_LABELS[role]}: ${formatModelRef(effective[role])} (${sourceLabel(
138
- effective.source[role],
139
- )})`,
140
- ),
141
- `UI Design: ${effective.uiDesign ? "on" : "off"} (${sourceLabel(effective.source.uiDesign)})`,
142
- ];
143
-
144
- if (effective.uiDesign) {
145
- options.push(
146
- `UI Design model: ${modelOption(effective.uiDesign)} (${sourceLabel(
147
- effective.source.uiDesign,
148
- )}; visual design only)`,
149
- );
150
- }
151
-
152
- return [...options, APPLY_TO_SESSION, SAVE_AS_DEFAULTS, RESET_SESSION, CANCEL];
153
- }
154
-
155
- function startsWithOption(choice: string, name: string): boolean {
156
- return choice.startsWith(`${name}:`);
157
- }
8
+ import { DelegatePanel, type DelegatePanelResult } from "./delegate-panel.ts";
9
+ import { loadRuntime, modelCandidates, sessionEntry } from "./runtime.ts";
158
10
 
159
11
  export async function openDelegateEditor(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
160
12
  if (!ctx.hasUI) return;
13
+ if (ctx.mode !== "tui") {
14
+ ctx.ui.notify(
15
+ "The delegation editor requires TUI mode. Use /delegate off, normal, aggressive, status, or reset here.",
16
+ "warning",
17
+ );
18
+ return;
19
+ }
161
20
 
162
21
  const state = await loadRuntime(ctx);
163
- let draft = clone(state.session);
164
-
165
- while (true) {
166
- const effective = resolveDelegateState(state.global, draft);
167
- const selected = await selectOrCancel(ctx, "Delegation policy", menuOptions(state, draft));
168
- if (!selected || selected === CANCEL) return;
169
-
170
- if (startsWithOption(selected, "Intensity")) {
171
- await selectIntensity(ctx, draft);
172
- continue;
173
- }
174
- if (startsWithOption(selected, "Preference")) {
175
- await selectPreference(ctx, draft);
176
- continue;
177
- }
178
- const role = MODEL_ROLES.find((candidate) =>
179
- startsWithOption(selected, ROLE_LABELS[candidate]),
180
- );
181
- if (role) {
182
- await selectRole(ctx, draft, role);
183
- continue;
184
- }
185
- if (startsWithOption(selected, "UI Design model") || startsWithOption(selected, "UI Design")) {
186
- await selectUiDesign(ctx, draft);
187
- continue;
188
- }
189
- if (selected === APPLY_TO_SESSION) {
190
- state.session = clone(draft);
191
- sessionEntry(pi, state);
192
- ctx.ui.notify("Saved delegation settings for this session branch.", "info");
193
- return;
194
- }
195
- if (selected === SAVE_AS_DEFAULTS) {
196
- try {
197
- const defaults = defaultsFromEffectiveState(effective);
198
- await writeConfig(getGlobalConfigPath(), defaults);
199
- state.global = defaults;
200
- state.diagnostics = [];
201
- ctx.ui.notify("Saved effective delegation settings as global defaults.", "info");
202
- } catch {
203
- ctx.ui.notify(
204
- "Could not save global defaults. Session settings were not changed.",
205
- "error",
206
- );
207
- }
208
- }
209
- if (selected === RESET_SESSION) {
210
- draft = { schemaVersion: 2, intensity: "off" };
22
+ const candidates = modelCandidates(ctx);
23
+
24
+ const result = await ctx.ui.custom<DelegatePanelResult>(
25
+ (tui, theme, _keybindings, done) =>
26
+ new DelegatePanel({
27
+ tui,
28
+ theme,
29
+ global: state.global,
30
+ session: state.session,
31
+ candidates,
32
+ diagnostics: state.diagnostics.map((diagnostic) => diagnostic.message),
33
+ onApply: async (draft) => {
34
+ const session = structuredClone(draft);
35
+ sessionEntry(pi, { ...state, session });
36
+ state.session = session;
37
+ return true;
38
+ },
39
+ onSaveDefaults: async (draft) => {
40
+ try {
41
+ const defaults = defaultsFromEffectiveState(resolveDelegateState(state.global, draft));
42
+ await writeConfig(getGlobalConfigPath(), defaults);
43
+ state.global = defaults;
44
+ state.diagnostics = [];
45
+ return defaults;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ },
50
+ onDone: done,
51
+ }),
52
+ );
53
+
54
+ if (result === "applied") {
55
+ try {
56
+ ctx.ui.notify("Applied delegation settings to this session branch.", "info");
57
+ } catch {
58
+ // The session entry is authoritative; notification failure must not invite a retry.
211
59
  }
212
60
  }
213
61
  }