pi-open-tui 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,15 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  Box,
4
+ getKeybindings,
5
+ getSelectPageIndex,
4
6
  Key,
5
7
  matchesKey,
6
8
  SelectList,
7
9
  type SelectItem,
8
10
  type TUI,
11
+ type TuiMouseEvent,
12
+ type TuiMouseEventResult,
9
13
  Text,
10
14
  truncateToWidth,
11
15
  visibleWidth,
@@ -18,6 +22,15 @@ interface SettingItem {
18
22
  currentValue: string;
19
23
  }
20
24
 
25
+ interface SettingGroup {
26
+ label?: string;
27
+ items: SettingItem[];
28
+ }
29
+
30
+ type SettingRow =
31
+ | { kind: "header"; label: string }
32
+ | { kind: "setting"; item: SettingItem };
33
+
21
34
  type Tab = "features" | "icons" | "segments" | "telemetry";
22
35
 
23
36
  const TABS: Tab[] = ["features", "icons", "segments", "telemetry"];
@@ -26,6 +39,20 @@ const COPY = {
26
39
  en: {
27
40
  title: "Open TUI Settings",
28
41
  tabs: { features: "General", icons: "Appearance", segments: "Footer", telemetry: "Telemetry" },
42
+ sections: {
43
+ extension: "Extension",
44
+ interface: "Interface",
45
+ messages: "Messages & tools",
46
+ footer: "Footer",
47
+ icons: "Icons",
48
+ editor: "Editor",
49
+ location: "Location",
50
+ git: "Git",
51
+ runtime: "Runtime",
52
+ usage: "Usage",
53
+ other: "Other",
54
+ measurements: "Measurements",
55
+ },
29
56
  hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
30
57
  labels: {
31
58
  enabled: "Enabled",
@@ -34,6 +61,7 @@ const COPY = {
34
61
  copyButtons: "Copy buttons",
35
62
  thinkingPeek: "Thinking peek",
36
63
  toolView: "Tool view",
64
+ extensionTools: "Compact extension tools",
37
65
  language: "Language",
38
66
  cursorStyle: "Cursor style",
39
67
  iconMode: "Icon mode",
@@ -48,6 +76,7 @@ const COPY = {
48
76
  tokens: "Session tokens",
49
77
  cost: "Session cost",
50
78
  extensionStatuses: "Extension status line",
79
+ extensionSegments: "Extension footer segments",
51
80
  capitalizeProviderName: "Capitalize provider name",
52
81
  nerdIconSpacing: "Extra space after Nerd icons",
53
82
  liveTps: "Live TPS",
@@ -69,6 +98,20 @@ const COPY = {
69
98
  zh: {
70
99
  title: "Open TUI 设置",
71
100
  tabs: { features: "常规", icons: "外观", segments: "底栏", telemetry: "遥测" },
101
+ sections: {
102
+ extension: "扩展",
103
+ interface: "界面",
104
+ messages: "消息与工具",
105
+ footer: "底栏",
106
+ icons: "图标",
107
+ editor: "输入框",
108
+ location: "位置",
109
+ git: "Git",
110
+ runtime: "运行环境",
111
+ usage: "用量",
112
+ other: "其他",
113
+ measurements: "测量指标",
114
+ },
72
115
  hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
73
116
  labels: {
74
117
  enabled: "启用",
@@ -77,6 +120,7 @@ const COPY = {
77
120
  copyButtons: "复制按钮",
78
121
  thinkingPeek: "思考预览",
79
122
  toolView: "工具视图",
123
+ extensionTools: "紧凑显示扩展工具",
80
124
  language: "语言",
81
125
  cursorStyle: "光标样式",
82
126
  iconMode: "图标模式",
@@ -91,6 +135,7 @@ const COPY = {
91
135
  tokens: "会话 Token",
92
136
  cost: "会话费用",
93
137
  extensionStatuses: "扩展状态行",
138
+ extensionSegments: "扩展底栏片段",
94
139
  capitalizeProviderName: "提供商名称首字母大写",
95
140
  nerdIconSpacing: "Nerd 图标后额外留空",
96
141
  liveTps: "实时 TPS",
@@ -165,69 +210,132 @@ function cycleThinkingPeek(config: OpenTuiConfig): OpenTuiConfig {
165
210
  };
166
211
  }
167
212
 
168
- function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
213
+ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
169
214
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
170
215
  return [
171
- { id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
172
- { id: "header", label: copy.labels.header, currentValue: flag(config.header) },
173
- { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
174
- { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
175
- { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
176
- { id: "copyButtons", label: copy.labels.copyButtons, currentValue: flag(config.copyButtons) },
177
- { id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
216
+ {
217
+ label: copy.sections.extension,
218
+ items: [{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) }],
219
+ },
220
+ {
221
+ label: copy.sections.interface,
222
+ items: [
223
+ { id: "header", label: copy.labels.header, currentValue: flag(config.header) },
224
+ { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
225
+ ],
226
+ },
227
+ {
228
+ label: copy.sections.messages,
229
+ items: [
230
+ { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
231
+ { id: "copyButtons", label: copy.labels.copyButtons, currentValue: flag(config.copyButtons) },
232
+ { id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
233
+ ...(config.toolView.mode === "compact" ? [
234
+ { id: "extensionTools", label: ` ${copy.labels.extensionTools}`, currentValue: flag(config.toolView.extensionTools) },
235
+ ] : []),
236
+ ],
237
+ },
238
+ {
239
+ label: copy.sections.footer,
240
+ items: [{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) }],
241
+ },
178
242
  ];
179
243
  }
180
244
 
181
- function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
245
+ function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
182
246
  return [
183
- { id: "mode", label: copy.labels.iconMode, currentValue: copy.values.icons[config.icons.mode] },
184
- { id: "cursorStyle", label: copy.labels.cursorStyle, currentValue: copy.values.cursorStyles[config.cursorStyle] },
247
+ {
248
+ label: copy.sections.icons,
249
+ items: [{ id: "mode", label: copy.labels.iconMode, currentValue: copy.values.icons[config.icons.mode] }],
250
+ },
251
+ {
252
+ label: copy.sections.editor,
253
+ items: [{ id: "cursorStyle", label: copy.labels.cursorStyle, currentValue: copy.values.cursorStyles[config.cursorStyle] }],
254
+ },
185
255
  ];
186
256
  }
187
257
 
188
- function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
258
+ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
189
259
  const segs = config.footerSegments;
190
260
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
191
261
  return [
192
- { id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
193
- { id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
194
- { id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
195
- { id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
196
- { id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
197
- { id: "gitCommit", label: copy.labels.gitCommit, currentValue: flag(segs.gitCommit) },
198
- { id: "runtime", label: copy.labels.runtime, currentValue: flag(segs.runtime) },
199
- { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
200
- { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
201
- { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
202
- { id: "liveTps", label: copy.labels.liveTps, currentValue: flag(segs.liveTps) },
203
- { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
204
- { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
205
- { id: "nerdIconSpacing", label: copy.labels.nerdIconSpacing, currentValue: flag(segs.nerdIconSpacing) },
262
+ {
263
+ label: copy.sections.location,
264
+ items: [
265
+ { id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
266
+ { id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
267
+ { id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
268
+ ],
269
+ },
270
+ {
271
+ label: copy.sections.git,
272
+ items: [
273
+ { id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
274
+ { id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
275
+ { id: "gitCommit", label: copy.labels.gitCommit, currentValue: flag(segs.gitCommit) },
276
+ ],
277
+ },
278
+ {
279
+ label: copy.sections.runtime,
280
+ items: [
281
+ { id: "runtime", label: copy.labels.runtime, currentValue: flag(segs.runtime) },
282
+ { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
283
+ { id: "liveTps", label: copy.labels.liveTps, currentValue: flag(segs.liveTps) },
284
+ ],
285
+ },
286
+ {
287
+ label: copy.sections.usage,
288
+ items: [
289
+ { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
290
+ { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
291
+ ],
292
+ },
293
+ {
294
+ label: copy.sections.other,
295
+ items: [
296
+ { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
297
+ { id: "extensionSegments", label: copy.labels.extensionSegments, currentValue: flag(segs.extensionSegments) },
298
+ { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
299
+ { id: "nerdIconSpacing", label: copy.labels.nerdIconSpacing, currentValue: flag(segs.nerdIconSpacing) },
300
+ ],
301
+ },
206
302
  ];
207
303
  }
208
304
 
209
- function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
305
+ function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
210
306
  const telemetry = config.telemetry;
211
307
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
212
308
  return [
213
- { id: "enabled", label: copy.labels.enabled, currentValue: flag(telemetry.enabled) },
214
- { id: "tps", label: "TPS", currentValue: flag(telemetry.tps) },
215
- { id: "ttft", label: "TTFT", currentValue: flag(telemetry.ttft) },
216
- { id: "duration", label: copy.labels.totalDuration, currentValue: flag(telemetry.duration) },
217
- { id: "tokens", label: copy.labels.tokenCounts, currentValue: flag(telemetry.tokens) },
218
- { id: "stalls", label: copy.labels.stallDetails, currentValue: flag(telemetry.stalls) },
219
- { id: "cost", label: copy.labels.costRate, currentValue: flag(telemetry.cost) },
309
+ { items: [{ id: "enabled", label: copy.labels.enabled, currentValue: flag(telemetry.enabled) }] },
310
+ {
311
+ label: copy.sections.measurements,
312
+ items: [
313
+ { id: "tps", label: "TPS", currentValue: flag(telemetry.tps) },
314
+ { id: "ttft", label: "TTFT", currentValue: flag(telemetry.ttft) },
315
+ { id: "duration", label: copy.labels.totalDuration, currentValue: flag(telemetry.duration) },
316
+ { id: "tokens", label: copy.labels.tokenCounts, currentValue: flag(telemetry.tokens) },
317
+ { id: "stalls", label: copy.labels.stallDetails, currentValue: flag(telemetry.stalls) },
318
+ { id: "cost", label: copy.labels.costRate, currentValue: flag(telemetry.cost) },
319
+ ],
320
+ },
220
321
  ];
221
322
  }
222
323
 
223
- function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
324
+ function buildItems(tab: Tab, config: OpenTuiConfig): SettingRow[] {
224
325
  const copy = COPY[config.settingsLanguage];
326
+ let groups: SettingGroup[];
225
327
  switch (tab) {
226
- case "features": return buildFeaturesItems(config, copy);
227
- case "icons": return buildIconsItems(config, copy);
228
- case "segments": return buildSegmentsItems(config, copy);
229
- case "telemetry": return buildTelemetryItems(config, copy);
328
+ case "features": groups = buildFeaturesItems(config, copy); break;
329
+ case "icons": groups = buildIconsItems(config, copy); break;
330
+ case "segments": groups = buildSegmentsItems(config, copy); break;
331
+ case "telemetry": groups = buildTelemetryItems(config, copy); break;
230
332
  }
333
+ return groups.flatMap<SettingRow>((group) => [
334
+ ...(group.label ? [
335
+ { kind: "header" as const, label: group.label },
336
+ ] : []),
337
+ ...group.items.map((item) => ({ kind: "setting" as const, item })),
338
+ ]);
231
339
  }
232
340
 
233
341
  function handleSettingChange(
@@ -243,7 +351,10 @@ function handleSettingChange(
243
351
  if (itemId === "settingsLanguage") return toggleLanguage(config);
244
352
  if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
245
353
  if (itemId === "toolView") {
246
- return { ...config, toolView: { mode: config.toolView.mode === "native" ? "compact" : "native" } };
354
+ return { ...config, toolView: { ...config.toolView, mode: config.toolView.mode === "native" ? "compact" : "native" } };
355
+ }
356
+ if (itemId === "extensionTools") {
357
+ return { ...config, toolView: { ...config.toolView, extensionTools: !config.toolView.extensionTools } };
247
358
  }
248
359
  }
249
360
  if (tab === "icons") {
@@ -259,16 +370,160 @@ function handleSettingChange(
259
370
  return config;
260
371
  }
261
372
 
373
+ // SelectList only receives actual settings. This component inserts section rows
374
+ // into its rendered output and scrolls in display rows, including the headers.
375
+ class SectionedSettingsList {
376
+ readonly capturesViewportInput = true;
377
+ onSelect?: (item: SelectItem) => void;
378
+ onCancel?: () => void;
379
+ onSelectionChange?: (item: SelectItem) => void;
380
+ private readonly selectList: SelectList;
381
+ private readonly items: SelectItem[];
382
+ private readonly rows: SettingRow[];
383
+ private readonly theme: Theme;
384
+ private readonly visibleRows: number;
385
+ private readonly showScrollInfo: boolean;
386
+ private pressedItemId: string | undefined;
387
+
388
+ constructor(
389
+ rows: SettingRow[],
390
+ availableRows: number,
391
+ width: number,
392
+ theme: Theme,
393
+ ) {
394
+ this.rows = rows;
395
+ this.theme = theme;
396
+ const compact = width <= 60;
397
+ this.items = rows.flatMap<SelectItem>((row) => {
398
+ if (row.kind !== "setting") return [];
399
+ const item = row.item;
400
+ return [{
401
+ value: item.id,
402
+ // Reserve Box padding (2), SelectList prefix/safety (4), and ": " (2).
403
+ label: compact
404
+ ? `${truncateToWidth(item.label, Math.max(0, width - 8 - visibleWidth(item.currentValue)))}: ${item.currentValue}`
405
+ : item.label,
406
+ description: compact ? undefined : item.currentValue,
407
+ }];
408
+ });
409
+ // Reserve the scroll indicator when there is room for it and a setting.
410
+ this.showScrollInfo = rows.length > availableRows && availableRows > 1;
411
+ this.visibleRows = Math.max(1, availableRows - (this.showScrollInfo ? 1 : 0));
412
+ this.selectList = new SelectList(this.items, this.items.length, {
413
+ selectedPrefix: (t) => theme.fg("accent", t),
414
+ selectedText: (t) => theme.fg("accent", t),
415
+ description: (t) => theme.fg("muted", t),
416
+ scrollInfo: (t) => theme.fg("dim", t),
417
+ noMatch: (t) => theme.fg("warning", t),
418
+ });
419
+ this.selectList.onSelect = (item) => this.onSelect?.(item);
420
+ this.selectList.onCancel = () => this.onCancel?.();
421
+ this.selectList.onSelectionChange = (item) => this.onSelectionChange?.(item);
422
+ }
423
+
424
+ getSelectedItem(): SelectItem | null {
425
+ return this.selectList.getSelectedItem();
426
+ }
427
+
428
+ selectItem(itemId: string | undefined): void {
429
+ const index = this.items.findIndex((item) => item.value === itemId);
430
+ if (index >= 0) this.selectList.setSelectedIndex(index);
431
+ }
432
+
433
+ private selectedRowIndex(): number {
434
+ const selected = this.getSelectedItem();
435
+ return this.rows.findIndex((row) => row.kind === "setting" && row.item.id === selected?.value);
436
+ }
437
+
438
+ private visibleRange(): { start: number; end: number } {
439
+ const start = Math.max(0, Math.min(
440
+ this.selectedRowIndex() - Math.floor(this.visibleRows / 2),
441
+ this.rows.length - this.visibleRows,
442
+ ));
443
+ return { start, end: Math.min(start + this.visibleRows, this.rows.length) };
444
+ }
445
+
446
+ render(width: number): string[] {
447
+ const settingLines = this.selectList.render(width);
448
+ let itemIndex = 0;
449
+ const lines = this.rows.map((row) => {
450
+ if (row.kind === "setting") return settingLines[itemIndex++]!;
451
+ const label = truncateToWidth(` ──${row.label} `, width, "");
452
+ return this.theme.fg("dim", label + "─".repeat(Math.max(0, width - visibleWidth(label))));
453
+ });
454
+ const { start, end } = this.visibleRange();
455
+ const visible = lines.slice(start, end);
456
+ if (this.showScrollInfo) {
457
+ visible.push(this.theme.fg("dim", truncateToWidth(
458
+ ` (${this.selectedRowIndex() + 1}/${this.rows.length})`, width, "",
459
+ )));
460
+ }
461
+ return visible;
462
+ }
463
+
464
+ handleInput(data: string): void {
465
+ this.pressedItemId = undefined;
466
+ const kb = getKeybindings();
467
+ const pageIndex = getSelectPageIndex(kb, data, this.selectedRowIndex(), this.rows.length, this.visibleRows);
468
+ if (pageIndex === undefined) {
469
+ this.selectList.handleInput(data);
470
+ return;
471
+ }
472
+ const direction = kb.matches(data, "tui.select.pageUp") ? -1 : 1;
473
+ let index = pageIndex;
474
+ while (index >= 0 && index < this.rows.length && this.rows[index]?.kind !== "setting") index += direction;
475
+ if (index < 0 || index >= this.rows.length) {
476
+ index = pageIndex;
477
+ while (index >= 0 && index < this.rows.length && this.rows[index]?.kind !== "setting") index -= direction;
478
+ }
479
+ const row = this.rows[index];
480
+ if (row?.kind === "setting") {
481
+ this.selectItem(row.item.id);
482
+ const selected = this.getSelectedItem();
483
+ if (selected) this.onSelectionChange?.(selected);
484
+ }
485
+ }
486
+
487
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
488
+ if (event.type === "wheel") {
489
+ this.pressedItemId = undefined;
490
+ return this.selectList.handleMouse(event);
491
+ }
492
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
493
+ const { start, end } = this.visibleRange();
494
+ const rowIndex = start + event.y;
495
+ const row = rowIndex >= start && rowIndex < end ? this.rows[rowIndex] : undefined;
496
+ if (row?.kind !== "setting") {
497
+ this.pressedItemId = undefined;
498
+ return { handled: true, render: false };
499
+ }
500
+ const itemId = event.type === "click" ? this.pressedItemId ?? row.item.id : row.item.id;
501
+ this.pressedItemId = event.type === "press" ? itemId : undefined;
502
+ this.selectItem(itemId);
503
+ const selected = this.getSelectedItem();
504
+ if (selected) {
505
+ this.onSelectionChange?.(selected);
506
+ if (event.type === "click") this.onSelect?.(selected);
507
+ }
508
+ return { handled: true, focus: true, render: true };
509
+ }
510
+
511
+ invalidate(): void {
512
+ this.selectList.invalidate();
513
+ }
514
+ }
515
+
262
516
  interface SettingsUiHandle {
263
517
  render: (width: number) => string[];
264
518
  invalidate: () => void;
265
519
  handleInput: (data: string) => void;
520
+ handleMouse: (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
266
521
  }
267
522
 
268
523
  class SettingsUi implements SettingsUiHandle {
269
524
  private tab: Tab = "features";
270
525
  private config: OpenTuiConfig;
271
- private selectList!: SelectList;
526
+ private selectList!: SectionedSettingsList;
272
527
  private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
273
528
  private readonly container: Box;
274
529
  private readonly tui: TUI;
@@ -314,7 +569,6 @@ class SettingsUi implements SettingsUiHandle {
314
569
  ): void {
315
570
  this.layoutWidth = width;
316
571
  this.layoutRows = this.tui.terminal.rows;
317
- const compact = width <= 60;
318
572
  const copy = COPY[this.config.settingsLanguage];
319
573
  this.container.clear();
320
574
  this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
@@ -327,28 +581,9 @@ class SettingsUi implements SettingsUiHandle {
327
581
  this.container.addChild(new Text(tabBar, 1, 0));
328
582
  this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
329
583
 
330
- const items = buildItems(this.tab, this.config).map<SelectItem>((item) => ({
331
- value: item.id,
332
- // Reserve Box padding (2), SelectList prefix/safety (4), and ": " (2).
333
- label: compact
334
- ? `${truncateToWidth(item.label, Math.max(0, width - 8 - visibleWidth(item.currentValue)))}: ${item.currentValue}`
335
- : item.label,
336
- description: compact ? undefined : item.currentValue,
337
- }));
338
584
  const availableRows = this.layoutRows - this.container.render(width).length;
339
- // A scrolling SelectList adds one status row below the items.
340
- const visibleItems = items.length <= availableRows ? items.length : Math.max(1, availableRows - 1);
341
- this.selectList = new SelectList(items, visibleItems, {
342
- selectedPrefix: (t) => this.theme.fg("accent", t),
343
- selectedText: (t) => this.theme.fg("accent", t),
344
- description: (t) => this.theme.fg("muted", t),
345
- scrollInfo: (t) => this.theme.fg("dim", t),
346
- noMatch: (t) => this.theme.fg("warning", t),
347
- });
348
- const selectedIndex = items.findIndex((item) => item.value === preferredItemId);
349
- if (selectedIndex >= 0) {
350
- this.selectList.setSelectedIndex(selectedIndex);
351
- }
585
+ this.selectList = new SectionedSettingsList(buildItems(this.tab, this.config), availableRows, width, this.theme);
586
+ this.selectList.selectItem(preferredItemId);
352
587
  this.selectedItemByTab[this.tab] = this.selectList.getSelectedItem()?.value;
353
588
  this.selectList.onSelectionChange = (item) => {
354
589
  this.selectedItemByTab[this.tab] = item.value;
@@ -388,6 +623,14 @@ class SettingsUi implements SettingsUiHandle {
388
623
  this.invalidate();
389
624
  }
390
625
 
626
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
627
+ const result = this.container.handleMouse(event);
628
+ if (result?.handled && result.render !== false) this.invalidate();
629
+ // Keep keyboard focus on SettingsUi so tab switching and close shortcuts
630
+ // still run here after clicking a setting inside the nested Box.
631
+ return result ? { handled: result.handled, render: result.render } : undefined;
632
+ }
633
+
391
634
  render(width: number): string[] {
392
635
  if (width !== this.layoutWidth || this.tui.terminal.rows !== this.layoutRows) {
393
636
  this.rebuild(undefined, width);
@@ -424,12 +667,18 @@ export function registerSettingsCommand(
424
667
  () => done(undefined),
425
668
  );
426
669
  return {
670
+ capturesViewportInput: true,
427
671
  render: (w: number) => ui.render(w),
428
672
  invalidate: () => ui.invalidate(),
429
673
  handleInput: (data: string) => {
430
674
  ui.handleInput(data);
431
675
  tui.requestRender();
432
676
  },
677
+ handleMouse: (event: TuiMouseEvent) => {
678
+ const result = ui.handleMouse(event);
679
+ if (result?.handled && result.render !== false) tui.requestRender();
680
+ return result;
681
+ },
433
682
  };
434
683
  }, { overlay: true });
435
684
  // Overlay is closed and focus is back on the editor. Deferred UI changes
@@ -6,6 +6,7 @@ import { stripAnsi } from "./utils.ts";
6
6
  const MAX_PREVIEW_LINES = 16;
7
7
  const MAX_HIGHLIGHT_CHARS = 16_384;
8
8
  const MAX_WORD_CHARS = 4_096;
9
+ const MAX_DETERMINISTIC_WORD_CHARS = 512;
9
10
  const SGR = /\x1b\[([\d;]*)m/g;
10
11
 
11
12
  type Span = { start: number; end: number };
@@ -42,8 +43,13 @@ function parseDiff(source: string): PreviewLine[] {
42
43
  }
43
44
 
44
45
  function changedWords(oldLine: PreviewLine, newLine: PreviewLine): void {
45
- if (Math.max(oldLine.text.length, newLine.text.length) > MAX_WORD_CHARS) return;
46
- const changes = diffWordsWithSpace(oldLine.text, newLine.text, { timeout: 5, maxEditLength: 256 });
46
+ const chars = Math.max(oldLine.text.length, newLine.text.length);
47
+ if (chars > MAX_WORD_CHARS) return;
48
+ // Short lines have bounded input and edit distance, so scheduling cannot abort their diff.
49
+ const options = chars <= MAX_DETERMINISTIC_WORD_CHARS
50
+ ? { maxEditLength: 256 }
51
+ : { timeout: 5, maxEditLength: 256 };
52
+ const changes = diffWordsWithSpace(oldLine.text, newLine.text, options);
47
53
  if (!changes) return;
48
54
  const shared = changes.filter((part) => !part.added && !part.removed)
49
55
  .reduce((count, part) => count + part.value.replace(/\s/g, "").length, 0);