@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.4

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 (103) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +353 -294
  3. package/dist/types/config/api-key-resolver.d.ts +9 -3
  4. package/dist/types/config/keybindings.d.ts +1 -1
  5. package/dist/types/config/model-discovery.d.ts +6 -4
  6. package/dist/types/config/model-registry.d.ts +7 -4
  7. package/dist/types/config/settings-schema.d.ts +458 -155
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/mnemopi/config.d.ts +3 -1
  10. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  11. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  12. package/dist/types/modes/components/tool-execution.d.ts +12 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  15. package/dist/types/modes/theme/theme.d.ts +23 -3
  16. package/dist/types/session/agent-session.d.ts +14 -7
  17. package/dist/types/session/auth-storage.d.ts +1 -1
  18. package/dist/types/session/snapcompact-inline.d.ts +28 -0
  19. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  20. package/dist/types/system-prompt.d.ts +3 -1
  21. package/dist/types/task/render.d.ts +16 -6
  22. package/dist/types/tools/gh.d.ts +3 -0
  23. package/dist/types/tools/render-utils.d.ts +8 -16
  24. package/dist/types/utils/session-color.d.ts +15 -3
  25. package/dist/types/web/kagi.d.ts +1 -2
  26. package/dist/types/web/search/providers/codex.d.ts +1 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  28. package/package.json +11 -11
  29. package/src/auto-thinking/classifier.ts +1 -5
  30. package/src/commit/model-selection.ts +3 -6
  31. package/src/config/api-key-resolver.ts +10 -3
  32. package/src/config/keybindings.ts +1 -1
  33. package/src/config/model-discovery.ts +60 -46
  34. package/src/config/model-registry.ts +21 -8
  35. package/src/config/model-resolver.ts +57 -3
  36. package/src/config/settings-schema.ts +601 -153
  37. package/src/eval/completion-bridge.ts +1 -5
  38. package/src/export/html/template.generated.ts +1 -1
  39. package/src/export/html/template.js +13 -6
  40. package/src/internal-urls/docs-index.generated.ts +5 -5
  41. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  42. package/src/memories/index.ts +2 -10
  43. package/src/mnemopi/backend.ts +30 -8
  44. package/src/mnemopi/config.ts +6 -1
  45. package/src/mnemopi/state.ts +6 -0
  46. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  47. package/src/modes/components/plan-review-overlay.ts +15 -17
  48. package/src/modes/components/plugin-settings.ts +22 -5
  49. package/src/modes/components/settings-defs.ts +19 -4
  50. package/src/modes/components/settings-selector.ts +493 -93
  51. package/src/modes/components/status-line/component.ts +3 -1
  52. package/src/modes/components/status-line/segments.ts +3 -1
  53. package/src/modes/components/tool-execution.ts +69 -12
  54. package/src/modes/components/transcript-container.ts +26 -0
  55. package/src/modes/components/tree-selector.ts +16 -6
  56. package/src/modes/controllers/command-controller.ts +37 -7
  57. package/src/modes/controllers/event-controller.ts +1 -0
  58. package/src/modes/controllers/input-controller.ts +68 -6
  59. package/src/modes/controllers/selector-controller.ts +81 -61
  60. package/src/modes/interactive-mode.ts +4 -2
  61. package/src/modes/rpc/rpc-mode.ts +2 -1
  62. package/src/modes/shared.ts +2 -0
  63. package/src/modes/theme/theme.ts +100 -7
  64. package/src/modes/utils/context-usage.ts +3 -1
  65. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  66. package/src/modes/utils/ui-helpers.ts +9 -5
  67. package/src/prompts/system/personalities/default.md +26 -0
  68. package/src/prompts/system/personalities/friendly.md +17 -0
  69. package/src/prompts/system/personalities/pragmatic.md +15 -0
  70. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  71. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  72. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  73. package/src/prompts/system/system-prompt.md +5 -22
  74. package/src/prompts/tools/task.md +3 -3
  75. package/src/sdk.ts +22 -1
  76. package/src/session/agent-session.ts +91 -24
  77. package/src/session/auth-storage.ts +1 -0
  78. package/src/session/session-dump-format.ts +8 -1
  79. package/src/session/session-manager.ts +5 -5
  80. package/src/session/snapcompact-inline.ts +187 -0
  81. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  82. package/src/slash-commands/helpers/usage-report.ts +24 -3
  83. package/src/system-prompt.ts +15 -1
  84. package/src/task/render.ts +29 -19
  85. package/src/tool-discovery/tool-index.ts +2 -0
  86. package/src/tools/bash.ts +10 -3
  87. package/src/tools/eval-render.ts +13 -8
  88. package/src/tools/gh.ts +39 -1
  89. package/src/tools/image-gen.ts +114 -78
  90. package/src/tools/inspect-image.ts +1 -5
  91. package/src/tools/job.ts +25 -5
  92. package/src/tools/read.ts +1 -57
  93. package/src/tools/render-utils.ts +29 -31
  94. package/src/tools/ssh.ts +3 -3
  95. package/src/tools/tts.ts +40 -20
  96. package/src/utils/clipboard.ts +56 -4
  97. package/src/utils/commit-message-generator.ts +1 -5
  98. package/src/utils/session-color.ts +83 -9
  99. package/src/utils/title-generator.ts +1 -1
  100. package/src/web/kagi.ts +26 -27
  101. package/src/web/search/providers/codex.ts +42 -40
  102. package/src/web/search/providers/gemini.ts +42 -22
  103. package/src/web/search/providers/perplexity.ts +22 -10
