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

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