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.
- package/CHANGELOG.md +25 -0
- package/README.md +52 -120
- package/README.zh-CN.md +52 -120
- package/docs/configuration.md +245 -0
- package/docs/footer-segments.md +121 -0
- package/docs/implementation.md +185 -0
- package/extensions/open-tui/config.ts +25 -7
- package/extensions/open-tui/copy-buttons.ts +141 -63
- package/extensions/open-tui/footer-segments.ts +119 -0
- package/extensions/open-tui/footer.ts +121 -13
- package/extensions/open-tui/index.ts +8 -1
- package/extensions/open-tui/settings-command.ts +312 -63
- package/extensions/open-tui/tool-preview.ts +8 -2
- package/extensions/open-tui/tool-view.ts +184 -31
- package/extensions/open-tui/utils.ts +18 -6
- package/package.json +9 -8
|
@@ -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):
|
|
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
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
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):
|
|
245
|
+
function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
|
|
182
246
|
return [
|
|
183
|
-
{
|
|
184
|
-
|
|
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):
|
|
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
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
{
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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):
|
|
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
|
-
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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):
|
|
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":
|
|
227
|
-
case "icons":
|
|
228
|
-
case "segments":
|
|
229
|
-
case "telemetry":
|
|
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!:
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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);
|