@jwilger/pi-development-system 0.48.0 → 0.51.1

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.
@@ -1,4 +1,9 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
1
+ // biome-ignore-all lint/complexity/noVoid: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
+ // biome-ignore-all lint/correctness/noVoidTypeReturn: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
3
+ // biome-ignore-all lint/suspicious/noControlCharactersInRegex: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
4
+ // biome-ignore-all lint/suspicious/noEmptyBlockStatements: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
5
+ // biome-ignore-all lint/suspicious/noUnnecessaryConditions: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
6
+ // biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
7
  import type {
3
8
  ExtensionContext,
4
9
  ExtensionUIContext,
@@ -6,6 +11,7 @@ import type {
6
11
  Theme,
7
12
  } from "@earendil-works/pi-coding-agent";
8
13
  import {
14
+ type Component,
9
15
  CURSOR_MARKER,
10
16
  Editor,
11
17
  getKeybindings,
@@ -13,11 +19,10 @@ import {
13
19
  Key,
14
20
  matchesKey,
15
21
  stripTerminalSequences,
22
+ type TUI,
16
23
  truncateToWidth,
17
24
  visibleWidth,
18
25
  wrapTextWithAnsi,
19
- type Component,
20
- type TUI,
21
26
  } from "@earendil-works/pi-tui";
22
27
 
23
28
  export interface DialogHost {
@@ -31,10 +36,10 @@ export interface DialogRow {
31
36
  id: string;
32
37
  label: string;
33
38
  /** Trusted formatter receives sanitized text; generated ANSI styling is kept when clipping. */
34
- renderLabel?: (label: string, theme: Theme) => string;
35
- value?: string;
36
- valueColor?: Parameters<Theme["fg"]>[0];
37
- help?: string;
39
+ renderLabel?: ((label: string, theme: Theme) => string) | undefined;
40
+ value?: string | undefined;
41
+ valueColor?: Parameters<Theme["fg"]>[0] | undefined;
42
+ help?: string | undefined;
38
43
  }
39
44
  export const DIALOG_OPTIONS = {
40
45
  overlay: true,
@@ -69,18 +74,14 @@ export function frameDialog(
69
74
  const clipped = truncateToWidth(text, Math.max(0, columns), "", true);
70
75
  return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
71
76
  };
72
- if (width < 4 || height < 4)
73
- return [fit(theme.fg("muted", dialogText(title)), width)];
77
+ if (width < 4 || height < 4) return [fit(theme.fg("muted", dialogText(title)), width)];
74
78
  const inner = width - 2;
75
79
  const line = (text: string) =>
76
80
  theme.fg("border", "│") + fit(text, inner) + theme.fg("border", "│");
77
81
  const heading = truncateToWidth(` ${dialogText(title)} `, inner, "");
78
82
  const top = theme.fg(
79
83
  "border",
80
- "╭" +
81
- heading +
82
- "─".repeat(Math.max(0, inner - visibleWidth(heading))) +
83
- "╮",
84
+ `╭${heading}${"─".repeat(Math.max(0, inner - visibleWidth(heading)))}╮`,
84
85
  );
85
86
  // Pad to the full frame so centered overlays keep the same bounds between views.
86
87
  const budget = height - 4;
@@ -90,9 +91,9 @@ export function frameDialog(
90
91
  return [
91
92
  top,
92
93
  ...content,
93
- theme.fg("border", "├" + "─".repeat(inner) + "┤"),
94
+ theme.fg("border", `├${"─".repeat(inner)}┤`),
94
95
  line(theme.fg("dim", dialogText(footer))),
95
- theme.fg("border", "╰" + "─".repeat(inner) + "╯"),
96
+ theme.fg("border", `╰${"─".repeat(inner)}╯`),
96
97
  ];
97
98
  }
98
99
 
@@ -100,13 +101,24 @@ export function frameDialog(
100
101
  class DialogEditor {
101
102
  private editor: Editor;
102
103
  private initialText: string;
104
+ private host: DialogEditorHost;
105
+ private theme: Theme;
106
+ readonly title: string;
107
+ readonly prefill: string;
108
+ private done: (value: string | undefined) => void;
109
+
103
110
  constructor(
104
- private host: DialogEditorHost,
105
- private theme: Theme,
106
- readonly title: string,
107
- readonly prefill: string,
108
- private done: (value: string | undefined) => void,
111
+ host: DialogEditorHost,
112
+ theme: Theme,
113
+ title: string,
114
+ prefill: string,
115
+ done: (value: string | undefined) => void,
109
116
  ) {
117
+ this.host = host;
118
+ this.theme = theme;
119
+ this.title = title;
120
+ this.prefill = prefill;
121
+ this.done = done;
110
122
  this.editor = new Editor(
111
123
  host as ConstructorParameters<typeof Editor>[0],
112
124
  {
@@ -173,8 +185,7 @@ export function dialogEditor(
173
185
  prefill: string,
174
186
  ): Promise<string | undefined> {
175
187
  return ctx.ui.custom(
176
- (host, theme, _keys, done) =>
177
- new DialogEditor(host, theme, title, prefill, done),
188
+ (host, theme, _keys, done) => new DialogEditor(host, theme, title, prefill, done),
178
189
  DIALOG_OPTIONS,
179
190
  );
180
191
  }
@@ -183,16 +194,31 @@ export function dialogEditor(
183
194
  class DialogMenu {
184
195
  private selected = 0;
185
196
  private viewport = 1;
197
+ private host: DialogHost;
198
+ private theme: Theme;
199
+ readonly title: string;
200
+ readonly rows: DialogRow[];
201
+ private done: (id: string | undefined) => void;
202
+ readonly footer: string;
203
+ private saveId: string | undefined;
204
+
186
205
  constructor(
187
- private host: DialogHost,
188
- private theme: Theme,
189
- readonly title: string,
190
- readonly rows: DialogRow[],
191
- private done: (id: string | undefined) => void,
206
+ host: DialogHost,
207
+ theme: Theme,
208
+ title: string,
209
+ rows: DialogRow[],
210
+ done: (id: string | undefined) => void,
192
211
  selectedId?: string,
193
- readonly footer = "↑↓ navigate · Enter select · Esc close",
194
- private saveId?: string,
212
+ footer: string = "↑↓ navigate · Enter select · Esc close",
213
+ saveId?: string,
195
214
  ) {
215
+ this.host = host;
216
+ this.theme = theme;
217
+ this.title = title;
218
+ this.rows = rows;
219
+ this.done = done;
220
+ this.footer = footer;
221
+ this.saveId = saveId;
196
222
  const index = rows.findIndex((row) => row.id === selectedId);
197
223
  if (index >= 0) this.selected = index;
198
224
  }
@@ -202,23 +228,16 @@ class DialogMenu {
202
228
  handleInput(data: string): void {
203
229
  const keys = getKeybindings();
204
230
  if (keys.matches(data, "tui.select.cancel")) return this.done(undefined);
205
- if (keys.matches(data, "tui.select.confirm"))
206
- return this.done(this.getSelectedId());
207
- if (this.saveId && matchesKey(data, Key.ctrl("s")))
208
- return this.done(this.saveId);
231
+ if (keys.matches(data, "tui.select.confirm")) return this.done(this.getSelectedId());
232
+ if (this.saveId && matchesKey(data, Key.ctrl("s"))) return this.done(this.saveId);
209
233
  let offset = 0;
210
234
  if (keys.matches(data, "tui.select.up")) offset = -1;
211
- else if (keys.matches(data, "tui.select.down") || matchesKey(data, Key.tab))
212
- offset = 1;
235
+ else if (keys.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) offset = 1;
213
236
  else if (matchesKey(data, Key.pageUp)) offset = -this.viewport;
214
237
  else if (matchesKey(data, Key.pageDown)) offset = this.viewport;
215
238
  else if (matchesKey(data, Key.home)) this.selected = 0;
216
- else if (matchesKey(data, Key.end))
217
- this.selected = Math.max(0, this.rows.length - 1);
218
- this.selected = Math.max(
219
- 0,
220
- Math.min(this.rows.length - 1, this.selected + offset),
221
- );
239
+ else if (matchesKey(data, Key.end)) this.selected = Math.max(0, this.rows.length - 1);
240
+ this.selected = Math.max(0, Math.min(this.rows.length - 1, this.selected + offset));
222
241
  this.host.requestRender();
223
242
  }
224
243
  invalidate(): void {}
@@ -232,20 +251,17 @@ class DialogMenu {
232
251
  this.viewport = Math.max(1, height - 6 - help.length);
233
252
  const start = Math.max(
234
253
  0,
235
- Math.min(
236
- this.selected - this.viewport + 1,
237
- this.rows.length - this.viewport,
238
- ),
254
+ Math.min(this.selected - this.viewport + 1, this.rows.length - this.viewport),
239
255
  );
240
256
  const labelWidth = Math.min(28, Math.max(8, Math.floor(inner * 0.35)));
241
257
  const body = [
242
258
  this.theme.fg(
243
259
  "dim",
244
- ` ${this.rows.length ? this.selected + 1 : 0}/${this.rows.length} · Field / Value`,
260
+ ` ${this.rows.length > 0 ? this.selected + 1 : 0}/${this.rows.length} · Field / Value`,
245
261
  ),
246
262
  ...this.rows.slice(start, start + this.viewport).map((row, i) => {
247
263
  const selected = start + i === this.selected;
248
- const color = (text: string) => selected ? this.theme.fg("accent", text) : text;
264
+ const color = (text: string) => (selected ? this.theme.fg("accent", text) : text);
249
265
  const plainLabel = dialogText(row.label);
250
266
  const label = truncateToWidth(
251
267
  row.renderLabel ? row.renderLabel(plainLabel, this.theme) : plainLabel,
@@ -255,22 +271,17 @@ class DialogMenu {
255
271
  );
256
272
  const padding = " ".repeat(Math.max(0, labelWidth - visibleWidth(label)));
257
273
  const value = dialogText(row.value ?? "");
258
- return color(`${selected ? "›" : " "} `)
259
- + (row.renderLabel ? label : color(label))
260
- + color(`${padding} │ `)
261
- + (row.valueColor ? this.theme.fg(row.valueColor, value) : color(value));
274
+ return (
275
+ color(`${selected ? "›" : " "} `) +
276
+ (row.renderLabel ? label : color(label)) +
277
+ color(`${padding} │ `) +
278
+ (row.valueColor ? this.theme.fg(row.valueColor, value) : color(value))
279
+ );
262
280
  }),
263
281
  "",
264
282
  ...help.map((line) => this.theme.fg("muted", ` ${line}`)),
265
283
  ];
266
- return frameDialog(
267
- this.theme,
268
- width,
269
- height,
270
- this.title,
271
- body,
272
- this.footer,
273
- );
284
+ return frameDialog(this.theme, width, height, this.title, body, this.footer);
274
285
  }
275
286
  }
276
287
 
@@ -287,7 +298,11 @@ export async function dialogMenu(
287
298
  ctx: ExtensionContext,
288
299
  title: string,
289
300
  rows: DialogRow[],
290
- options: { selectedId?: string; footer?: string; saveId?: string } = {},
301
+ options: {
302
+ selectedId?: string | undefined;
303
+ footer?: string | undefined;
304
+ saveId?: string | undefined;
305
+ } = {},
291
306
  ): Promise<string | undefined> {
292
307
  return ctx.ui.custom(
293
308
  (host, theme, _keys, done) =>
@@ -371,9 +386,7 @@ const dialogScopes = new WeakSet<ExtensionContext>();
371
386
 
372
387
  function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
373
388
  return (
374
- typeof value === "object" &&
375
- value !== null &&
376
- typeof (value as Promise<T>).then === "function"
389
+ typeof value === "object" && value !== null && typeof (value as Promise<T>).then === "function"
377
390
  );
378
391
  }
379
392
 
@@ -395,14 +408,25 @@ export class DialogSession {
395
408
  private disposed = false;
396
409
  private generation = 0;
397
410
  private pending: PendingView | undefined;
398
- private outerDone: ((result?: void) => void) | undefined;
411
+ private outerDone: ((result?: undefined) => void) | undefined;
412
+
413
+ private host: TUI;
414
+
415
+ private theme: Theme;
416
+
417
+ private keys: KeybindingsManager;
399
418
 
400
419
  constructor(
401
- private host: TUI,
402
- private theme: Theme,
403
- private keys: KeybindingsManager,
404
- done: (result?: void) => void,
420
+ host: TUI,
421
+ theme: Theme,
422
+ keys: KeybindingsManager,
423
+ done: (result?: undefined) => void,
405
424
  ) {
425
+ this.host = host;
426
+
427
+ this.theme = theme;
428
+
429
+ this.keys = keys;
406
430
  this.outerDone = done;
407
431
  }
408
432
 
@@ -442,16 +466,14 @@ export class DialogSession {
442
466
  const resolveOnce = (value: T) => {
443
467
  if (settled) return;
444
468
  settled = true;
445
- if (this.pending?.generation === generation)
446
- this.pending.settled = true;
469
+ if (this.pending?.generation === generation) this.pending.settled = true;
447
470
  resolve(value);
448
471
  };
449
472
  const rejectOnce = (error: unknown) => {
450
473
  if (settled) return;
451
474
  settled = true;
452
475
  this.inputLocked = true;
453
- if (this.pending?.generation === generation)
454
- this.pending.settled = true;
476
+ if (this.pending?.generation === generation) this.pending.settled = true;
455
477
  reject(error);
456
478
  };
457
479
  this.pending = {
@@ -480,8 +502,7 @@ export class DialogSession {
480
502
  void produced.then(
481
503
  (component) => adopt(component),
482
504
  (error: unknown) => {
483
- if (settled || this.closed || generation !== this.generation)
484
- return;
505
+ if (settled || this.closed || generation !== this.generation) return;
485
506
  rejectOnce(error);
486
507
  },
487
508
  );
@@ -540,8 +561,7 @@ export class DialogSession {
540
561
  return;
541
562
  }
542
563
  this.replaceChild(component);
543
- this.inputLocked =
544
- this.pending?.generation === generation ? this.pending.settled : true;
564
+ this.inputLocked = this.pending?.generation === generation ? this.pending.settled : true;
545
565
  this.host.requestRender();
546
566
  }
547
567
 
@@ -556,7 +576,7 @@ export class DialogSession {
556
576
 
557
577
  private forwardFocus(): void {
558
578
  const child = this.child;
559
- if (!child || !("focused" in child)) return;
579
+ if (!(child && "focused" in child)) return;
560
580
  // Read the setter's latest value; the first view can mount before showOverlay focuses us.
561
581
  child.focused = this.focused;
562
582
  }
@@ -569,18 +589,13 @@ export class DialogSession {
569
589
  }
570
590
  }
571
591
 
572
- export function scopeDialogContext<T extends ExtensionContext>(
573
- ctx: T,
574
- session: DialogSession,
575
- ): T {
592
+ export function scopeDialogContext<T extends ExtensionContext>(ctx: T, session: DialogSession): T {
576
593
  const source = ctx.ui;
577
594
  const originalCustom = source.custom.bind(source);
578
595
  const ui = Object.create(source) as ExtensionUIContext;
579
596
  ui.custom = ((factory, options) => {
580
597
  if (options?.overlay) return session.mount(factory);
581
- return originalCustom(factory, options).finally(() =>
582
- session.restoreFocus(),
583
- );
598
+ return originalCustom(factory, options).finally(() => session.restoreFocus());
584
599
  }) as ExtensionUIContext["custom"];
585
600
  // Pi exposes ctx.ui as a getter; assignment cannot shadow an inherited accessor.
586
601
  // Define an own property while keeping the remaining context getters live.
@@ -1,4 +1,3 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
1
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
2
  import {
4
3
  CURSOR_MARKER,
@@ -10,10 +9,10 @@ import {
10
9
  } from "@earendil-works/pi-tui";
11
10
  import {
12
11
  DIALOG_OPTIONS,
12
+ type DialogHost,
13
13
  dialogHeight,
14
14
  dialogText,
15
15
  frameDialog,
16
- type DialogHost,
17
16
  } from "./dialog.ts";
18
17
 
19
18
  export interface ImportPickerItem {
@@ -76,12 +75,28 @@ class ImportPicker {
76
75
  private viewport = 1;
77
76
  private readonly checked = new Set<string>();
78
77
 
78
+ private host: DialogHost;
79
+
80
+ private theme: Theme;
81
+
82
+ private readonly items: readonly ImportPickerItem[];
83
+
84
+ private done: (ids: string[] | undefined) => void;
85
+
79
86
  constructor(
80
- private host: DialogHost,
81
- private theme: Theme,
82
- private readonly items: readonly ImportPickerItem[],
83
- private done: (ids: string[] | undefined) => void,
84
- ) {}
87
+ host: DialogHost,
88
+ theme: Theme,
89
+ items: readonly ImportPickerItem[],
90
+ done: (ids: string[] | undefined) => void,
91
+ ) {
92
+ this.host = host;
93
+
94
+ this.theme = theme;
95
+
96
+ this.items = items;
97
+
98
+ this.done = done;
99
+ }
85
100
 
86
101
  /** Selected ids in source order. Empty until the user toggles a row. */
87
102
  getSelectedIds(): string[] {
@@ -99,7 +114,9 @@ class ImportPicker {
99
114
  return this.cursor;
100
115
  }
101
116
 
102
- invalidate(): void {}
117
+ invalidate(): void {
118
+ // Nothing is cached, so there is nothing to invalidate.
119
+ }
103
120
 
104
121
  handleInput(data: string): void {
105
122
  const keys = getKeybindings();
@@ -155,8 +172,8 @@ class ImportPicker {
155
172
  if (listCount > 0) body.push(this.theme.fg("muted", " No external agents to import"));
156
173
  } else {
157
174
  const rows = this.items.slice(start, start + listCount);
158
- for (let offset = 0; offset < rows.length; offset++)
159
- body.push(this.row(rows[offset]!, start + offset === this.cursor));
175
+ for (const [offset, row] of rows.entries())
176
+ body.push(this.row(row, start + offset === this.cursor));
160
177
  }
161
178
  if (gap) body.push("");
162
179
  for (const line of detail.slice(0, detailCount)) body.push(this.theme.fg("muted", ` ${line}`));
@@ -164,7 +181,7 @@ class ImportPicker {
164
181
  }
165
182
 
166
183
  private countLine(start: number, shown: number): string {
167
- const position = this.items.length ? this.cursor + 1 : 0;
184
+ const position = this.items.length > 0 ? this.cursor + 1 : 0;
168
185
  return this.theme.fg(
169
186
  "dim",
170
187
  ` ${this.scrollHint(start, shown)}${this.checked.size} selected · ${position}/${this.items.length}`,
@@ -13,7 +13,7 @@ import {
13
13
  wrapTextWithAnsi,
14
14
  } from "@earendil-works/pi-tui";
15
15
  import type { ThreadService, TranscriptSnapshot } from "../types.ts";
16
- import { dialogText, type DialogHost } from "./dialog.ts";
16
+ import { type DialogHost, dialogText } from "./dialog.ts";
17
17
  import { AGENT_PROGRESS_INTERVAL, agentProgressIcon, agentTypeLabel } from "./ui.ts";
18
18
 
19
19
  /** Transcript detail level cycled by the t key: full → preview → compact. */
@@ -66,7 +66,10 @@ export class LiveAgentView {
66
66
  private unsubscribe: (() => void) | undefined;
67
67
  private renderTimer: ReturnType<typeof setTimeout> | undefined;
68
68
  private progressTimer: ReturnType<typeof setInterval> | undefined;
69
- private cache = new WeakMap<object, { width: number; detail: TranscriptDetail; lines: string[] }>();
69
+ private cache = new WeakMap<
70
+ object,
71
+ { width: number; detail: TranscriptDetail; lines: string[] }
72
+ >();
70
73
  private scroll: ScrollView;
71
74
  private content: string[] = [];
72
75
  private restoreViewport = true;
@@ -188,7 +191,10 @@ export class LiveAgentView {
188
191
 
189
192
  private syncProgress(): void {
190
193
  const state = this.snapshot?.thread?.state;
191
- const active = this.nerdFontIcons && !this.disposed && !this.error &&
194
+ const active =
195
+ this.nerdFontIcons &&
196
+ !this.disposed &&
197
+ !this.error &&
192
198
  (state === "starting" || state === "running");
193
199
  if (!active) {
194
200
  if (this.progressTimer !== undefined) clearInterval(this.progressTimer);
@@ -333,7 +339,11 @@ export class LiveAgentView {
333
339
  if (this.detail === "compact")
334
340
  return [this.theme.fg("dim", "[Thinking hidden · t cycles view]")];
335
341
  // Thinking is model-authored Markdown; preview truncates the rendered lines, not the source.
336
- const rendered = this.markdown(String(block.thinking ?? "[redacted thinking]"), width, true);
342
+ const rendered = this.markdown(
343
+ String(block.thinking ?? "[redacted thinking]"),
344
+ width,
345
+ true,
346
+ );
337
347
  if (this.detail === "full" || rendered.length <= 3) return rendered;
338
348
  return [...rendered.slice(0, 3), this.theme.fg("dim", "...")];
339
349
  }
@@ -449,7 +459,9 @@ export class LiveAgentView {
449
459
  }
450
460
  case "ls": {
451
461
  const limit = num("limit");
452
- lines = [`${title("ls")} ${path ?? "."}${limit != null ? detail(` (limit ${limit})`) : ""}`];
462
+ lines = [
463
+ `${title("ls")} ${path ?? "."}${limit != null ? detail(` (limit ${limit})`) : ""}`,
464
+ ];
453
465
  break;
454
466
  }
455
467
  default:
@@ -1,9 +1,9 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
- import fuzzysort, { type SnapshotKeys } from "fuzzysort";
1
+ // biome-ignore-all lint/suspicious/noControlCharactersInRegex: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
+ // biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
3
3
  import type { Api, Model } from "@earendil-works/pi-ai";
4
4
  import {
5
- getSelectListTheme,
6
5
  type ExtensionCommandContext,
6
+ getSelectListTheme,
7
7
  type ScopedModel,
8
8
  type Theme,
9
9
  } from "@earendil-works/pi-coding-agent";
@@ -13,14 +13,15 @@ import {
13
13
  Input,
14
14
  Key,
15
15
  matchesKey,
16
+ type SelectItem,
16
17
  SelectList,
17
18
  stripTerminalSequences,
18
- truncateToWidth,
19
19
  Text,
20
- type SelectItem,
20
+ truncateToWidth,
21
21
  } from "@earendil-works/pi-tui";
22
+ import fuzzysort, { type SnapshotKeys } from "fuzzysort";
22
23
  import { modelIdentity } from "../prefs/models.ts";
23
- import { dialogHeight, frameDialog, DIALOG_OPTIONS } from "./dialog.ts";
24
+ import { DIALOG_OPTIONS, dialogHeight, frameDialog } from "./dialog.ts";
24
25
 
25
26
  const MODEL_EDITOR_LAYOUT = {
26
27
  minPrimaryColumnWidth: 24,
@@ -44,8 +45,8 @@ interface OrderedModelEditorComponentOptions {
44
45
  theme: Theme;
45
46
  availableModels: readonly AvailableModel[];
46
47
  scopedModels: readonly ScopedModel[];
47
- initialModels?: readonly string[];
48
- modelSuggestions?: readonly string[];
48
+ initialModels?: readonly string[] | undefined;
49
+ modelSuggestions?: readonly string[] | undefined;
49
50
  onDone(models: string[]): void;
50
51
  onCancel(): void;
51
52
  }
@@ -133,7 +134,7 @@ class OrderedModelEditorComponent extends Container {
133
134
  this.theme.fg(
134
135
  "muted",
135
136
  this.withScopeWarning(
136
- this.suggestedScores.size
137
+ this.suggestedScores.size > 0
137
138
  ? "Selected first; suggested matches scoped first, then other models; the rest A–Z. Type to search."
138
139
  : "Selected first. Search by provider/id or model name; other models A–Z.",
139
140
  ),
@@ -244,7 +245,12 @@ class OrderedModelEditorComponent extends Container {
244
245
  const next = index + (earlier ? -1 : 1);
245
246
  // Consume reorder keys even on unselected models or at a list boundary.
246
247
  if (index >= 0 && next >= 0 && next < this.draft.length) {
247
- [this.draft[index], this.draft[next]] = [this.draft[next], this.draft[index]];
248
+ const moved = this.draft[index];
249
+ const other = this.draft[next];
250
+ if (moved !== undefined && other !== undefined) {
251
+ this.draft[index] = other;
252
+ this.draft[next] = moved;
253
+ }
248
254
  this.refreshPickerList(selected);
249
255
  this.tui.requestRender();
250
256
  }
@@ -271,6 +277,11 @@ class OrderedModelEditorComponent extends Container {
271
277
  this.tui.requestRender();
272
278
  }
273
279
 
280
+ private fallbackIndex(focusSearchResult: boolean, previousIndex: number): number {
281
+ if (focusSearchResult) return 0;
282
+ return Math.max(0, Math.min(previousIndex, this.currentItems.length - 1));
283
+ }
284
+
274
285
  private refreshPickerList(selectedValue?: string, focusSearchResult = false): void {
275
286
  const previousIndex = this.currentItems.findIndex(
276
287
  (item) => item.value === this.selectList.getSelectedItem()?.value,
@@ -314,11 +325,7 @@ class OrderedModelEditorComponent extends Container {
314
325
  )
315
326
  : this.currentItems.findIndex((item) => item.value === selectedValue);
316
327
  list.setSelectedIndex(
317
- preferredIndex >= 0
318
- ? preferredIndex
319
- : focusSearchResult
320
- ? 0
321
- : Math.max(0, Math.min(previousIndex, this.currentItems.length - 1)),
328
+ preferredIndex >= 0 ? preferredIndex : this.fallbackIndex(focusSearchResult, previousIndex),
322
329
  );
323
330
  list.onSelect = (item) => {
324
331
  if (this.currentItems.find((row) => row.value === item.value)?.group === ACTIONS_GROUP) {
@@ -380,7 +387,8 @@ class OrderedModelEditorComponent extends Container {
380
387
  }
381
388
  }
382
389
  const bySuggestionScore = (a: AvailableModel, b: AvailableModel) =>
383
- this.suggestedScores.get(modelIdentity(b))! - this.suggestedScores.get(modelIdentity(a))!;
390
+ (this.suggestedScores.get(modelIdentity(b)) ?? 0) -
391
+ (this.suggestedScores.get(modelIdentity(a)) ?? 0);
384
392
  // Stable score sorting preserves alphabetical order for ties. The last
385
393
  // tier retains the registry's alphabetical order, irrespective of scope.
386
394
  scoped.sort(bySuggestionScore);
@@ -397,7 +405,8 @@ class OrderedModelEditorComponent extends Container {
397
405
  ...items(other, "Suggested matches · other models"),
398
406
  ...items(
399
407
  remaining,
400
- scoped.length || other.length ? "All other models · A–Z" : "All models · A–Z",
408
+
409
+ scoped.length > 0 || other.length > 0 ? "All other models · A–Z" : "All models · A–Z",
401
410
  ),
402
411
  ];
403
412
  }