pi-open-tui 0.4.2 → 0.4.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.
@@ -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",
@@ -45,9 +73,11 @@ const COPY = {
45
73
  gitCommit: "Git tag (detached HEAD)",
46
74
  runtime: "Project runtime",
47
75
  context: "Context usage",
76
+ contextBar: "Context bar",
48
77
  tokens: "Session tokens",
49
78
  cost: "Session cost",
50
79
  extensionStatuses: "Extension status line",
80
+ extensionSegments: "Extension footer segments",
51
81
  capitalizeProviderName: "Capitalize provider name",
52
82
  nerdIconSpacing: "Extra space after Nerd icons",
53
83
  liveTps: "Live TPS",
@@ -69,6 +99,20 @@ const COPY = {
69
99
  zh: {
70
100
  title: "Open TUI 设置",
71
101
  tabs: { features: "常规", icons: "外观", segments: "底栏", telemetry: "遥测" },
102
+ sections: {
103
+ extension: "扩展",
104
+ interface: "界面",
105
+ messages: "消息与工具",
106
+ footer: "底栏",
107
+ icons: "图标",
108
+ editor: "输入框",
109
+ location: "位置",
110
+ git: "Git",
111
+ runtime: "运行环境",
112
+ usage: "用量",
113
+ other: "其他",
114
+ measurements: "测量指标",
115
+ },
72
116
  hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
73
117
  labels: {
74
118
  enabled: "启用",
@@ -77,6 +121,7 @@ const COPY = {
77
121
  copyButtons: "复制按钮",
78
122
  thinkingPeek: "思考预览",
79
123
  toolView: "工具视图",
124
+ extensionTools: "紧凑显示扩展工具",
80
125
  language: "语言",
81
126
  cursorStyle: "光标样式",
82
127
  iconMode: "图标模式",
@@ -88,9 +133,11 @@ const COPY = {
88
133
  gitCommit: "Git 标签(分离 HEAD)",
89
134
  runtime: "项目运行环境",
90
135
  context: "上下文用量",
136
+ contextBar: "上下文进度条",
91
137
  tokens: "会话 Token",
92
138
  cost: "会话费用",
93
139
  extensionStatuses: "扩展状态行",
140
+ extensionSegments: "扩展底栏片段",
94
141
  capitalizeProviderName: "提供商名称首字母大写",
95
142
  nerdIconSpacing: "Nerd 图标后额外留空",
96
143
  liveTps: "实时 TPS",
@@ -165,69 +212,133 @@ function cycleThinkingPeek(config: OpenTuiConfig): OpenTuiConfig {
165
212
  };
166
213
  }
167
214
 
168
- function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
215
+ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
169
216
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
170
217
  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] },
218
+ {
219
+ label: copy.sections.extension,
220
+ items: [{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) }],
221
+ },
222
+ {
223
+ label: copy.sections.interface,
224
+ items: [
225
+ { id: "header", label: copy.labels.header, currentValue: flag(config.header) },
226
+ { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
227
+ ],
228
+ },
229
+ {
230
+ label: copy.sections.messages,
231
+ items: [
232
+ { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
233
+ { id: "copyButtons", label: copy.labels.copyButtons, currentValue: flag(config.copyButtons) },
234
+ { id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
235
+ ...(config.toolView.mode === "compact" ? [
236
+ { id: "extensionTools", label: ` ${copy.labels.extensionTools}`, currentValue: flag(config.toolView.extensionTools) },
237
+ ] : []),
238
+ ],
239
+ },
240
+ {
241
+ label: copy.sections.footer,
242
+ items: [{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) }],
243
+ },
178
244
  ];
179
245
  }
180
246
 
181
- function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
247
+ function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
182
248
  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] },
249
+ {
250
+ label: copy.sections.icons,
251
+ items: [{ id: "mode", label: copy.labels.iconMode, currentValue: copy.values.icons[config.icons.mode] }],
252
+ },
253
+ {
254
+ label: copy.sections.editor,
255
+ items: [{ id: "cursorStyle", label: copy.labels.cursorStyle, currentValue: copy.values.cursorStyles[config.cursorStyle] }],
256
+ },
185
257
  ];
186
258
  }
187
259
 
188
- function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
260
+ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
189
261
  const segs = config.footerSegments;
190
262
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
191
263
  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) },
