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.
@@ -1,43 +1,23 @@
1
- export class SessionLifecycle {
2
- private current = 0;
3
- private deferred: Array<() => void> = [];
4
- private shutDown = false;
5
-
6
- start(): void {
7
- this.current++;
8
- this.shutDown = false;
9
- this.flushDeferred();
10
- }
11
-
12
- shutdown(): void {
13
- this.shutDown = true;
14
- this.deferred = [];
15
- }
16
-
17
- isCurrent(generation?: number): boolean {
18
- if (this.shutDown) return false;
19
- if (generation === undefined) return true;
20
- return generation === this.current;
21
- }
22
-
23
- currentGeneration(): number {
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: 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 cachedWidth: number | undefined;
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(preferredItemId = this.selectedItemByTab[this.tab]): void {
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
- label: this.compact ? `${item.label}: ${item.currentValue}` : item.label,
326
- description: this.compact ? undefined : item.currentValue,
327
- } as SelectItem));
328
- this.selectList = new SelectList(items, Math.min(items.length, 12), {
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
- const compact = width <= 60;
381
- if (compact !== this.compact) {
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 && this.cachedWidth === width) return 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
- await ctx.ui.custom<void>((tui: TUI, theme, _kb, done) => {
411
- const ui = new SettingsUi(
412
- theme,
413
- hooks.getConfig(),
414
- (config) => hooks.onConfigChanged(config),
415
- () => done(undefined),
416
- );
417
- return {
418
- render: (w: number) => ui.render(w),
419
- invalidate: () => ui.invalidate(),
420
- handleInput: (data: string) => {
421
- ui.handleInput(data);
422
- tui.requestRender();
423
- },
424
- };
425
- }, { overlay: true });
426
- // Overlay is closed and focus is back on the editor. Deferred UI changes
427
- // (e.g. toggling the extension) run here, so pi core's focus restore
428
- // cannot strand the overlay without keyboard input.
429
- hooks.onOverlayClosed?.();
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: { key: string; totals: UsageTotals } | undefined;
26
-
27
- function entriesKey(ctx: ExtensionContext): string {
28
- const entries = ctx.sessionManager.getEntries();
29
- const last = entries.at(-1);
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 key = entriesKey(ctx);
35
- if (usageCache && usageCache.key === key) return usageCache.totals;
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 ctx.sessionManager.getEntries()) {
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 = { key, totals };
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
+ }