gsd-pi 2.25.0 → 2.26.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.
Files changed (122) hide show
  1. package/README.md +11 -2
  2. package/dist/headless.js +24 -4
  3. package/dist/resources/extensions/async-jobs/index.ts +9 -1
  4. package/dist/resources/extensions/bg-shell/index.ts +3 -2
  5. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -4
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +14 -3
  7. package/dist/resources/extensions/gsd/auto.ts +81 -12
  8. package/dist/resources/extensions/gsd/doctor-proactive.ts +7 -6
  9. package/dist/resources/extensions/gsd/doctor.ts +24 -1
  10. package/dist/resources/extensions/gsd/files.ts +13 -2
  11. package/dist/resources/extensions/gsd/guided-flow.ts +19 -9
  12. package/dist/resources/extensions/gsd/index.ts +48 -7
  13. package/dist/resources/extensions/gsd/migrate/writer.ts +39 -0
  14. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +122 -4
  15. package/dist/resources/extensions/gsd/preferences.ts +2 -1
  16. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  17. package/dist/resources/extensions/gsd/prompts/discuss.md +1 -1
  18. package/dist/resources/extensions/gsd/prompts/queue.md +2 -2
  19. package/dist/resources/extensions/gsd/roadmap-slices.ts +45 -1
  20. package/dist/resources/extensions/gsd/state.ts +17 -6
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +70 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +23 -3
  23. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +13 -7
  24. package/dist/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +171 -0
  25. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +8 -4
  26. package/dist/resources/extensions/gsd/types.ts +2 -0
  27. package/dist/resources/extensions/search-the-web/native-search.ts +4 -0
  28. package/dist/resources/extensions/shared/path-display.ts +19 -0
  29. package/package.json +1 -6
  30. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  31. package/packages/pi-ai/dist/providers/anthropic.js +25 -0
  32. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  33. package/packages/pi-ai/src/providers/anthropic.ts +27 -0
  34. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
  35. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  36. package/packages/pi-coding-agent/dist/core/agent-session.js +32 -0
  37. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  38. package/packages/pi-coding-agent/dist/core/keybindings.js +1 -1
  39. package/packages/pi-coding-agent/dist/core/keybindings.js.map +1 -1
  40. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  41. package/packages/pi-coding-agent/dist/core/lsp/client.js +12 -1
  42. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  43. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/core/lsp/index.js +7 -0
  45. package/packages/pi-coding-agent/dist/core/lsp/index.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/core/sdk.d.ts +2 -2
  47. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/sdk.js +8 -3
  49. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  51. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/settings-manager.js +8 -0
  53. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  56. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/core/system-prompt.js +2 -1
  59. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/index.d.ts +2 -1
  61. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/index.js +5 -1
  63. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts +41 -3
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +301 -62
  67. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  69. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +63 -30
  71. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/tests/path-display.test.d.ts +8 -0
  73. package/packages/pi-coding-agent/dist/tests/path-display.test.d.ts.map +1 -0
  74. package/packages/pi-coding-agent/dist/tests/path-display.test.js +60 -0
  75. package/packages/pi-coding-agent/dist/tests/path-display.test.js.map +1 -0
  76. package/packages/pi-coding-agent/dist/utils/clipboard-image.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/utils/clipboard-image.js +32 -6
  78. package/packages/pi-coding-agent/dist/utils/clipboard-image.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/utils/path-display.d.ts +34 -0
  80. package/packages/pi-coding-agent/dist/utils/path-display.d.ts.map +1 -0
  81. package/packages/pi-coding-agent/dist/utils/path-display.js +36 -0
  82. package/packages/pi-coding-agent/dist/utils/path-display.js.map +1 -0
  83. package/packages/pi-coding-agent/src/core/agent-session.ts +36 -0
  84. package/packages/pi-coding-agent/src/core/keybindings.ts +1 -1
  85. package/packages/pi-coding-agent/src/core/lsp/client.ts +11 -1
  86. package/packages/pi-coding-agent/src/core/lsp/index.ts +7 -0
  87. package/packages/pi-coding-agent/src/core/sdk.ts +17 -1
  88. package/packages/pi-coding-agent/src/core/settings-manager.ts +11 -0
  89. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  90. package/packages/pi-coding-agent/src/core/system-prompt.ts +2 -1
  91. package/packages/pi-coding-agent/src/index.ts +15 -0
  92. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +347 -62
  93. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +40 -4
  94. package/packages/pi-coding-agent/src/tests/path-display.test.ts +85 -0
  95. package/packages/pi-coding-agent/src/utils/clipboard-image.ts +33 -6
  96. package/packages/pi-coding-agent/src/utils/path-display.ts +36 -0
  97. package/src/resources/extensions/async-jobs/index.ts +9 -1
  98. package/src/resources/extensions/bg-shell/index.ts +3 -2
  99. package/src/resources/extensions/gsd/auto-recovery.ts +7 -4
  100. package/src/resources/extensions/gsd/auto-worktree.ts +14 -3
  101. package/src/resources/extensions/gsd/auto.ts +81 -12
  102. package/src/resources/extensions/gsd/doctor-proactive.ts +7 -6
  103. package/src/resources/extensions/gsd/doctor.ts +24 -1
  104. package/src/resources/extensions/gsd/files.ts +13 -2
  105. package/src/resources/extensions/gsd/guided-flow.ts +19 -9
  106. package/src/resources/extensions/gsd/index.ts +48 -7
  107. package/src/resources/extensions/gsd/migrate/writer.ts +39 -0
  108. package/src/resources/extensions/gsd/parallel-orchestrator.ts +122 -4
  109. package/src/resources/extensions/gsd/preferences.ts +2 -1
  110. package/src/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  111. package/src/resources/extensions/gsd/prompts/discuss.md +1 -1
  112. package/src/resources/extensions/gsd/prompts/queue.md +2 -2
  113. package/src/resources/extensions/gsd/roadmap-slices.ts +45 -1
  114. package/src/resources/extensions/gsd/state.ts +17 -6
  115. package/src/resources/extensions/gsd/tests/derive-state.test.ts +70 -0
  116. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +23 -3
  117. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +13 -7
  118. package/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +171 -0
  119. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +8 -4
  120. package/src/resources/extensions/gsd/types.ts +2 -0
  121. package/src/resources/extensions/search-the-web/native-search.ts +4 -0
  122. package/src/resources/extensions/shared/path-display.ts +19 -0