264
+ {
265
+ label: copy.sections.location,
266
+ items: [
267
+ { id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
268
+ { id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
269
+ { id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
270
+ ],
271
+ },
272
+ {
273
+ label: copy.sections.git,
274
+ items: [
275
+ { id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
276
+ { id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
277
+ { id: "gitCommit", label: copy.labels.gitCommit, currentValue: flag(segs.gitCommit) },
278
+ ],
279
+ },
280
+ {
281
+ label: copy.sections.runtime,
282
+ items: [
283
+ { id: "runtime", label: copy.labels.runtime, currentValue: flag(segs.runtime) },
284
+ { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
285
+ { id: "contextBar", label: copy.labels.contextBar, currentValue: flag(segs.contextBar) },
286
+ { id: "liveTps", label: copy.labels.liveTps, currentValue: flag(segs.liveTps) },
287
+ ],
288
+ },
289
+ {
290
+ label: copy.sections.usage,
291
+ items: [
292
+ { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
293
+ { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
294
+ ],
295
+ },
296
+ {
297
+ label: copy.sections.other,
298
+ items: [
299
+ { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
300
+ { id: "extensionSegments", label: copy.labels.extensionSegments, currentValue: flag(segs.extensionSegments) },
301
+ { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
302
+ { id: "nerdIconSpacing", label: copy.labels.nerdIconSpacing, currentValue: flag(segs.nerdIconSpacing) },
303
+ ],
304
+ },
206
305
  ];
207
306
  }
208
307
 
209
- function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
308
+ function buildTelemetryItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
210
309
  const telemetry = config.telemetry;
211
310
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
212
311
  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) },
312
+ { items: [{ id: "enabled", label: copy.labels.enabled, currentValue: flag(telemetry.enabled) }] },
313
+ {
314
+ label: copy.sections.measurements,
315
+ items: [
316
+ { id: "tps", label: "TPS", currentValue: flag(telemetry.tps) },
317
+ { id: "ttft", label: "TTFT", currentValue: flag(telemetry.ttft) },
318
+ { id: "duration", label: copy.labels.totalDuration, currentValue: flag(telemetry.duration) },
319
+ { id: "tokens", label: copy.labels.tokenCounts, currentValue: flag(telemetry.tokens) },
320
+ { id: "stalls", label: copy.labels.stallDetails, currentValue: flag(telemetry.stalls) },
321
+ { id: "cost", label: copy.labels.costRate, currentValue: flag(telemetry.cost) },
322
+ ],
323
+ },
220
324
  ];
221
325
  }
222
326
 
223
- function buildItems(tab: Tab, config: OpenTuiConfig): SettingItem[] {
327
+ function buildItems(tab: Tab, config: OpenTuiConfig): SettingRow[] {
224
328
  const copy = COPY[config.settingsLanguage];
329
+ let groups: SettingGroup[];
225
330
  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);
331
+ case "features": groups = buildFeaturesItems(config, copy); break;
332
+ case "icons": groups = buildIconsItems(config, copy); break;
333
+ case "segments": groups = buildSegmentsItems(config, copy); break;
334
+ case "telemetry": groups = buildTelemetryItems(config, copy); break;
230
335
  }
336
+ return groups.flatMap<SettingRow>((group) => [
337
+ ...(group.label ? [
338
+ { kind: "header" as const, label: group.label },
339
+ ] : []),
340
+ ...group.items.map((item) => ({ kind: "setting" as const, item })),
341
+ ]);
231
342
  }
232
343
 
233
344
  function handleSettingChange(
@@ -243,7 +354,10 @@ function handleSettingChange(
243
354
  if (itemId === "settingsLanguage") return toggleLanguage(config);
244
355
  if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
245
356
  if (itemId === "toolView") {
246
- return { ...config, toolView: { mode: config.toolView.mode === "native" ? "compact" : "native" } };
357
+ return { ...config, toolView: { ...config.toolView, mode: config.toolView.mode === "native" ? "compact" : "native" } };
358
+ }
359
+ if (itemId === "extensionTools") {
360
+ return { ...config, toolView: { ...config.toolView, extensionTools: !config.toolView.extensionTools } };
247
361
  }
248
362
  }
249
363
  if (tab === "icons") {
@@ -259,16 +373,160 @@ function handleSettingChange(
259
373
  return config;
260
374
  }
261
375
 
376
+ // SelectList only receives actual settings. This component inserts section rows
377
+ // into its rendered output and scrolls in display rows, including the headers.
378
+ class SectionedSettingsList {
379
+ readonly capturesViewportInput = true;
380
+ onSelect?: (item: SelectItem) => void;
381
+ onCancel?: () => void;
382
+ onSelectionChange?: (item: SelectItem) => void;
383
+ private readonly selectList: SelectList;
384
+ private readonly items: SelectItem[];
385
+ private readonly rows: SettingRow[];
386
+ private readonly theme: Theme;
387
+ private readonly visibleRows: number;
388
+ private readonly showScrollInfo: boolean;
389
+ private pressedItemId: string | undefined;
390
+
391
+ constructor(
392
+ rows: SettingRow[],
393
+ availableRows: number,
394
+ width: number,
395
+ theme: Theme,
396
+ ) {
397
+ this.rows = rows;
398
+ this.theme = theme;
399
+ const compact = width <= 60;
400
+ this.items = rows.flatMap<SelectItem>((row) => {
401
+ if (row.kind !== "setting") return [];
402
+ const item = row.item;
403
+ return [{
404
+ value: item.id,
405
+ // Reserve Box padding (2), SelectList prefix/safety (4), and ": " (2).
406
+ label: compact
407
+ ? `${truncateToWidth(item.label, Math.max(0, width - 8 - visibleWidth(item.currentValue)))}: ${item.currentValue}`
408
+ : item.label,
409
+ description: compact ? undefined : item.currentValue,
410
+ }];
411
+ });
412
+ // Reserve the scroll indicator when there is room for it and a setting.
413
+ this.showScrollInfo = rows.length > availableRows && availableRows > 1;
414
+ this.visibleRows = Math.max(1, availableRows - (this.showScrollInfo ? 1 : 0));
415
+ this.selectList = new SelectList(this.items, this.items.length, {
416
+ selectedPrefix: (t) => theme.fg("accent", t),
417
+ selectedText: (t) => theme.fg("accent", t),
418
+ description: (t) => theme.fg("muted", t),
419
+ scrollInfo: (t) => theme.fg("dim", t),
420
+ noMatch: (t) => theme.fg("warning", t),
421
+ });
422
+ this.selectList.onSelect = (item) => this.onSelect?.(item);
423
+ this.selectList.onCancel = () => this.onCancel?.();
424
+ this.selectList.onSelectionChange = (item) => this.onSelectionChange?.(item);
425
+ }
426
+
427
+ getSelectedItem(): SelectItem | null {
428
+ return this.selectList.getSelectedItem();
429
+ }
430
+
431
+ selectItem(itemId: string | undefined): void {
432
+ const index = this.items.findIndex((item) => item.value === itemId);
433
+ if (index >= 0) this.selectList.setSelectedIndex(index);
434
+ }
435
+
436
+ private selectedRowIndex(): number {
437
+ const selected = this.getSelectedItem();
438
+ return this.rows.findIndex((row) => row.kind === "setting" && row.item.id === selected?.value);
439
+ }
440
+
441
+ private visibleRange(): { start: number; end: number } {
442
+ const start = Math.max(0, Math.min(
443
+ this.selectedRowIndex() - Math.floor(this.visibleRows / 2),
444
+ this.rows.length - this.visibleRows,
445
+ ));
446
+ return { start, end: Math.min(start + this.visibleRows, this.rows.length) };
447
+ }
448
+
449
+ render(width: number): string[] {
450
+ const settingLines = this.selectList.render(width);
451
+ let itemIndex = 0;
452
+ const lines = this.rows.map((row) => {
453
+ if (row.kind === "setting") return settingLines[itemIndex++]!;
454
+ const label = truncateToWidth(` ──${row.label} `, width, "");
455
+ return this.theme.fg("dim", label + "─".repeat(Math.max(0, width - visibleWidth(label))));
456
+ });
457
+ const { start, end } = this.visibleRange();
458
+ const visible = lines.slice(start, end);
459
+ if (this.showScrollInfo) {
460
+ visible.push(this.theme.fg("dim", truncateToWidth(
461
+ ` (${this.selectedRowIndex() + 1}/${this.rows.length})`, width, "",
462
+ )));
463
+ }
464
+ return visible;
465
+ }
466
+
467
+ handleInput(data: string): void {
468
+ this.pressedItemId = undefined;
469
+ const kb = getKeybindings();
470
+ const pageIndex = getSelectPageIndex(kb, data, this.selectedRowIndex(), this.rows.length, this.visibleRows);
471
+ if (pageIndex === undefined) {
472
+ this.selectList.handleInput(data);
473
+ return;
474
+ }
475
+ const direction = kb.matches(data, "tui.select.pageUp") ? -1 : 1;
476
+ let index = pageIndex;
477
+ while (index >= 0 && index < this.rows.length && this.rows[index]?.kind !== "setting") index += direction;
478
+ if (index < 0 || index >= this.rows.length) {
479
+ index = pageIndex;
480
+ while (index >= 0 && index < this.rows.length && this.rows[index]?.kind !== "setting") index -= direction;
481
+ }
482
+ const row = this.rows[index];
483
+ if (row?.kind === "setting") {
484
+ this.selectItem(row.item.id);
485
+ const selected = this.getSelectedItem();
486
+ if (selected) this.onSelectionChange?.(selected);
487
+ }
488
+ }
489
+
490
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
491
+ if (event.type === "wheel") {
492
+ this.pressedItemId = undefined;
493
+ return this.selectList.handleMouse(event);
494
+ }
495
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
496
+ const { start, end } = this.visibleRange();
497
+ const rowIndex = start + event.y;
498
+ const row = rowIndex >= start && rowIndex < end ? this.rows[rowIndex] : undefined;
499
+ if (row?.kind !== "setting") {
500
+ this.pressedItemId = undefined;
501
+ return { handled: true, render: false };
502
+ }
503
+ const itemId = event.type === "click" ? this.pressedItemId ?? row.item.id : row.item.id;
504
+ this.pressedItemId = event.type === "press" ? itemId : undefined;
505
+ this.selectItem(itemId);
506
+ const selected = this.getSelectedItem();
507
+ if (selected) {
508
+ this.onSelectionChange?.(selected);
509
+ if (event.type === "click") this.onSelect?.(selected);
510
+ }
511
+ return { handled: true, focus: true, render: true };
512
+ }
513
+
514
+ invalidate(): void {
515
+ this.selectList.invalidate();
516
+ }
517
+ }
518
+
262
519
  interface SettingsUiHandle {
263
520
  render: (width: number) => string[];
264
521
  invalidate: () => void;
265
522
  handleInput: (data: string) => void;
523
+ handleMouse: (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
266
524
  }
267
525
 
268
526
  class SettingsUi implements SettingsUiHandle {
269
527
  private tab: Tab = "features";
270
528
  private config: OpenTuiConfig;
271
- private selectList!: SelectList;
529
+ private selectList!: SectionedSettingsList;
272
530
  private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
273
531
  private readonly container: Box;
274
532
  private readonly tui: TUI;
@@ -314,7 +572,6 @@ class SettingsUi implements SettingsUiHandle {
314
572
  ): void {
315
573
  this.layoutWidth = width;
316
574
  this.layoutRows = this.tui.terminal.rows;
317
- const compact = width <= 60;
318
575
  const copy = COPY[this.config.settingsLanguage];
319
576
  this.container.clear();
320
577
  this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
@@ -327,28 +584,9 @@ class SettingsUi implements SettingsUiHandle {
327
584
  this.container.addChild(new Text(tabBar, 1, 0));
328
585
  this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
329
586
 
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
587
  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
- }
588
+ this.selectList = new SectionedSettingsList(buildItems(this.tab, this.config), availableRows, width, this.theme);
589
+ this.selectList.selectItem(preferredItemId);
352
590
  this.selectedItemByTab[this.tab] = this.selectList.getSelectedItem()?.value;
353
591
  this.selectList.onSelectionChange = (item) => {
354
592
  this.selectedItemByTab[this.tab] = item.value;
@@ -388,6 +626,14 @@ class SettingsUi implements SettingsUiHandle {
388
626
  this.invalidate();
389
627
  }
390
628
 
629
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
630
+ const result = this.container.handleMouse(event);
631
+ if (result?.handled && result.render !== false) this.invalidate();
632
+ // Keep keyboard focus on SettingsUi so tab switching and close shortcuts
633
+ // still run here after clicking a setting inside the nested Box.
634
+ return result ? { handled: result.handled, render: result.render } : undefined;
635
+ }
636
+
391
637
  render(width: number): string[] {
392
638
  if (width !== this.layoutWidth || this.tui.terminal.rows !== this.layoutRows) {
393
639
  this.rebuild(undefined, width);
@@ -424,12 +670,18 @@ export function registerSettingsCommand(
424
670
  () => done(undefined),
425
671
  );
426
672
  return {
673
+ capturesViewportInput: true,
427
674
  render: (w: number) => ui.render(w),
428
675
  invalidate: () => ui.invalidate(),
429
676
  handleInput: (data: string) => {
430
677
  ui.handleInput(data);
431
678
  tui.requestRender();
432
679
  },
680
+ handleMouse: (event: TuiMouseEvent) => {
681
+ const result = ui.handleMouse(event);
682
+ if (result?.handled && result.render !== false) tui.requestRender();
683
+ return result;
684
+ },
433
685
  };
434
686
  }, { overlay: true });
435
687
  // 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);