@@ -1,17 +1,27 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Effort } from "@oh-my-pi/pi-ai";
3
3
  import {
4
+ type Component,
4
5
  Container,
6
+ extractPrintableText,
7
+ fuzzyFilter,
8
+ getKeybindings,
9
+ getSettingItemFilterText,
5
10
  Input,
6
11
  matchesKey,
12
+ padding,
13
+ parseSgrMouse,
7
14
  type SelectItem,
8
15
  SelectList,
9
16
  type SettingItem,
10
17
  SettingsList,
18
+ type SgrMouseEvent,
11
19
  Spacer,
12
20
  type Tab,
13
21
  TabBar,
14
22
  Text,
23
+ truncateToWidth,
24
+ visibleWidth,
15
25
  } from "@oh-my-pi/pi-tui";
16
26
  import { getDefault, type SettingPath, settings } from "../../config/settings";
17
27
  import type {
@@ -22,12 +32,11 @@ import type {
22
32
  } from "../../config/settings-schema";
23
33
  import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
24
34
  import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
25
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
26
35
  import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
27
36
  import { getTabBarTheme } from "../shared";
28
- import { DynamicBorder } from "./dynamic-border";
37
+ import { bottomBorder, divider, row, topBorder } from "./overlay-box";
29
38
  import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
30
- import { getSettingsForTab, type SettingDef } from "./settings-defs";
39
+ import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
31
40
  import { getPreset } from "./status-line/presets";
32
41
 
33
42
  /**
@@ -79,6 +88,8 @@ class SelectSubmenu extends Container {
79
88
  #selectList: SelectList;
80
89
  #previewText: Text | null = null;
81
90
  #previewUpdateRequestId: number = 0;
91
+ #selectListLineOffset = 0;
92
+ #selectListLineCount = 0;
82
93
 
83
94
  constructor(
84
95
  title: string,
@@ -158,19 +169,73 @@ class SelectSubmenu extends Container {
158
169
  }
159
170
  }
160
171
 
172
+ /**
173
+ * Concatenate children like Container.render, recording where the select
174
+ * list lands so routed mouse events can be hit-tested against it.
175
+ */
176
+ override render(width: number): readonly string[] {
177
+ const lines: string[] = [];
178
+ for (const child of this.children) {
179
+ const childLines = child.render(Math.max(1, width));
180
+ if (child === this.#selectList) {
181
+ this.#selectListLineOffset = lines.length;
182
+ this.#selectListLineCount = childLines.length;
183
+ }
184
+ lines.push(...childLines);
185
+ }
186
+ return lines;
187
+ }
188
+
189
+ /** Mouse routed from the host: wheel steps, hover lights, click confirms. */
190
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
191
+ if (event.wheel !== null) {
192
+ this.#selectList.handleWheel(event.wheel);
193
+ return;
194
+ }
195
+ const listLine = line - this.#selectListLineOffset;
196
+ const within = listLine >= 0 && listLine < this.#selectListLineCount;
197
+ const index = within ? this.#selectList.hitTest(listLine) : undefined;
198
+ if (event.motion) {
199
+ this.#selectList.setHoverIndex(index ?? null);
200
+ return;
201
+ }
202
+ if (event.leftClick && index !== undefined) {
203
+ this.#selectList.clickItem(index);
204
+ }
205
+ }
206
+
161
207
  handleInput(data: string): void {
162
208
  this.#selectList.handleInput(data);
163
209
  }
164
210
  }
165
211
 