@@ -38,10 +38,22 @@ interface ScopedModelItem {
38
38
  thinkingLevel?: string;
39
39
  }
40
40
 
41
+ /**
42
+ * A navigable row — either a provider group header or a selectable model entry.
43
+ */
44
+ type ListRow =
45
+ | { kind: "header"; provider: string; count: number }
46
+ | { kind: "model"; item: ModelItem };
47
+
41
48
  type ModelScope = "all" | "scoped";
42
49
 
43
50
  /**
44
- * Component that renders a model selector with search
51
+ * Component that renders a grouped model selector with search.
52
+ *
53
+ * Browsing (no search): models are grouped under provider headers.
54
+ * - Current model's provider is shown first; remaining providers sorted alphabetically.
55
+ * - Arrow keys navigate all rows; headers are skipped during selection.
56
+ * Searching: reverts to a flat fuzzy-filtered list (same as before), with [provider] badges.
45
57
  */
46
58
  export class ModelSelectorComponent extends Container implements Focusable {
47
59
  private searchInput: Input;
@@ -59,8 +71,17 @@ export class ModelSelectorComponent extends Container implements Focusable {
59
71
  private allModels: ModelItem[] = [];
60
72
  private scopedModelItems: ModelItem[] = [];
61
73
  private activeModels: ModelItem[] = [];
74
+
75
+ // Grouped (browse) state
76
+ private groupedRows: ListRow[] = [];
77
+ private modelRowIndices: number[] = []; // indices into groupedRows that are "model" kind
78
+ private selectedGroupIndex: number = 0; // index into groupedRows (can be model or header)
79
+
80
+ // Search (flat) state
62
81
  private filteredModels: ModelItem[] = [];
63
- private selectedIndex: number = 0;
82
+ private selectedFlatIndex: number = 0;
83
+
84
+ private isSearching: boolean = false;
64
85
  private currentModel?: Model<any>;
65
86
  private settingsManager: SettingsManager;
66
87
  private modelRegistry: ModelRegistry;
@@ -116,9 +137,13 @@ export class ModelSelectorComponent extends Container implements Focusable {
116
137
  this.searchInput.setValue(initialSearchInput);
117
138
  }
118
139
  this.searchInput.onSubmit = () => {
119
- // Enter on search input selects the first filtered item
120
- if (this.filteredModels[this.selectedIndex]) {
121
- this.handleSelect(this.filteredModels[this.selectedIndex].model);
140
+ if (this.isSearching) {
141
+ if (this.filteredModels[this.selectedFlatIndex]) {
142
+ this.handleSelect(this.filteredModels[this.selectedFlatIndex].model);
143
+ }
144
+ } else {
145
+ const model = this.getSelectedModel();
146
+ if (model) this.handleSelect(model);
122
147
  }
123
148
  };
124
149
  this.addChild(this.searchInput);
@@ -137,8 +162,11 @@ export class ModelSelectorComponent extends Container implements Focusable {
137
162
  // Load models and do initial render
138
163
  this.loadModels().then(() => {
139
164
  if (initialSearchInput) {
165
+ this.isSearching = true;
140
166
  this.filterModels(initialSearchInput);
141
167
  } else {
168
+ this.buildGroupedRows();
169
+ this.jumpToCurrentModel();
142
170
  this.updateList();
143
171
  }
144
172
  // Request re-render after models are loaded
@@ -171,12 +199,14 @@ export class ModelSelectorComponent extends Container implements Focusable {
171
199
  this.scopedModelItems = [];
172
200
  this.activeModels = [];
173
201
  this.filteredModels = [];
202
+ this.groupedRows = [];
203
+ this.modelRowIndices = [];
174
204
  this.errorMessage = error instanceof Error ? error.message : String(error);
175
205
  return;
176
206
  }
177
207
 
178
- this.allModels = this.sortModels(models);
179
- this.scopedModelItems = this.sortModels(
208
+ this.allModels = this.sortModelsWithinProvider(models);
209
+ this.scopedModelItems = this.sortModelsWithinProvider(
180
210
  this.scopedModels.map((scoped) => ({
181
211
  provider: scoped.model.provider,
182
212
  id: scoped.model.id,
@@ -185,18 +215,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
185
215
  );
186
216
  this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
187
217
  this.filteredModels = this.activeModels;
188
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
189
218
  }
190
219
 
191
- private sortModels(models: ModelItem[]): ModelItem[] {
220
+ /**
221
+ * Sort models within each provider: current model first, then by name desc.
222
+ * Provider ordering is handled separately in buildGroupedRows().
223
+ */
224
+ private sortModelsWithinProvider(models: ModelItem[]): ModelItem[] {
192
225
  const sorted = [...models];
193
- // Sort: current model first, then by name descending (newest first), then by provider
194
226
  sorted.sort((a, b) => {
195
227
  const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
196
228
  const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
197
229
  if (aIsCurrent && !bIsCurrent) return -1;
198
230
  if (!aIsCurrent && bIsCurrent) return 1;
199
- // Group by model name (display name), newest/largest first
231
+ // Within provider: newest/largest model name first
200
232
  const nameCmp = b.model.name.localeCompare(a.model.name);
201
233
  if (nameCmp !== 0) return nameCmp;
202
234
  return a.provider.localeCompare(b.provider);
@@ -204,6 +236,79 @@ export class ModelSelectorComponent extends Container implements Focusable {
204
236
  return sorted;
205
237
  }
206
238
 
239
+ /**
240
+ * Build the grouped rows array for browse mode.
241
+ * Current model's provider comes first; remaining providers sorted alphabetically.
242
+ */
243
+ private buildGroupedRows(): void {
244
+ // Group models by provider
245
+ const byProvider = new Map<string, ModelItem[]>();
246
+ for (const item of this.activeModels) {
247
+ let group = byProvider.get(item.provider);
248
+ if (!group) {
249
+ group = [];
250
+ byProvider.set(item.provider, group);
251
+ }
252
+ group.push(item);
253
+ }
254
+
255
+ // Determine provider order: current model's provider first, rest alphabetically
256
+ const currentProvider = this.currentModel?.provider;
257
+ const providers = Array.from(byProvider.keys()).sort((a, b) => {
258
+ if (a === currentProvider) return -1;
259
+ if (b === currentProvider) return 1;
260
+ return a.localeCompare(b);
261
+ });
262
+
263
+ const rows: ListRow[] = [];
264
+ const modelIndices: number[] = [];
265
+
266
+ for (const provider of providers) {
267
+ const items = byProvider.get(provider)!;
268
+ rows.push({ kind: "header", provider, count: items.length });
269
+ for (const item of items) {
270
+ modelIndices.push(rows.length);
271
+ rows.push({ kind: "model", item });
272
+ }
273
+ }
274
+
275
+ this.groupedRows = rows;
276
+ this.modelRowIndices = modelIndices;
277
+ }
278
+
279
+ /**
280
+ * Move selectedGroupIndex to point at the current model (or first model).
281
+ */
282
+ private jumpToCurrentModel(): void {
283
+ if (this.groupedRows.length === 0) {
284
+ this.selectedGroupIndex = 0;
285
+ return;
286
+ }
287
+ // Find the current model in grouped rows
288
+ for (let i = 0; i < this.groupedRows.length; i++) {
289
+ const row = this.groupedRows[i];
290
+ if (row.kind === "model" && modelsAreEqual(this.currentModel, row.item.model)) {
291
+ this.selectedGroupIndex = i;
292
+ return;
293
+ }
294
+ }
295
+ // Fall back to first model row
296
+ if (this.modelRowIndices.length > 0) {
297
+ this.selectedGroupIndex = this.modelRowIndices[0];
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Get the currently selected model from grouped or flat state.
303
+ */
304
+ private getSelectedModel(): Model<any> | undefined {
305
+ if (this.isSearching) {
306
+ return this.filteredModels[this.selectedFlatIndex]?.model;
307
+ }
308
+ const row = this.groupedRows[this.selectedGroupIndex];
309
+ return row?.kind === "model" ? row.item.model : undefined;
310
+ }
311
+
207
312
  private getScopeText(): string {
208
313
  const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");
209
314
  const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");
@@ -218,8 +323,16 @@ export class ModelSelectorComponent extends Container implements Focusable {
218
323
  if (this.scope === scope) return;
219
324
  this.scope = scope;
220
325
  this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
221
- this.selectedIndex = 0;
222
- this.filterModels(this.searchInput.getValue());
326
+
327
+ if (this.isSearching) {
328
+ this.selectedFlatIndex = 0;
329
+ this.filterModels(this.searchInput.getValue());
330
+ } else {
331
+ this.buildGroupedRows();
332
+ this.jumpToCurrentModel();
333
+ this.updateList();
334
+ }
335
+
223
336
  if (this.scopeText) {
224
337
  this.scopeText.setText(this.getScopeText());
225
338
  }
@@ -229,26 +342,51 @@ export class ModelSelectorComponent extends Container implements Focusable {
229
342
  this.filteredModels = query
230
343
  ? fuzzyFilter(this.activeModels, query, ({ id, provider }) => `${id} ${provider}`)
231
344
  : this.activeModels;
232
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
345
+ this.selectedFlatIndex = Math.min(this.selectedFlatIndex, Math.max(0, this.filteredModels.length - 1));
233
346
  this.updateList();
234
347
  }
235
348
 
236
349
  private updateList(): void {
237
350
  this.listContainer.clear();
238
351
 
352
+ if (this.errorMessage) {
353
+ const errorLines = this.errorMessage.split("\n");
354
+ for (const line of errorLines) {
355
+ this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
356
+ }
357
+ return;
358
+ }
359
+
360
+ if (this.isSearching) {
361
+ this.renderFlatList();
362
+ } else {
363
+ this.renderGroupedList();
364
+ }
365
+ }
366
+
367
+ /** Flat fuzzy-search results, same as original behaviour */
368
+ private renderFlatList(): void {
239
369
  const maxVisible = 10;
370
+
371
+ if (this.filteredModels.length === 0) {
372
+ this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
373
+ return;
374
+ }
375
+
240
376
  const startIndex = Math.max(
241
377
  0,
242
- Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),
378
+ Math.min(
379
+ this.selectedFlatIndex - Math.floor(maxVisible / 2),
380
+ this.filteredModels.length - maxVisible,
381
+ ),
243
382
  );
244
383
  const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
245
384
 
246
- // Show visible slice of filtered models
247
385
  for (let i = startIndex; i < endIndex; i++) {
248
386
  const item = this.filteredModels[i];
249
387
  if (!item) continue;
250
388
 
251
- const isSelected = i === this.selectedIndex;
389
+ const isSelected = i === this.selectedFlatIndex;
252
390
  const isCurrent = modelsAreEqual(this.currentModel, item.model);
253
391
 
254
392
  const ctx = formatTokenCount(item.model.contextWindow);
@@ -256,7 +394,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
256
394
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
257
395
  const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
258
396
 
259
- let line = "";
397
+ let line: string;
260
398
  if (isSelected) {
261
399
  const prefix = theme.fg("accent", "→ ");
262
400
  line = `${prefix}${theme.fg("accent", item.id)} ${ctxBadge} ${providerBadge}${checkmark}`;
@@ -267,40 +405,110 @@ export class ModelSelectorComponent extends Container implements Focusable {
267
405
  this.listContainer.addChild(new Text(line, 0, 0));
268
406
  }
269
407
 
270
- // Add scroll indicator if needed
271
408
  if (startIndex > 0 || endIndex < this.filteredModels.length) {
272
- const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
273
- this.listContainer.addChild(new Text(scrollInfo, 0, 0));
409
+ this.listContainer.addChild(
410
+ new Text(theme.fg("muted", ` (${this.selectedFlatIndex + 1}/${this.filteredModels.length})`), 0, 0),
411
+ );
274
412
  }
275
413
 
276
- // Show error message or "no results" if empty
277
- if (this.errorMessage) {
278
- // Show error in red
279
- const errorLines = this.errorMessage.split("\n");
280
- for (const line of errorLines) {
281
- this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
282
- }
283
- } else if (this.filteredModels.length === 0) {
284
- this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
285
- } else {
286
- const selected = this.filteredModels[this.selectedIndex];
287
- if (selected) {
288
- const m = selected.model;
289
- const details = [
290
- m.name,
291
- `ctx: ${formatTokenCount(m.contextWindow)}`,
292
- `out: ${formatTokenCount(m.maxTokens)}`,
293
- m.reasoning ? "thinking" : "",
294
- m.input.includes("image") ? "vision" : "",
295
- ].filter(Boolean).join(" · ");
296
- this.listContainer.addChild(new Spacer(1));
297
- this.listContainer.addChild(new Text(theme.fg("muted", ` ${details}`), 0, 0));
414
+ // Detail line for selected model
415
+ const selected = this.filteredModels[this.selectedFlatIndex];
416
+ if (selected) {
417
+ this.listContainer.addChild(new Spacer(1));
418
+ this.listContainer.addChild(new Text(theme.fg("muted", ` ${this.modelDetailLine(selected.model)}`), 0, 0));
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Grouped browse view: provider headers + model rows, windowed around selection.
424
+ * Shows enough rows to fill ~10 visible lines; headers count as one line each.
425
+ */
426
+ private renderGroupedList(): void {
427
+ const maxVisible = 12;
428
+
429
+ if (this.groupedRows.length === 0) {
430
+ this.listContainer.addChild(new Text(theme.fg("muted", " No models available"), 0, 0));
431
+ return;
432
+ }
433
+
434
+ // Window around selectedGroupIndex
435
+ const startIndex = Math.max(
436
+ 0,
437
+ Math.min(
438
+ this.selectedGroupIndex - Math.floor(maxVisible / 2),
439
+ this.groupedRows.length - maxVisible,
440
+ ),
441
+ );
442
+ const endIndex = Math.min(startIndex + maxVisible, this.groupedRows.length);
443
+
444
+ for (let i = startIndex; i < endIndex; i++) {
445
+ const row = this.groupedRows[i];
446
+ if (!row) continue;
447
+
448
+ if (row.kind === "header") {
449
+ // Provider group header — always unselectable
450
+ const providerLabel = theme.fg("borderAccent", row.provider);
451
+ const count = theme.fg("muted", ` (${row.count})`);
452
+ // Add blank line before header if not the very first visible row
453
+ if (i > startIndex) {
454
+ this.listContainer.addChild(new Text("", 0, 0));
455
+ }
456
+ this.listContainer.addChild(new Text(` ${providerLabel}${count}`, 0, 0));
457
+ } else {
458
+ // Model row
459
+ const isSelected = i === this.selectedGroupIndex;
460
+ const isCurrent = modelsAreEqual(this.currentModel, row.item.model);
461
+
462
+ const ctx = formatTokenCount(row.item.model.contextWindow);
463
+ const ctxBadge = theme.fg("muted", ` ${ctx}`);
464
+ const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
465
+
466
+ let line: string;
467
+ if (isSelected) {
468
+ line = ` ${theme.fg("accent", "→")} ${theme.fg("accent", row.item.id)}${ctxBadge}${checkmark}`;
469
+ } else {
470
+ line = ` ${row.item.id}${ctxBadge}${checkmark}`;
471
+ }
472
+
473
+ this.listContainer.addChild(new Text(line, 0, 0));
298
474
  }
299
475
  }
476
+
477
+ // Scroll indicator
478
+ if (startIndex > 0 || endIndex < this.groupedRows.length) {
479
+ const modelPos = this.modelRowIndices.indexOf(this.selectedGroupIndex) + 1;
480
+ const totalModels = this.modelRowIndices.length;
481
+ this.listContainer.addChild(
482
+ new Text(theme.fg("muted", ` (${modelPos}/${totalModels})`), 0, 0),
483
+ );
484
+ }
485
+
486
+ // Detail line for selected model
487
+ const selectedModel = this.getSelectedModel();
488
+ if (selectedModel) {
489
+ this.listContainer.addChild(new Spacer(1));
490
+ this.listContainer.addChild(
491
+ new Text(theme.fg("muted", ` ${this.modelDetailLine(selectedModel)}`), 0, 0),
492
+ );
493
+ }
494
+ }
495
+
496
+ private modelDetailLine(m: Model<any>): string {
497
+ return [
498
+ m.name,
499
+ `ctx: ${formatTokenCount(m.contextWindow)}`,
500
+ `out: ${formatTokenCount(m.maxTokens)}`,
501
+ m.reasoning ? "thinking" : "",
502
+ m.input.includes("image") ? "vision" : "",
503
+ ]
504
+ .filter(Boolean)
505
+ .join(" · ");
300
506
  }
301
507
 
302
508
  handleInput(keyData: string): void {
303
509
  const kb = getEditorKeybindings();
510
+
511
+ // Tab: scope toggle
304
512
  if (kb.matches(keyData, "tab")) {
305
513
  if (this.scopedModelItems.length > 0) {
306
514
  const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all";
@@ -311,34 +519,111 @@ export class ModelSelectorComponent extends Container implements Focusable {
311
519
  }
312
520
  return;
313
521
  }
314
- // Up arrow - wrap to bottom when at top
522
+
523
+ // Navigation keys
315
524
  if (kb.matches(keyData, "selectUp")) {
525
+ this.moveUp();
526
+ return;
527
+ }
528
+ if (kb.matches(keyData, "selectDown")) {
529
+ this.moveDown();
530
+ return;
531
+ }
532
+
533
+ // Confirm
534
+ if (kb.matches(keyData, "selectConfirm")) {
535
+ const model = this.getSelectedModel();
536
+ if (model) this.handleSelect(model);
537
+ return;
538
+ }
539
+
540
+ // Cancel
541
+ if (kb.matches(keyData, "selectCancel")) {
542
+ this.onCancelCallback();
543
+ return;
544
+ }
545
+
546
+ // Everything else: feed to search input
547
+ const prevQuery = this.searchInput.getValue();
548
+ this.searchInput.handleInput(keyData);
549
+ const newQuery = this.searchInput.getValue();
550
+
551
+ if (newQuery !== prevQuery) {
552
+ const entering = !prevQuery && !!newQuery;
553
+ const leaving = !!prevQuery && !newQuery;
554
+
555
+ if (entering) {
556
+ // Entering search mode: remember current model position
557
+ this.isSearching = true;
558
+ this.selectedFlatIndex = 0;
559
+ } else if (leaving) {
560
+ // Leaving search mode: return to grouped view, restore position
561
+ this.isSearching = false;
562
+ this.buildGroupedRows();
563
+ this.jumpToCurrentModel();
564
+ }
565
+ if (this.isSearching) {
566
+ this.filterModels(newQuery);
567
+ } else {
568
+ this.updateList();
569
+ }
570
+ }
571
+ }
572
+
573
+ /** Move selection up, skipping headers in grouped mode */
574
+ private moveUp(): void {
575
+ if (this.isSearching) {
316
576
  if (this.filteredModels.length === 0) return;
317
- this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
577
+ this.selectedFlatIndex =
578
+ this.selectedFlatIndex === 0
579
+ ? this.filteredModels.length - 1
580
+ : this.selectedFlatIndex - 1;
318
581
  this.updateList();
582
+ return;
583
+ }
584
+
585
+ if (this.groupedRows.length === 0) return;
586
+ let next = this.selectedGroupIndex - 1;
587
+ // Wrap
588
+ if (next < 0) next = this.groupedRows.length - 1;
589
+ // Skip headers
590
+ while (next > 0 && this.groupedRows[next]?.kind === "header") {
591
+ next--;
592
+ }
593
+ // If landed on header at 0, wrap to bottom
594
+ if (this.groupedRows[next]?.kind === "header") {
595
+ next = this.groupedRows.length - 1;
319
596
  }
320
- // Down arrow - wrap to top when at bottom
321
- else if (kb.matches(keyData, "selectDown")) {
597
+ this.selectedGroupIndex = next;
598
+ this.updateList();
599
+ }
600
+
601
+ /** Move selection down, skipping headers in grouped mode */
602
+ private moveDown(): void {
603
+ if (this.isSearching) {
322
604
  if (this.filteredModels.length === 0) return;
323
- this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
605
+ this.selectedFlatIndex =
606
+ this.selectedFlatIndex === this.filteredModels.length - 1
607
+ ? 0
608
+ : this.selectedFlatIndex + 1;
324
609
  this.updateList();
610
+ return;
325
611
  }
326
- // Enter
327
- else if (kb.matches(keyData, "selectConfirm")) {
328
- const selectedModel = this.filteredModels[this.selectedIndex];
329
- if (selectedModel) {
330
- this.handleSelect(selectedModel.model);
331
- }
332
- }
333
- // Escape or Ctrl+C
334
- else if (kb.matches(keyData, "selectCancel")) {
335
- this.onCancelCallback();
612
+
613
+ if (this.groupedRows.length === 0) return;
614
+ let next = this.selectedGroupIndex + 1;
615
+ // Wrap
616
+ if (next >= this.groupedRows.length) next = 0;
617
+ // Skip headers
618
+ while (next < this.groupedRows.length - 1 && this.groupedRows[next]?.kind === "header") {
619
+ next++;
336
620
  }
337
- // Pass everything else to search input
338
- else {
339
- this.searchInput.handleInput(keyData);
340
- this.filterModels(this.searchInput.getValue());
621
+ // If landed on header at end, wrap to first model
622
+ if (this.groupedRows[next]?.kind === "header") {
623
+ next = this.modelRowIndices[0] ?? 0;
341
624
  }
625
+ this.selectedGroupIndex = next;
626
+ this.updateList();
342
627
  }
343
628
 
344
629
  private handleSelect(model: Model<any>): void {
@@ -2063,6 +2063,12 @@ export class InteractiveMode {
2063
2063
  this.handleThinkingCommand(arg);
2064
2064
  return;
2065
2065
  }
2066
+ if (text === "/edit-mode" || text.startsWith("/edit-mode ")) {
2067
+ const arg = text.startsWith("/edit-mode ") ? text.slice(11).trim() : undefined;
2068
+ this.editor.setText("");
2069
+ this.handleEditModeCommand(arg);
2070
+ return;
2071
+ }
2066
2072
  if (text === "/debug") {
2067
2073
  this.handleDebugCommand();
2068
2074
  this.editor.setText("");
@@ -2891,6 +2897,27 @@ export class InteractiveMode {
2891
2897
  this.showThinkingSelector();
2892
2898
  }
2893
2899
 
2900
+ private handleEditModeCommand(arg?: string): void {
2901
+ const modes = ["standard", "hashline"] as const;
2902
+
2903
+ if (arg) {
2904
+ const mode = arg.toLowerCase();
2905
+ if (!modes.includes(mode as typeof modes[number])) {
2906
+ this.showStatus(`Invalid edit mode "${arg}". Available: standard, hashline`);
2907
+ return;
2908
+ }
2909
+ this.session.setEditMode(mode as "standard" | "hashline");
2910
+ this.showStatus(`Edit mode: ${mode}${mode === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
2911
+ return;
2912
+ }
2913
+
2914
+ // Toggle
2915
+ const current = this.session.editMode;
2916
+ const next = current === "standard" ? "hashline" : "standard";
2917
+ this.session.setEditMode(next);
2918
+ this.showStatus(`Edit mode: ${next}${next === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
2919
+ }
2920
+
2894
2921
  private showThinkingSelector(): void {
2895
2922
  const availableLevels = this.session.getAvailableThinkingLevels();
2896
2923
  this.showSelector((done) => {
@@ -3883,12 +3910,16 @@ export class InteractiveMode {
3883
3910
  const selector = new OAuthSelectorComponent(
3884
3911
  mode,
3885
3912
  this.session.modelRegistry.authStorage,
3886
- async (providerId: string) => {
3913
+ (providerId: string) => {
3887
3914
  done();
3888
3915
 
3889
- if (mode === "login") {
3890
- await this.showLoginDialog(providerId);
3891
- } else {
3916
+ // OAuthSelectorComponent calls this synchronously (no await),
3917
+ // so we must catch async errors here to prevent unhandled rejections
3918
+ // when the user cancels the login dialog (#821).
3919
+ const handleAsync = async () => {
3920
+ if (mode === "login") {
3921
+ await this.showLoginDialog(providerId);
3922
+ } else {
3892
3923
  // Logout flow
3893
3924
  const providerInfo = this.session.modelRegistry.authStorage
3894
3925
  .getOAuthProviders()
@@ -3919,6 +3950,11 @@ export class InteractiveMode {
3919
3950
  this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
3920
3951
  }
3921
3952
  }
3953
+ };
3954
+ handleAsync().catch(() => {
3955
+ // Swallow — showLoginDialog already handles its own errors.
3956
+ // This prevents unhandled rejections when login is cancelled.
3957
+ });
3922
3958
  },
3923
3959
  () => {
3924
3960
  done();