pi-open-tui 0.4.0 → 0.4.2
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 +29 -0
- package/README.md +238 -197
- package/README.zh-CN.md +238 -197
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +9 -2
- package/extensions/open-tui/copy-buttons.ts +391 -0
- package/extensions/open-tui/editor.ts +311 -119
- package/extensions/open-tui/footer.ts +13 -11
- package/extensions/open-tui/git.ts +10 -32
- package/extensions/open-tui/header.ts +133 -244
- package/extensions/open-tui/index.ts +34 -33
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +54 -45
- package/extensions/open-tui/state.ts +11 -14
- package/extensions/open-tui/tool-preview.ts +167 -0
- package/extensions/open-tui/tool-view.ts +121 -36
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +8 -11
|
@@ -1,43 +1,23 @@
|
|
|
1
|
-
export class SessionLifecycle {
|
|
2
|
-
private current = 0;
|
|
3
|
-
private
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
this.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return this.current;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
defer(fn: () => void): void {
|
|
28
|
-
if (this.shutDown) return;
|
|
29
|
-
this.deferred.push(fn);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private flushDeferred(): void {
|
|
33
|
-
const pending = this.deferred;
|
|
34
|
-
this.deferred = [];
|
|
35
|
-
for (const fn of pending) {
|
|
36
|
-
try {
|
|
37
|
-
fn();
|
|
38
|
-
} catch {
|
|
39
|
-
// ponytail: silent fallback — deferred tasks are best-effort
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
export class SessionLifecycle {
|
|
2
|
+
private current = 0;
|
|
3
|
+
private shutDown = false;
|
|
4
|
+
|
|
5
|
+
start(): void {
|
|
6
|
+
this.current++;
|
|
7
|
+
this.shutDown = false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
shutdown(): void {
|
|
11
|
+
this.shutDown = true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
isCurrent(generation?: number): boolean {
|
|
15
|
+
if (this.shutDown) return false;
|
|
16
|
+
if (generation === undefined) return true;
|
|
17
|
+
return generation === this.current;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
currentGeneration(): number {
|
|
21
|
+
return this.current;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
Key,
|
|
5
5
|
matchesKey,
|
|
6
6
|
SelectList,
|
|
7
|
-
type Component,
|
|
8
7
|
type SelectItem,
|
|
9
8
|
type TUI,
|
|
10
9
|
Text,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
11
12
|
} from "@earendil-works/pi-tui";
|
|
12
13
|
import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage, ThinkingPeekLines } from "./config.ts";
|
|
13
14
|
|
|
@@ -30,6 +31,7 @@ const COPY = {
|
|
|
30
31
|
enabled: "Enabled",
|
|
31
32
|
header: "Override header",
|
|
32
33
|
inlineFooter: "Footer in editor border",
|
|
34
|
+
copyButtons: "Copy buttons",
|
|
33
35
|
thinkingPeek: "Thinking peek",
|
|
34
36
|
toolView: "Tool view",
|
|
35
37
|
language: "Language",
|
|
@@ -72,6 +74,7 @@ const COPY = {
|
|
|
72
74
|
enabled: "启用",
|
|
73
75
|
header: "覆盖顶部信息栏",
|
|
74
76
|
inlineFooter: "底栏嵌入输入框",
|
|
77
|
+
copyButtons: "复制按钮",
|
|
75
78
|
thinkingPeek: "思考预览",
|
|
76
79
|
toolView: "工具视图",
|
|
77
80
|
language: "语言",
|
|
@@ -170,6 +173,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
170
173
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
171
174
|
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
172
175
|
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
176
|
+
{ id: "copyButtons", label: copy.labels.copyButtons, currentValue: flag(config.copyButtons) },
|
|
173
177
|
{ id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
|
|
174
178
|
];
|
|
175
179
|
}
|
|
@@ -235,6 +239,7 @@ function handleSettingChange(
|
|
|
235
239
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
236
240
|
if (itemId === "header") return { ...config, header: !config.header };
|
|
237
241
|
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
242
|
+
if (itemId === "copyButtons") return { ...config, copyButtons: !config.copyButtons };
|
|
238
243
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
239
244
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
240
245
|
if (itemId === "toolView") {
|
|
@@ -263,34 +268,30 @@ interface SettingsUiHandle {
|
|
|
263
268
|
class SettingsUi implements SettingsUiHandle {
|
|
264
269
|
private tab: Tab = "features";
|
|
265
270
|
private config: OpenTuiConfig;
|
|
266
|
-
private selectList
|
|
271
|
+
private selectList!: SelectList;
|
|
267
272
|
private readonly selectedItemByTab: Partial<Record<Tab, string>> = {};
|
|
268
273
|
private readonly container: Box;
|
|
274
|
+
private readonly tui: TUI;
|
|
269
275
|
private readonly theme: Theme;
|
|
270
276
|
private readonly onChange: (config: OpenTuiConfig) => void;
|
|
271
277
|
private readonly onClose: () => void;
|
|
272
|
-
private
|
|
278
|
+
private layoutWidth: number | undefined;
|
|
279
|
+
private layoutRows: number | undefined;
|
|
273
280
|
private cachedLines: string[] | undefined;
|
|
274
|
-
private compact = false;
|
|
275
281
|
|
|
276
282
|
constructor(
|
|
283
|
+
tui: TUI,
|
|
277
284
|
theme: Theme,
|
|
278
285
|
config: OpenTuiConfig,
|
|
279
286
|
onChange: (config: OpenTuiConfig) => void,
|
|
280
287
|
onClose: () => void,
|
|
281
288
|
) {
|
|
289
|
+
this.tui = tui;
|
|
282
290
|
this.theme = theme;
|
|
283
291
|
this.config = config;
|
|
284
292
|
this.onChange = onChange;
|
|
285
293
|
this.onClose = onClose;
|
|
286
294
|
this.container = new Box(1, 1, (s: string) => theme.bg("customMessageBg", s));
|
|
287
|
-
this.selectList = new SelectList([], 12, {
|
|
288
|
-
selectedPrefix: (t) => theme.fg("accent", t),
|
|
289
|
-
selectedText: (t) => theme.fg("accent", t),
|
|
290
|
-
description: (t) => theme.fg("muted", t),
|
|
291
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
292
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
293
|
-
});
|
|
294
295
|
this.rebuild();
|
|
295
296
|
}
|
|
296
297
|
|
|
@@ -307,7 +308,13 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
307
308
|
this.rebuild();
|
|
308
309
|
}
|
|
309
310
|
|
|
310
|
-
private rebuild(
|
|
311
|
+
private rebuild(
|
|
312
|
+
preferredItemId = this.selectedItemByTab[this.tab],
|
|
313
|
+
width = this.layoutWidth ?? Math.min(80, this.tui.terminal.columns),
|
|
314
|
+
): void {
|
|
315
|
+
this.layoutWidth = width;
|
|
316
|
+
this.layoutRows = this.tui.terminal.rows;
|
|
317
|
+
const compact = width <= 60;
|
|
311
318
|
const copy = COPY[this.config.settingsLanguage];
|
|
312
319
|
this.container.clear();
|
|
313
320
|
this.container.addChild(new Text(this.theme.bold(this.theme.fg("accent", copy.title)), 1, 0));
|
|
@@ -320,12 +327,18 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
320
327
|
this.container.addChild(new Text(tabBar, 1, 0));
|
|
321
328
|
this.container.addChild(new Text(this.theme.fg("dim", copy.hint), 1, 0));
|
|
322
329
|
|
|
323
|
-
const items = buildItems(this.tab, this.config).map((item) => ({
|
|
330
|
+
const items = buildItems(this.tab, this.config).map<SelectItem>((item) => ({
|
|
324
331
|
value: item.id,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
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, {
|
|
329
342
|
selectedPrefix: (t) => this.theme.fg("accent", t),
|
|
330
343
|
selectedText: (t) => this.theme.fg("accent", t),
|
|
331
344
|
description: (t) => this.theme.fg("muted", t),
|
|
@@ -348,7 +361,6 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
348
361
|
};
|
|
349
362
|
this.container.addChild(this.selectList);
|
|
350
363
|
|
|
351
|
-
this.cachedWidth = undefined;
|
|
352
364
|
this.cachedLines = undefined;
|
|
353
365
|
}
|
|
354
366
|
|
|
@@ -377,19 +389,15 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
377
389
|
}
|
|
378
390
|
|
|
379
391
|
render(width: number): string[] {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
this.compact = compact;
|
|
383
|
-
this.rebuild();
|
|
392
|
+
if (width !== this.layoutWidth || this.tui.terminal.rows !== this.layoutRows) {
|
|
393
|
+
this.rebuild(undefined, width);
|
|
384
394
|
}
|
|
385
|
-
if (this.cachedLines
|
|
386
|
-
this.cachedWidth = width;
|
|
395
|
+
if (this.cachedLines) return this.cachedLines;
|
|
387
396
|
this.cachedLines = this.container.render(width);
|
|
388
397
|
return this.cachedLines;
|
|
389
398
|
}
|
|
390
399
|
|
|
391
400
|
invalidate(): void {
|
|
392
|
-
this.cachedWidth = undefined;
|
|
393
401
|
this.cachedLines = undefined;
|
|
394
402
|
this.container.invalidate();
|
|
395
403
|
}
|
|
@@ -407,26 +415,27 @@ export function registerSettingsCommand(
|
|
|
407
415
|
description: "Open the open-tui settings UI",
|
|
408
416
|
handler: async (_args, ctx: ExtensionContext) => {
|
|
409
417
|
if (!ctx.hasUI) return;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
418
|
+
await ctx.ui.custom<void>((tui: TUI, theme, _kb, done) => {
|
|
419
|
+
const ui = new SettingsUi(
|
|
420
|
+
tui,
|
|
421
|
+
theme,
|
|
422
|
+
hooks.getConfig(),
|
|
423
|
+
(config) => hooks.onConfigChanged(config),
|
|
424
|
+
() => done(undefined),
|
|
425
|
+
);
|
|
426
|
+
return {
|
|
427
|
+
render: (w: number) => ui.render(w),
|
|
428
|
+
invalidate: () => ui.invalidate(),
|
|
429
|
+
handleInput: (data: string) => {
|
|
430
|
+
ui.handleInput(data);
|
|
431
|
+
tui.requestRender();
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}, { overlay: true });
|
|
435
|
+
// Overlay is closed and focus is back on the editor. Deferred UI changes
|
|
436
|
+
// (e.g. toggling the extension) run here, so pi core's focus restore
|
|
437
|
+
// cannot strand the overlay without keyboard input.
|
|
438
|
+
hooks.onOverlayClosed?.();
|
|
430
439
|
},
|
|
431
440
|
});
|
|
432
441
|
}
|
|
@@ -8,7 +8,6 @@ import { finiteOrZero, formatProviderLabel } from "./utils.ts";
|
|
|
8
8
|
export interface FooterState {
|
|
9
9
|
git: GitStatus;
|
|
10
10
|
runtime: RuntimeInfo | null;
|
|
11
|
-
sessionStartEpoch: number;
|
|
12
11
|
workingSince: number | undefined;
|
|
13
12
|
lastDoneIn: number | undefined;
|
|
14
13
|
}
|
|
@@ -22,23 +21,22 @@ export interface UsageTotals {
|
|
|
22
21
|
latestCacheHitRate: number | undefined;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
let usageCache: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return `${entries.length}:${last?.id ?? ""}:${last?.timestamp ?? ""}`;
|
|
31
|
-
}
|
|
24
|
+
let usageCache: {
|
|
25
|
+
sessionManager: ExtensionContext["sessionManager"];
|
|
26
|
+
leafId: string | null;
|
|
27
|
+
totals: UsageTotals;
|
|
28
|
+
} | undefined;
|
|
32
29
|
|
|
33
30
|
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
34
|
-
const
|
|
35
|
-
|
|
31
|
+
const { sessionManager } = ctx;
|
|
32
|
+
const leafId = sessionManager.getLeafId();
|
|
33
|
+
if (usageCache?.sessionManager === sessionManager && usageCache.leafId === leafId) return usageCache.totals;
|
|
36
34
|
|
|
37
35
|
const totals: UsageTotals = {
|
|
38
36
|
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0,
|
|
39
37
|
latestCacheHitRate: undefined,
|
|
40
38
|
};
|
|
41
|
-
for (const entry of
|
|
39
|
+
for (const entry of sessionManager.getEntries()) {
|
|
42
40
|
let u: Usage | undefined;
|
|
43
41
|
let updateCacheHitRate = false;
|
|
44
42
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
@@ -46,7 +44,7 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
46
44
|
updateCacheHitRate = true;
|
|
47
45
|
} else if (entry.type === "message" && entry.message.role === "toolResult") {
|
|
48
46
|
u = entry.message.usage;
|
|
49
|
-
} else if (entry.type === "branch_summary" || entry.type === "compaction") {
|
|
47
|
+
} else if (entry.type === "usage" || entry.type === "branch_summary" || entry.type === "compaction") {
|
|
50
48
|
u = entry.usage;
|
|
51
49
|
}
|
|
52
50
|
if (!u) continue;
|
|
@@ -67,7 +65,7 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
67
65
|
: undefined;
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
|
-
usageCache = {
|
|
68
|
+
usageCache = { sessionManager, leafId, totals };
|
|
71
69
|
return totals;
|
|
72
70
|
}
|
|
73
71
|
|
|
@@ -79,7 +77,6 @@ export function createInitialState(): FooterState {
|
|
|
79
77
|
return {
|
|
80
78
|
git: emptyGitStatus(),
|
|
81
79
|
runtime: null,
|
|
82
|
-
sessionStartEpoch: Date.now(),
|
|
83
80
|
workingSince: undefined,
|
|
84
81
|
lastDoneIn: undefined,
|
|
85
82
|
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { backgroundAnsi, rgbColor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { diffWordsWithSpace } from "diff";
|
|
4
|
+
import { stripAnsi } from "./utils.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_PREVIEW_LINES = 16;
|
|
7
|
+
const MAX_HIGHLIGHT_CHARS = 16_384;
|
|
8
|
+
const MAX_WORD_CHARS = 4_096;
|
|
9
|
+
const SGR = /\x1b\[([\d;]*)m/g;
|
|
10
|
+
|
|
11
|
+
type Span = { start: number; end: number };
|
|
12
|
+
type PreviewLine = {
|
|
13
|
+
kind: " " | "+" | "-" | "gap";
|
|
14
|
+
number: string;
|
|
15
|
+
text: string;
|
|
16
|
+
highlighted?: string;
|
|
17
|
+
spans?: Span[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Code keeps its indentation and blank lines; raw terminal controls never reach highlighting. */
|
|
21
|
+
export function codeLines(source: string): string[] {
|
|
22
|
+
if (!source) return [];
|
|
23
|
+
const clean = stripAnsi(source).replace(/\r\n?/g, "\n")
|
|
24
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFF9-\uFFFB]/g, "")
|
|
25
|
+
.replace(/\t/g, " ");
|
|
26
|
+
return clean.replace(/\n$/, "").split("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseDiff(source: string): PreviewLine[] {
|
|
30
|
+
const rows: PreviewLine[] = [];
|
|
31
|
+
for (const line of codeLines(source)) {
|
|
32
|
+
if (/^ +\.\.\.$/.test(line)) {
|
|
33
|
+
rows.push({ kind: "gap", number: "", text: "…" });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const match = /^([ +\-]) *(\d+) (.*)$/.exec(line);
|
|
37
|
+
// A malformed result gets no guessed preview.
|
|
38
|
+
if (!match) return [];
|
|
39
|
+
rows.push({ kind: match[1] as PreviewLine["kind"], number: match[2]!, text: match[3]! });
|
|
40
|
+
}
|
|
41
|
+
return rows.some((line) => line.kind === "+" || line.kind === "-") ? rows : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
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 });
|
|
47
|
+
if (!changes) return;
|
|
48
|
+
const shared = changes.filter((part) => !part.added && !part.removed)
|
|
49
|
+
.reduce((count, part) => count + part.value.replace(/\s/g, "").length, 0);
|
|
50
|
+
if (shared < 0.3 * Math.max(oldLine.text.trim().length, newLine.text.trim().length) || shared === 0) return;
|
|
51
|
+
let oldOffset = 0;
|
|
52
|
+
let newOffset = 0;
|
|
53
|
+
oldLine.spans = [];
|
|
54
|
+
newLine.spans = [];
|
|
55
|
+
for (const part of changes) {
|
|
56
|
+
if (part.removed) oldLine.spans.push({ start: oldOffset, end: oldOffset + part.value.length });
|
|
57
|
+
if (part.added) newLine.spans.push({ start: newOffset, end: newOffset + part.value.length });
|
|
58
|
+
if (!part.added) oldOffset += part.value.length;
|
|
59
|
+
if (!part.removed) newOffset += part.value.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pairChanges(rows: PreviewLine[], shown: number): void {
|
|
64
|
+
for (let i = 0; i < shown; i++) {
|
|
65
|
+
if (rows[i]!.kind !== "-") continue;
|
|
66
|
+
const start = i;
|
|
67
|
+
while (rows[i]?.kind === "-") i++;
|
|
68
|
+
const added = i;
|
|
69
|
+
while (rows[i]?.kind === "+") i++;
|
|
70
|
+
for (let n = 0; n < Math.min(added - start, i - added); n++) {
|
|
71
|
+
if (start + n >= shown && added + n >= shown) break;
|
|
72
|
+
changedWords(rows[start + n]!, rows[added + n]!);
|
|
73
|
+
}
|
|
74
|
+
i--;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function highlight(rows: PreviewLine[], path: string, theme: Theme): void {
|
|
79
|
+
const language = getLanguageFromPath(path);
|
|
80
|
+
for (let start = 0; start < rows.length;) {
|
|
81
|
+
let end = start;
|
|
82
|
+
while (end < rows.length && rows[end]!.kind !== "gap") end++;
|
|
83
|
+
const segment = rows.slice(start, end);
|
|
84
|
+
// Highlight the two file versions independently, never as one mixed diff document.
|
|
85
|
+
for (const side of ["old", "new"] as const) {
|
|
86
|
+
if (side === "old" && !segment.some((line) => line.kind === "-")) continue;
|
|
87
|
+
const selected = segment.filter((line) => line.kind !== (side === "old" ? "+" : "-"));
|
|
88
|
+
const source = selected.map((line) => line.text).join("\n");
|
|
89
|
+
// shortcut: oversized snippets use plain foreground; raise the budget only with bounded highlighting.
|
|
90
|
+
const colored = language && source.length <= MAX_HIGHLIGHT_CHARS ? highlightCode(source, language) : undefined;
|
|
91
|
+
const unsupported = colored?.every((line, index) => line === theme.fg("mdCodeBlock", selected[index]?.text ?? ""));
|
|
92
|
+
selected.forEach((line, index) => {
|
|
93
|
+
if (side === "new" || line.kind === "-") line.highlighted = !unsupported && colored?.[index] !== undefined
|
|
94
|
+
? colored[index]!
|
|
95
|
+
: theme.fg("toolOutput", line.text);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
start = end + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Insert word bands by UTF-16 source offsets, leaving syntax ANSI outside the offset count. */
|
|
103
|
+
function withBands(code: string, spans: Span[], base: string, word: string): string {
|
|
104
|
+
if (spans.length === 0) return base + code.replace(SGR, (sgr, values: string) =>
|
|
105
|
+
values === "" || values.split(";").some((value) => value === "0" || value === "49") ? sgr + base : sgr);
|
|
106
|
+
let output = base;
|
|
107
|
+
let offset = 0;
|
|
108
|
+
let spanIndex = 0;
|
|
109
|
+
let active = base;
|
|
110
|
+
let from = 0;
|
|
111
|
+
const append = (text: string) => {
|
|
112
|
+
for (const char of text) {
|
|
113
|
+
while (spans[spanIndex] && spans[spanIndex]!.end <= offset) spanIndex++;
|
|
114
|
+
const span = spans[spanIndex];
|
|
115
|
+
const next = span && span.start < offset + char.length && span.end > offset ? word : base;
|
|
116
|
+
if (next !== active) output += next;
|
|
117
|
+
active = next;
|
|
118
|
+
output += char;
|
|
119
|
+
offset += char.length;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
for (const match of code.matchAll(SGR)) {
|
|
123
|
+
append(code.slice(from, match.index));
|
|
124
|
+
output += match[0];
|
|
125
|
+
// Syntax resets must restore the current band, including a word band.
|
|
126
|
+
if (match[1] === "" || match[1]!.split(";").some((value) => value === "0" || value === "49")) output += active;
|
|
127
|
+
from = match.index + match[0].length;
|
|
128
|
+
}
|
|
129
|
+
append(code.slice(from));
|
|
130
|
+
return output;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function prepareToolPreview(kind: "edit" | "write", source: string, path: string, theme: Theme): (width: number, indent: string) => string[] {
|
|
134
|
+
const rows: PreviewLine[] = kind === "edit" ? parseDiff(source) : codeLines(source)
|
|
135
|
+
.map((text, index) => ({ kind: " ", number: String(index + 1), text }));
|
|
136
|
+
const shown = Math.min(rows.length, MAX_PREVIEW_LINES);
|
|
137
|
+
if (kind === "edit") pairChanges(rows, shown);
|
|
138
|
+
const preview = rows.slice(0, shown);
|
|
139
|
+
highlight(preview, path, theme);
|
|
140
|
+
const digits = preview.reduce((max, line) => Math.max(max, line.number.length), 1);
|
|
141
|
+
const palette = theme.appearance === "light"
|
|
142
|
+
? { "+": [[229, 245, 232], [174, 226, 185]], "-": [[255, 230, 230], [255, 188, 188]] }
|
|
143
|
+
: { "+": [[26, 55, 40], [32, 96, 59]], "-": [[70, 30, 32], [124, 42, 48]] };
|
|
144
|
+
const prepared = preview.map((line) => {
|
|
145
|
+
const marker = kind === "edit" ? (line.kind === "gap" ? " " : line.kind) : "";
|
|
146
|
+
const prefix = `${marker}${line.number.padStart(digits)} `;
|
|
147
|
+
const code = theme.fg("dim", prefix) + (line.highlighted ?? theme.fg("dim", line.text));
|
|
148
|
+
if (line.kind !== "+" && line.kind !== "-") return { code };
|
|
149
|
+
const [normal, strong] = palette[line.kind].map(([r, g, b]) => backgroundAnsi(rgbColor(r!, g!, b!), theme.getColorMode()));
|
|
150
|
+
const spans = (line.spans ?? []).map(({ start, end }) => ({ start: start + prefix.length, end: end + prefix.length }));
|
|
151
|
+
return { code: withBands(code, spans, normal!, strong!), band: normal };
|
|
152
|
+
});
|
|
153
|
+
const hidden = rows.length - shown;
|
|
154
|
+
return (width, indent) => {
|
|
155
|
+
const pad = truncateToWidth(indent, Math.max(0, width), "");
|
|
156
|
+
const available = Math.max(0, width - visibleWidth(pad));
|
|
157
|
+
const result = prepared.map(({ code, band }) => {
|
|
158
|
+
const columns = Math.max(0, available - (band ? 2 : 0));
|
|
159
|
+
const clipped = truncateToWidth(code, columns, band ? `${band}…` : "…");
|
|
160
|
+
return pad + (band && columns > 0
|
|
161
|
+
? clipped + band + " ".repeat(Math.max(0, columns - visibleWidth(clipped))) + "\x1b[49m"
|
|
162
|
+
: clipped);
|
|
163
|
+
});
|
|
164
|
+
if (hidden > 0) result.push(pad + theme.fg("dim", truncateToWidth(`… +${hidden} lines (ctrl+o to expand)`, available, "…")));
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
}
|