212
+ let cachedSidebarWidth: number | undefined;
213
+ /**
214
+ * Split-sidebar width derived from every group name in the schema (not just
215
+ * the visible tab), so the divider column never moves when switching tabs or
216
+ * when condition-gated groups appear.
217
+ */
218
+ function settingsSidebarWidth(): number {
219
+ if (cachedSidebarWidth === undefined) {
220
+ let nameWidth = 0;
221
+ for (const tab of SETTING_TABS) {
222
+ for (const def of getSettingsForTab(tab)) {
223
+ if (def.group) nameWidth = Math.max(nameWidth, visibleWidth(def.group));
224
+ }
225
+ }
226
+ cachedSidebarWidth = Math.min(22, nameWidth) + 4;
227
+ }
228
+ return cachedSidebarWidth;
229
+ }
230
+
166
231
  function getSettingsTabs(): Tab[] {
167
232
  return [
168
233
  ...SETTING_TABS.map(id => {
169
234
  const meta = TAB_METADATA[id];
170
235
  const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
171
- return { id, label: `${icon} ${meta.label}` };
236
+ return { id, label: `${icon} ${meta.label}`, short: icon };
172
237
  }),
173
- { id: "plugins", label: `${theme.icon.package} Plugins` },
238
+ { id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package },
174
239
  ];
175
240
  }
176
241
 
@@ -218,72 +283,364 @@ export interface SettingsCallbacks {
218
283
  * Main tabbed settings selector component.
219
284
  * Uses declarative settings definitions from settings-defs.ts.
220
285
  */
221
- export class SettingsSelectorComponent extends Container {
286
+ export class SettingsSelectorComponent implements Component {
222
287
  #tabBar: TabBar;
223
288
  #currentList: SettingsList | null = null;
224
- #currentSubmenu: Container | null = null;
289
+ #searchList: SettingsList | null = null;
225
290
  #pluginComponent: PluginSettingsComponent | null = null;
226
- #statusPreviewContainer: Container | null = null;
227
- #statusPreviewText: Text | null = null;
228
291
  #currentTabId: SettingTab | "plugins" = "appearance";
292
+ #preSearchTabId: SettingTab | "plugins" = "appearance";
293
+ #searchQuery = "";
294
+ #searchMatchCount = 0;
295
+ /** First matching item id per tab id, for Tab-key jumps while searching. */
296
+ #searchFirstMatch = new Map<string, string>();
229
297
  #textInputActive = false;
298
+ #hasSectionJump = false;
299
+ // Frame geometry from the last render, for mouse hit-testing (the
300
+ // fullscreen overlay paints from screen row 0, so mouse rows map 1:1).
301
+ #tabRowStart = 0;
302
+ #tabRowCount = 0;
303
+ #contentRowStart = 0;
304
+ #contentRowCount = 0;
230
305
 
231
306
  constructor(
232
307
  private readonly context: SettingsRuntimeContext,
233
308
  private readonly callbacks: SettingsCallbacks,
234
309
  ) {
235
- super();
236
-
237
- // Add top border
238
- this.addChild(new DynamicBorder());
239
-
240
- // Tab bar
241
- this.#tabBar = new TabBar("Settings", getSettingsTabs(), getTabBarTheme());
310
+ // No label prefix (the frame title already says Settings) and no
311
+ // "(tab to cycle)" hint (folded into the footer hint line).
312
+ this.#tabBar = new TabBar("", getSettingsTabs(), getTabBarTheme());
313
+ this.#tabBar.showHint = false;
242
314
  this.#tabBar.onTabChange = () => {
243
- this.#switchToTab(this.#tabBar.getActiveTab().id as SettingTab | "plugins");
315
+ const tabId = this.#tabBar.getActiveTab().id as SettingTab | "plugins";
316
+ if (this.#searchList) {
317
+ // While searching, tabs act as jump targets into the result list.
318
+ const firstId = this.#searchFirstMatch.get(tabId);
319
+ if (firstId) this.#searchList.selectItem(firstId);
320
+ return;
321
+ }
322
+ this.#switchToTab(tabId);
244
323
  };
245
- this.addChild(this.#tabBar);
246
-
247
- // Spacer after tab bar
248
- this.addChild(new Spacer(1));
249
324
 
250
325
  // Initialize with first tab
251
326
  this.#switchToTab("appearance");
327
+ }
328
+
329
+ invalidate(): void {
330
+ this.#tabBar.invalidate();
331
+ this.#currentList?.invalidate();
332
+ this.#searchList?.invalidate();
333
+ this.#pluginComponent?.invalidate();
334
+ }
252
335
 
253
- // Add bottom border
254
- this.addChild(new DynamicBorder());
336
+ /** Swap the active content (per-tab list, search list, or plugins). */
337
+ #setContent(build: () => void): void {
338
+ this.#currentList = null;
339
+ this.#searchList = null;
340
+ this.#pluginComponent = null;
341
+ build();
255
342
  }
256
343
 
257
344
  #switchToTab(tabId: SettingTab | "plugins"): void {
258
345
  this.#currentTabId = tabId;
346
+ this.#setContent(() => {
347
+ if (tabId === "plugins") {
348
+ this.#showPluginsTab();
349
+ } else {
350
+ this.#showSettingsTab(tabId);
351
+ }
352
+ });
353
+ }
259
354
 
