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.
- package/CHANGELOG.md +35 -0
- package/README.md +52 -120
- package/README.zh-CN.md +52 -120
- package/docs/configuration.md +248 -0
- package/docs/footer-segments.md +121 -0
- package/docs/implementation.md +185 -0
- package/extensions/open-tui/config.ts +28 -7
- package/extensions/open-tui/copy-buttons.ts +144 -63
- package/extensions/open-tui/footer-segments.ts +119 -0
- package/extensions/open-tui/footer.ts +129 -18
- package/extensions/open-tui/index.ts +8 -1
- package/extensions/open-tui/settings-command.ts +315 -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",
|
|
@@ -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):
|
|
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
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
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):
|
|
247
|
+
function buildIconsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingGroup[] {
|
|
182
248
|
return [
|
|
183
|
-
{
|
|
184
|
-
|
|
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):
|
|
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
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
{
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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):
|
|
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
|
-
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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):
|
|
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":
|
|
227
|
-
case "icons":
|
|
228
|
-
case "segments":
|
|
229
|
-
case "telemetry":
|
|
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!:
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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);
|