@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.
- package/CHANGELOG.md +54 -0
- package/dist/cli.js +353 -294
- package/dist/types/config/api-key-resolver.d.ts +9 -3
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/model-discovery.d.ts +6 -4
- package/dist/types/config/model-registry.d.ts +7 -4
- package/dist/types/config/settings-schema.d.ts +458 -155
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/mnemopi/config.d.ts +3 -1
- package/dist/types/modes/components/settings-defs.d.ts +9 -2
- package/dist/types/modes/components/settings-selector.d.ts +9 -4
- package/dist/types/modes/components/tool-execution.d.ts +12 -1
- package/dist/types/modes/components/transcript-container.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +9 -1
- package/dist/types/modes/theme/theme.d.ts +23 -3
- package/dist/types/session/agent-session.d.ts +14 -7
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/snapcompact-inline.d.ts +28 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
- package/dist/types/system-prompt.d.ts +3 -1
- package/dist/types/task/render.d.ts +16 -6
- package/dist/types/tools/gh.d.ts +3 -0
- package/dist/types/tools/render-utils.d.ts +8 -16
- package/dist/types/utils/session-color.d.ts +15 -3
- package/dist/types/web/kagi.d.ts +1 -2
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/dist/types/web/search/providers/gemini.d.ts +9 -6
- package/package.json +11 -11
- package/src/auto-thinking/classifier.ts +1 -5
- package/src/commit/model-selection.ts +3 -6
- package/src/config/api-key-resolver.ts +10 -3
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-discovery.ts +60 -46
- package/src/config/model-registry.ts +21 -8
- package/src/config/model-resolver.ts +57 -3
- package/src/config/settings-schema.ts +601 -153
- package/src/eval/completion-bridge.ts +1 -5
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +13 -6
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/issue-pr-protocol.ts +10 -4
- package/src/memories/index.ts +2 -10
- package/src/mnemopi/backend.ts +30 -8
- package/src/mnemopi/config.ts +6 -1
- package/src/mnemopi/state.ts +6 -0
- package/src/modes/components/extensions/inspector-panel.ts +6 -2
- package/src/modes/components/plan-review-overlay.ts +15 -17
- package/src/modes/components/plugin-settings.ts +22 -5
- package/src/modes/components/settings-defs.ts +19 -4
- package/src/modes/components/settings-selector.ts +493 -93
- package/src/modes/components/status-line/component.ts +3 -1
- package/src/modes/components/status-line/segments.ts +3 -1
- package/src/modes/components/tool-execution.ts +69 -12
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +16 -6
- package/src/modes/controllers/command-controller.ts +37 -7
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +68 -6
- package/src/modes/controllers/selector-controller.ts +81 -61
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/rpc/rpc-mode.ts +2 -1
- package/src/modes/shared.ts +2 -0
- package/src/modes/theme/theme.ts +100 -7
- package/src/modes/utils/context-usage.ts +3 -1
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +9 -5
- package/src/prompts/system/personalities/default.md +26 -0
- package/src/prompts/system/personalities/friendly.md +17 -0
- package/src/prompts/system/personalities/pragmatic.md +15 -0
- package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-system-stub.md +1 -0
- package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
- package/src/prompts/system/system-prompt.md +5 -22
- package/src/prompts/tools/task.md +3 -3
- package/src/sdk.ts +22 -1
- package/src/session/agent-session.ts +91 -24
- package/src/session/auth-storage.ts +1 -0
- package/src/session/session-dump-format.ts +8 -1
- package/src/session/session-manager.ts +5 -5
- package/src/session/snapcompact-inline.ts +187 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
- package/src/slash-commands/helpers/usage-report.ts +24 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/render.ts +29 -19
- package/src/tool-discovery/tool-index.ts +2 -0
- package/src/tools/bash.ts +10 -3
- package/src/tools/eval-render.ts +13 -8
- package/src/tools/gh.ts +39 -1
- package/src/tools/image-gen.ts +114 -78
- package/src/tools/inspect-image.ts +1 -5
- package/src/tools/job.ts +25 -5
- package/src/tools/read.ts +1 -57
- package/src/tools/render-utils.ts +29 -31
- package/src/tools/ssh.ts +3 -3
- package/src/tools/tts.ts +40 -20
- package/src/utils/clipboard.ts +56 -4
- package/src/utils/commit-message-generator.ts +1 -5
- package/src/utils/session-color.ts +83 -9
- package/src/utils/title-generator.ts +1 -1
- package/src/web/kagi.ts +26 -27
- package/src/web/search/providers/codex.ts +42 -40
- package/src/web/search/providers/gemini.ts +42 -22
- 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 {
|
|
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
|
|
286
|
+
export class SettingsSelectorComponent implements Component {
|
|
222
287
|
#tabBar: TabBar;
|
|
223
288
|
#currentList: SettingsList | null = null;
|
|
224
|
-
#
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
this.
|
|
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.#
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
if (this.#
|
|
262
|
-
|
|
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.#
|
|
266
|
-
|
|
267
|
-
this.#pluginComponent = null;
|
|
359
|
+
if (this.#currentTabId === "plugins") {
|
|
360
|
+
return "Tab to switch tabs · Esc to close";
|
|
268
361
|
}
|
|
269
|
-
if (this.#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
this
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
) {
|
|
632
|
-
this.#
|
|
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
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
}
|