260
- // Remove current content
261
- if (this.#currentList) {
262
- this.removeChild(this.#currentList);
263
- this.#currentList = null;
355
+ #footerHintText(): string {
356
+ if (this.#searchList) {
357
+ return "Enter/Space to change · Tab to jump tabs · Backspace to edit · Esc to exit search";
264
358
  }
265
- if (this.#pluginComponent) {
266
- this.removeChild(this.#pluginComponent);
267
- this.#pluginComponent = null;
359
+ if (this.#currentTabId === "plugins") {
360
+ return "Tab to switch tabs · Esc to close";
268
361
  }
269
- if (this.#statusPreviewContainer) {
270
- this.removeChild(this.#statusPreviewContainer);
271
- this.#statusPreviewContainer = null;
272
- this.#statusPreviewText = null;
362
+ if (this.#currentList?.sectionFocused) {
363
+ return "↑/↓ to jump sections · Tab/Enter to settings · ←/→ to switch tabs · Esc to close";
364
+ }
365
+ const nav = this.#hasSectionJump ? "Tab to jump sections · ←/→ to switch tabs" : "Tab to switch tabs";
366
+ return `Enter/Space to change · ${nav} · Type to search · Esc to close`;
367
+ }
368
+
369
+ /** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */
370
+ #renderSearchBanner(width: number): string {
371
+ const icon = theme.symbol("icon.search");
372
+ const countText = this.#searchMatchCount === 1 ? "1 match" : `${this.#searchMatchCount} matches`;
373
+ const rightWidth = visibleWidth(countText) + 1; // trailing margin
374
+ // Fixed chrome: " <icon> " prefix plus the "▌" cursor cell.
375
+ const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1);
376
+
377
+ // Keep the tail visible (where the cursor is) when the query overflows.
378
+ let display = this.#searchQuery;
379
+ if (visibleWidth(display) > queryBudget) {
380
+ const chars = [...display];
381
+ while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) {
382
+ chars.shift();
383
+ }
384
+ display = `…${chars.join("")}`;
273
385
  }
274
386
 
275
- // Remove bottom border temporarily
276
- const bottomBorder = this.children[this.children.length - 1];
277
- this.removeChild(bottomBorder);
387
+ const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`;
388
+ const count = theme.fg(this.#searchMatchCount > 0 ? "dim" : "warning", countText);
389
+ const gap = Math.max(1, width - visibleWidth(left) - rightWidth);
390
+ return truncateToWidth(`${left}${padding(gap)}${count} `, width);
391
+ }
278
392
 
279
- if (tabId === "plugins") {
280
- this.#showPluginsTab();
393
+ /**
394
+ * Fullscreen frame: title border, tab row, divider, optional search banner,
395
+ * the active content sized to fill the terminal, the appearance preview,
396
+ * then a footer hint pinned above the bottom border.
397
+ */
398
+ render(width: number): readonly string[] {
399
+ const height = Math.max(14, process.stdout.rows || 40);
400
+ const innerWidth = Math.max(1, width - 4);
401
+
402
+ const tabLines = this.#tabBar.render(innerWidth);
403
+ const searching = this.#searchList !== null;
404
+ const showPreview = !searching && this.#currentTabId === "appearance";
405
+ const previewLines = showPreview ? ["", theme.fg("muted", "Preview:"), this.#getStatusPreviewString()] : [];
406
+
407
+ // Fixed chrome: top border, tabs, divider, [search row], divider, hint, bottom border.
408
+ const fixedRows = 1 + tabLines.length + 1 + (searching ? 1 : 0) + 1 + 1 + 1;
409
+ const contentRows = Math.max(7, height - fixedRows - previewLines.length);
410
+
411
+ const list = this.#searchList ?? this.#currentList;
412
+ let contentLines: readonly string[];
413
+ if (list) {
414
+ // SettingsList pads itself to viewport + blank + 3 description rows.
415
+ list.setMaxVisible(contentRows - 4);
416
+ contentLines = list.render(innerWidth);
417
+ } else if (this.#pluginComponent) {
418
+ contentLines = this.#pluginComponent.render(innerWidth);
281
419
  } else {
282
- this.#showSettingsTab(tabId);
420
+ contentLines = [];
421
+ }
422
+
423
+ const out: string[] = [];
424
+ out.push(topBorder(width, "Settings"));
425
+ this.#tabRowStart = out.length;
426
+ this.#tabRowCount = tabLines.length;
427
+ for (const line of tabLines) {
428
+ out.push(row(line, width));
429
+ }
430
+ out.push(divider(width));
431
+ if (searching) {
432
+ out.push(row(this.#renderSearchBanner(innerWidth), width));
433
+ }
434
+ this.#contentRowStart = out.length;
435
+ this.#contentRowCount = contentRows;
436
+ for (let i = 0; i < contentRows; i++) {
437
+ out.push(row(contentLines[i] ?? "", width));
438
+ }
439
+ for (const line of previewLines) {
440
+ out.push(row(line, width));
441
+ }
442
+ out.push(divider(width));
443
+ out.push(row(theme.fg("dim", this.#footerHintText()), width));
444
+ out.push(bottomBorder(width));
445
+ return out;
446
+ }
447
+
448
+ /**
449
+ * Route an SGR mouse report against the frame geometry of the last render.
450
+ * Wheel scrolls the focused list, motion drives the hover highlights (tabs
451
+ * and rows), and a left click activates: tabs switch (or jump, while
452
+ * searching), a row click selects, and a click on the already-selected row
453
+ * activates it (toggle / open submenu).
454
+ */
455
+ #handleMouse(data: string): boolean {
456
+ const event = parseSgrMouse(data);
457
+ if (!event) return false;
458
+
459
+ const list = this.#searchList ?? this.#currentList;
460
+ // row() insets content by two columns (border + space).
461
+ const innerCol = event.col - 2;
462
+ const contentLine = event.row - this.#contentRowStart;
463
+
464
+ // An open submenu owns the pointer: wheel, hover, and clicks route into
465
+ // it (text-input submenus ignore routed events).
466
+ if (list?.hasOpenSubmenu()) {
467
+ list.routeSubmenuMouse(event, contentLine, innerCol);
468
+ return true;
469
+ }
470
+
471
+ if (event.wheel !== null) {
472
+ list?.handleWheel(event.wheel);
473
+ return true;
283
474
  }
284
475
 
285
- // Re-add bottom border
286
- this.addChild(bottomBorder);
476
+ const tabLine = event.row - this.#tabRowStart;
477
+ const overTabs = tabLine >= 0 && tabLine < this.#tabRowCount;
478
+ const overContent = contentLine >= 0 && contentLine < this.#contentRowCount;
479
+
480
+ if (event.motion) {
481
+ const hovered = overTabs ? this.#tabBar.tabAt(tabLine, innerCol) : undefined;
482
+ this.#tabBar.setHoverTab(hovered && !hovered.muted ? hovered.id : null);
483
+ // hoverTest: never light up pane rows while the pointer is on the
484
+ // sidebar — only rows the pointer is actually on.
485
+ list?.setHoverItem(overContent ? (list.hoverTest(contentLine, innerCol) ?? null) : null);
486
+ return true;
487
+ }
488
+ if (!event.leftClick) return true;
489
+
490
+ if (overTabs) {
491
+ const tab = this.#tabBar.tabAt(tabLine, innerCol);
492
+ if (tab) this.#tabBar.selectTab(tab.id);
493
+ return true;
494
+ }
495
+ if (overContent && list) {
496
+ const id = list.hitTest(contentLine, innerCol);
497
+ if (id !== undefined) {
498
+ const wasSelected = list.getSelectedItem()?.id === id;
499
+ list.selectItem(id);
500
+ // Click-again activates: toggle booleans, open submenus.
501
+ if (wasSelected) list.handleInput("\n");
502
+ }
503
+ }
504
+ return true;
505
+ }
506
+
507
+ // ═══════════════════════════════════════════════════════════════════════
508
+ // Global search (type-to-search across every tab)
509
+ // ═══════════════════════════════════════════════════════════════════════
510
+
511
+ /** Swap the tab content for the global search result list. */
512
+ #startSearch(initialQuery: string): void {
513
+ this.#preSearchTabId = this.#currentTabId;
514
+ const list = new SettingsList(
515
+ [],
516
+ 10,
517
+ getSettingsListTheme(),
518
+ (id, newValue) => this.#onSearchSettingChange(id as SettingPath, newValue),
519
+ () => this.callbacks.onCancel(),
520
+ {
521
+ layout: "flat",
522
+ typeToSearch: false,
523
+ emptyText: "No matching settings",
524
+ hint: "",
525
+ },
526
+ );
527
+ // Keep the footer tab highlight on the tab owning the selected result.
528
+ list.onSelectionChange = item => this.#syncTabBarToSelection(item);
529
+ this.#setContent(() => {
530
+ this.#searchList = list;
531
+ });
532
+ this.#setSearchQuery(initialQuery);
533
+ }
534
+
535
+ /**
536
+ * Recompute matches across every settings tab. Results render as one flat
537
+ * list with a heading row per tab; the footer tab bar reorders to show
538
+ * matching tabs (with counts) first and the rest muted at the end.
539
+ */
540
+ #setSearchQuery(query: string): void {
541
+ if (!this.#searchList) return;
542
+ if (query.length === 0) {
543
+ this.#endSearch(false);
544
+ return;
545
+ }
546
+ this.#searchQuery = query;
547
+
548
+ const counts = new Map<SettingTab, number>();
549
+ const items: SettingItem[] = [];
550
+ this.#searchFirstMatch.clear();
551
+ let total = 0;
552
+ for (const tab of SETTING_TABS) {
553
+ const candidates: SettingItem[] = [];
554
+ for (const def of getSettingsForTab(tab)) {
555
+ const item = this.#defToItem(def);
556
+ if (item) candidates.push(item);
557
+ }
558
+ const matched = fuzzyFilter(candidates, query, getSettingItemFilterText);
559
+ counts.set(tab, matched.length);
560
+ if (matched.length === 0) continue;
561
+ total += matched.length;
562
+ const meta = TAB_METADATA[tab];
563
+ items.push({
564
+ id: `__tab:${tab}`,
565
+ label: `${theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0])} ${meta.label}`,
566
+ currentValue: "",
567
+ heading: true,
568
+ });
569
+ this.#searchFirstMatch.set(tab, matched[0].id);
570
+ items.push(...matched);
571
+ }
572
+
573
+ this.#searchList.setItems(items);
574
+ this.#searchMatchCount = total;
575
+ this.#tabBar.setTabs(this.#buildSearchTabs(counts));
576
+ this.#syncTabBarToSelection(this.#searchList.getSelectedItem());
577
+ }
578
+
579
+ /**
580
+ * Leave search mode. With `jumpToSelection`, land on the tab containing
581
+ * the selected result and keep it selected there — search doubles as
582
+ * navigation. Otherwise restore the pre-search tab.
583
+ */
584
+ #endSearch(jumpToSelection: boolean): void {
585
+ if (!this.#searchList) return;
586
+ const selected = jumpToSelection ? this.#searchList.getSelectedItem() : undefined;
587
+ const selectedDef = selected ? getSettingDef(selected.id as SettingPath) : undefined;
588
+ const targetTab: SettingTab | "plugins" = selectedDef?.tab ?? this.#preSearchTabId;
589
+
590
+ this.#searchQuery = "";
591
+ this.#searchFirstMatch.clear();
592
+ this.#searchMatchCount = 0;
593
+ this.#tabBar.setTabs(getSettingsTabs(), targetTab);
594
+ this.#switchToTab(targetTab);
595
+ if (selectedDef) {
596
+ this.#currentList?.selectItem(selectedDef.path);
597
+ }
598
+ }
599
+
600
+ /** Matching tabs first (counts attached), the rest muted at the end. */
601
+ #buildSearchTabs(counts: Map<SettingTab, number>): Tab[] {
602
+ const matched: Tab[] = [];
603
+ const empty: Tab[] = [];
604
+ for (const id of SETTING_TABS) {
605
+ const meta = TAB_METADATA[id];
606
+ const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
607
+ const count = counts.get(id) ?? 0;
608
+ if (count > 0) {
609
+ matched.push({ id, label: `${icon} ${meta.label} (${count})`, short: `${icon} ${count}` });
610
+ } else {
611
+ empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true });
612
+ }
613
+ }
614
+ // Plugins hosts its own UI; it is not part of the schema-backed search.
615
+ empty.push({ id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package, muted: true });
616
+ return [...matched, ...empty];
617
+ }
618
+
619
+ #syncTabBarToSelection(item: SettingItem | undefined): void {
620
+ if (!this.#searchList || !item) return;
621
+ const def = getSettingDef(item.id as SettingPath);
622
+ if (def) this.#tabBar.setActiveById(def.tab);
623
+ }
624
+
625
+ /** Value-change dispatch for the search result list (any tab's setting). */
626
+ #onSearchSettingChange(path: SettingPath, newValue: string): void {
627
+ const def = getSettingDef(path);
628
+ if (!def) return;
629
+ if (def.type === "boolean") {
630
+ const boolValue = newValue === "true";
631
+ settings.set(path, boolValue as never);
632
+ this.callbacks.onChange(path, boolValue);
633
+ } else if (def.type === "enum") {
634
+ settings.set(path, newValue as never);
635
+ this.callbacks.onChange(path, newValue);
636
+ }
637
+ // Submenu/text types already persisted inside their own done callbacks.
638
+ if (def.tab === "appearance") {
639
+ this.#triggerStatusLinePreview();
640
+ }
641
+ // Values feed the searchable text and condition gates may have flipped:
642
+ // recompute results in place (selection is preserved by item id).
643
+ this.#setSearchQuery(this.#searchQuery);
287
644
  }
288
645
 
289
646
  /**
@@ -408,7 +765,6 @@ export class SettingsSelectorComponent extends Container {
408
765
  rightSegments: presetDef.rightSegments,
409
766
  separator: presetDef.separator,
410
767
  });
411
- this.#updateStatusPreview();
412
768
  };
413
769
  onPreviewCancel = () => {
414
770
  const currentPreset = settings.get("statusLine.preset");
@@ -419,17 +775,14 @@ export class SettingsSelectorComponent extends Container {
419
775
  rightSegments: presetDef.rightSegments,
420
776
  separator: presetDef.separator,
421
777
  });
422
- this.#updateStatusPreview();
423
778
  };
424
779
  } else if (def.path === "statusLine.separator") {
425
780
  onPreview = value => {
426
781
  this.callbacks.onStatusLinePreview?.({ separator: value as StatusLineSeparatorStyle });
427
- this.#updateStatusPreview();
428
782
  };
429
783
  onPreviewCancel = () => {
430
784
  const separator = settings.get("statusLine.separator");
431
785
  this.callbacks.onStatusLinePreview?.({ separator });
432
- this.#updateStatusPreview();
433
786
  };
434
787
  }
435
788
 
@@ -509,19 +862,15 @@ export class SettingsSelectorComponent extends Container {
509
862
  #showSettingsTab(tabId: SettingTab): void {
510
863
  const defs = getSettingsForTab(tabId);
511
864
 
512
- // Add status line preview for appearance tab
513
- if (tabId === "appearance") {
514
- this.#statusPreviewContainer = new Container();
515
- this.#statusPreviewContainer.addChild(new Spacer(1));
516
- this.#statusPreviewContainer.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0));
517
- this.#statusPreviewText = new Text(this.#getStatusPreviewString(), 0, 0);
518
- this.#statusPreviewContainer.addChild(this.#statusPreviewText);
519
- this.#statusPreviewContainer.addChild(new Spacer(1));
520
- this.addChild(this.#statusPreviewContainer);
521
- }
865
+ const items = this.#buildItemsForDefs(defs);
866
+ // Mirror SettingsList's section detection (leading ungrouped items form
867
+ // an implicit section) so the footer hint only advertises PgUp/PgDn
868
+ // when the jump actually changes sections.
869
+ const sectionCount = items.filter(item => item.heading).length + (items.length > 0 && !items[0].heading ? 1 : 0);
870
+ this.#hasSectionJump = sectionCount >= 2;
522
871
 
523
872
  this.#currentList = new SettingsList(
524
- this.#buildItemsForDefs(defs),
873
+ items,
525
874
  10,
526
875
  getSettingsListTheme(),
527
876
  (id, newValue) => {
@@ -550,17 +899,28 @@ export class SettingsSelectorComponent extends Container {
550
899
  this.#refreshCurrentTabItems(defs);
551
900
  },
552
901
  () => this.callbacks.onCancel(),
902
+ // The selector owns type-to-search and the footer hint; pin the
903
+ // split sidebar width so the divider never jumps between tabs.
904
+ { typeToSearch: false, hint: "", sidebarWidth: settingsSidebarWidth() },
553
905
  );
554
-
555
- this.addChild(this.#currentList);
556
906
  }
557
907
 
558
- /** Map a definition list to UI items, dropping any whose condition is false. */
908
+ /**
909
+ * Map a definition list to UI items, dropping any whose condition is false.
910
+ * Inserts a heading row whenever the (group-sorted) definition list crosses
911
+ * into a new group; groups whose items are all condition-hidden emit none.
912
+ */
559
913
  #buildItemsForDefs(defs: SettingDef[]): SettingItem[] {
560
914
  const items: SettingItem[] = [];
915
+ let lastGroup: string | undefined;
561
916
  for (const def of defs) {
562
917
  const item = this.#defToItem(def);
563
- if (item) items.push(item);
918
+ if (!item) continue;
919
+ if (def.group && def.group !== lastGroup) {
920
+ items.push({ id: `__heading:${def.group}`, label: def.group, currentValue: "", heading: true });
921
+ lastGroup = def.group;
922
+ }
923
+ items.push(item);
564
924
  }
565
925
  return items;
566
926
  }
@@ -594,16 +954,6 @@ export class SettingsSelectorComponent extends Container {
594
954
  transparent: settings.get("statusLine.transparent"),
595
955
  };
596
956
  this.callbacks.onStatusLinePreview?.(statusLineSettings);
597
- this.#updateStatusPreview();
598
- }
599
-
600
- /**
601
- * Update the inline status preview text.
602
- */
603
- #updateStatusPreview(): void {
604
- if (this.#statusPreviewText && this.#currentTabId === "appearance") {
605
- this.#statusPreviewText.setText(this.#getStatusPreviewString());
606
- }
607
957
  }
608
958
 
609
959
  #showPluginsTab(): void {
@@ -611,43 +961,93 @@ export class SettingsSelectorComponent extends Container {
611
961
  onClose: () => this.callbacks.onCancel(),
612
962
  onPluginChanged: () => this.callbacks.onPluginsChanged?.(),
613
963
  });
614
- this.addChild(this.#pluginComponent);
615
- }
616
-
617
- getFocusComponent(): SettingsList | PluginSettingsComponent {
618
- // Return the current focusable component - one of these will always be set
619
- return (this.#currentList || this.#pluginComponent)!;
620
964
  }
621
965
 
622
966
  handleInput(data: string): void {
623
- // Handle tab switching but NOT when a text input is active, since
624
- // arrow keys must reach the cursor and Tab must not switch tabs.
625
- if (
626
- !this.#textInputActive &&
627
- (matchesKey(data, "tab") ||
628
- matchesKey(data, "shift+tab") ||
629
- matchesKey(data, "left") ||
630
- matchesKey(data, "right"))
631
- ) {
632
- this.#tabBar.handleInput(data);
967
+ // SGR mouse reports (the fullscreen overlay enables tracking).
968
+ if (data.startsWith("\x1b[<")) {
969
+ this.#handleMouse(data);
970
+ return;
971
+ }
972
+
973
+ // Text-input submenus take every byte: arrow keys must reach the
974
+ // cursor and Tab must not switch tabs.
975
+ if (this.#textInputActive) {
976
+ (this.#searchList ?? this.#currentList)?.handleInput(data);
977
+ return;
978
+ }
979
+
980
+ const activeList = this.#searchList ?? this.#currentList;
981
+
982
+ // An open submenu owns input entirely — Tab/arrows/typing belong to it.
983
+ if (activeList?.hasOpenSubmenu()) {
984
+ activeList.handleInput(data);
985
+ return;
986
+ }
987
+
988
+ if (this.#searchList) {
989
+ this.#handleSearchModeInput(data, this.#searchList);
633
990
  return;
634
991
  }
635
992
 
636
- // Escape clears an active settings search before closing the panel.
637
- if (matchesAppInterrupt(data) && !this.#currentSubmenu) {
638
- if (this.#currentList?.hasSearchQuery()) {
639
- this.#currentList.clearSearch();
993
+ // Tab toggles keyboard focus between section headings and setting rows
994
+ // (fast section hopping); tabs without sections keep Tab switching tabs.
995
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
996
+ if (this.#currentList?.hasSectionFocusTargets()) {
997
+ this.#currentList.toggleSectionFocus();
640
998
  return;
641
999
  }
642
- this.callbacks.onCancel();
1000
+ this.#tabBar.handleInput(data);
1001
+ return;
1002
+ }
1003
+ if (matchesKey(data, "left") || matchesKey(data, "right")) {
1004
+ this.#tabBar.handleInput(data);
643
1005
  return;
644
1006
  }
645
1007
 
646
- // Pass to current content
1008
+ // Printable characters start a search across every settings tab. The
1009
+ // plugins tab keeps its own local filtering instead.
1010
+ if (this.#currentTabId !== "plugins") {
1011
+ const printable = extractPrintableText(data);
1012
+ if (printable !== undefined && printable.trim().length > 0) {
1013
+ this.#startSearch(printable);
1014
+ return;
1015
+ }
1016
+ }
1017
+
647
1018
  if (this.#currentList) {
648
1019
  this.#currentList.handleInput(data);
649
1020
  } else if (this.#pluginComponent) {
650
1021
  this.#pluginComponent.handleInput(data);
651
1022
  }
652
1023
  }
1024
+
1025
+ #handleSearchModeInput(data: string, list: SettingsList): void {
1026
+ const kb = getKeybindings();
1027
+ if (kb.matches(data, "tui.select.cancel")) {
1028
+ // Exit search, landing on the tab of the selected result.
1029
+ this.#endSearch(true);
1030
+ return;
1031
+ }
1032
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1033
+ this.#setSearchQuery([...this.#searchQuery].slice(0, -1).join(""));
1034
+ return;
1035
+ }
1036
+ if (
1037
+ matchesKey(data, "tab") ||
1038
+ matchesKey(data, "shift+tab") ||
1039
+ matchesKey(data, "left") ||
1040
+ matchesKey(data, "right")
1041
+ ) {
1042
+ // Jump between tabs that have matches (muted tabs are skipped).
1043
+ this.#tabBar.handleInput(data);
1044
+ return;
1045
+ }
1046
+ const printable = extractPrintableText(data);
1047
+ if (printable !== undefined) {
1048
+ this.#setSearchQuery(this.#searchQuery + printable);
1049
+ return;
1050
+ }
1051
+ list.handleInput(data);
1052
+ }
653
1053
  }