pi-open-tui 0.4.0 → 0.4.1

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,244 +1,133 @@
1
- import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Component, TUI } from "@earendil-works/pi-tui";
3
- import {
4
- center,
5
- collectPiCommandNames,
6
- formatCwd,
7
- formatModelLabel,
8
- formatThinkingLabel,
9
- headerColumnWidths,
10
- padRight,
11
- pickSlashCommandTips,
12
- truncateToWidth,
13
- visibleWidth,
14
- } from "./utils.ts";
15
-
16
- const LOGO_CELL = "███";
17
-
18
- type LogoColor = "panel" | "cyan" | "red" | "green" | "orange" | "white" | "flash" | "brand";
19
- type LogoFrame = { phase: number; active: "left" | "top" | "right" | "none"; ax: number; ay: number; flash: boolean; white: boolean };
20
-
21
- const LOGO_FRAMES: LogoFrame[] = [
22
- ...Array.from({ length: 4 }, (_, ay) => ({ phase: 0, active: "left" as const, ax: 2, ay, flash: false, white: false })),
23
- ...Array.from({ length: 3 }, (_, ay) => ({ phase: 1, active: "top" as const, ax: 2, ay, flash: false, white: false })),
24
- ...Array.from({ length: 5 }, (_, ay) => ({ phase: 2, active: "right" as const, ax: 5, ay, flash: false, white: false })),
25
- { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
26
- { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
27
- { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
28
- { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
29
- { phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
30
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
31
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
32
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
33
- { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
34
- { phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
35
- ];
36
-
37
- function hasCell(y: number, x: number, cells: string): boolean {
38
- return cells.split(" ").includes(`${y},${x}`);
39
- }
40
-
41
- function hasPiece(y: number, x: number, py: number, px: number, cells: string): boolean {
42
- return cells.split(" ").some((item) => {
43
- const [dy, dx] = item.split(",").map(Number);
44
- return y === py + dy && x === px + dx;
45
- });
46
- }
47
-
48
- function logoCellColor(frame: LogoFrame, y: number, x: number): LogoColor {
49
- if (frame.white) {
50
- return hasCell(y, x, "3,2 3,3 3,4 4,2 4,4 5,2 5,3 5,5 6,2 6,5") ? "white" : "panel";
51
- }
52
- if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
53
-
54
- switch (frame.active) {
55
- case "left":
56
- if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 1,1 2,0")) return "red";
57
- break;
58
- case "top":
59
- if (hasPiece(y, x, frame.ay, frame.ax, "0,0 0,1 0,2 1,2")) return "cyan";
60
- break;
61
- case "right":
62
- if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 2,0 2,1")) return "green";
63
- break;
64
- }
65
-
66
- if (frame.phase === 6) {
67
- return hasCell(y, x, "3,2 3,3 3,4 4,4 4,2 5,2 5,3 5,5 6,2 6,5") ? "brand" : "panel";
68
- }
69
- if (frame.phase === 4) {
70
- if (hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
71
- if (hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
72
- if (hasCell(y, x, "4,5 5,5")) return "green";
73
- return "panel";
74
- }
75
- if (frame.phase >= 5) {
76
- if (hasCell(y, x, "3,2 3,3 3,4 4,4")) return "cyan";
77
- if (hasCell(y, x, "4,2 5,2 5,3 6,2")) return "red";
78
- if (hasCell(y, x, "5,5 6,5")) return "green";
79
- return "panel";
80
- }
81
- if (frame.phase <= 3 && hasCell(y, x, "6,1 6,2 6,3 6,4")) return "orange";
82
- if (frame.phase >= 2 && hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
83
- if (frame.phase >= 1 && hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
84
- if (frame.phase >= 3 && hasCell(y, x, "4,5 5,5 6,5 6,6")) return "green";
85
- return "panel";
86
- }
87
-
88
- function colorCell(color: LogoColor, paintBrand: (text: string) => string): string {
89
- switch (color) {
90
- case "cyan": return `\x1b[36m${LOGO_CELL}\x1b[39m`;
91
- case "red": return `\x1b[31m${LOGO_CELL}\x1b[39m`;
92
- case "green": return `\x1b[32m${LOGO_CELL}\x1b[39m`;
93
- case "orange":
94
- case "flash": return `\x1b[33m${LOGO_CELL}\x1b[39m`;
95
- case "white": return `\x1b[39m${LOGO_CELL}`;
96
- case "brand": return paintBrand(LOGO_CELL);
97
- default: return " ".repeat(LOGO_CELL.length);
98
- }
99
- }
100
-
101
- function renderLogo(frameIndex: number, paintBrand: (text: string) => string): string[] {
102
- const frame = LOGO_FRAMES[frameIndex % LOGO_FRAMES.length]!;
103
- const grid: LogoColor[][] = [];
104
- for (let y = 1; y <= 7; y++) {
105
- const row: LogoColor[] = [];
106
- for (let x = 1; x <= 8; x++) row.push(logoCellColor(frame, y, x));
107
- grid.push(row);
108
- }
109
-
110
- let minX = 7;
111
- let maxX = 0;
112
- for (const row of grid) {
113
- row.forEach((cell, x) => {
114
- if (cell !== "panel") {
115
- minX = Math.min(minX, x);
116
- maxX = Math.max(maxX, x);
117
- }
118
- });
119
- }
120
- if (maxX < minX) { minX = 0; maxX = 7; }
121
-
122
- return grid.map((row) => {
123
- let line = "";
124
- for (let x = minX; x <= maxX; x++) line += colorCell(row[x]!, paintBrand);
125
- return line;
126
- });
127
- }
128
-
129
- function borderLine(
130
- left: string,
131
- label: string,
132
- right: string,
133
- width: number,
134
- paint: (text: string) => string,
135
- ): string {
136
- if (width <= 1) return "";
137
- if (width < 8 || label.length === 0) {
138
- return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
139
- }
140
-
141
- const before = "─── ";
142
- const after = " ─────";
143
- const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
144
- const fill = Math.max(0, width - 2 - fixedWidth);
145
- return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
146
- }
147
-
148
- function boxedLine(content: string, width: number, paint: (text: string) => string): string {
149
- if (width <= 2) return truncateToWidth(content, width, "");
150
- return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
151
- }
152
-
153
- function twoColumn(
154
- left: string,
155
- right: string,
156
- leftWidth: number,
157
- rightWidth: number,
158
- paint: (text: string) => string,
159
- ): string {
160
- return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
161
- }
162
-
163
- export class OpenTuiHeader implements Component {
164
- private readonly pi: ExtensionAPI;
165
- private readonly ctx: ExtensionContext;
166
- private readonly frame = LOGO_FRAMES.length - 1;
167
- private readonly tipCommands: string[];
168
-
169
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
170
- this.pi = pi;
171
- this.ctx = ctx;
172
- const pool = collectPiCommandNames(pi.getCommands());
173
- this.tipCommands = pickSlashCommandTips(pool, {
174
- fixed: ["open-tui"],
175
- count: 3,
176
- });
177
- }
178
-
179
- render(width: number): string[] {
180
- const theme = this.ctx.ui.theme;
181
- const paint = (s: string) => theme.fg("accent", s);
182
- const muted = (s: string) => theme.fg("muted", s);
183
- const dim = (s: string) => theme.fg("dim", s);
184
- const bold = (s: string) => theme.bold(s);
185
-
186
- if (width < 24) return [paint(`Pi v${VERSION}`)];
187
-
188
- const innerWidth = width - 2;
189
- const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
190
- const model = formatModelLabel(this.ctx.model);
191
- const effort = formatThinkingLabel(this.pi.getThinkingLevel());
192
- const cwd = formatCwd(this.ctx.cwd);
193
-
194
- const leftLines = [
195
- ...renderLogo(this.frame, paint).map((line) => center(line, leftWidth)),
196
- center(bold("Let's build something great"), leftWidth),
197
- center(muted(`${model} · ${effort}`), leftWidth),
198
- center(dim(cwd), leftWidth),
199
- ];
200
-
201
- const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
202
- const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
203
- const tipLines = [
204
- "",
205
- paint(bold("Welcome")),
206
- muted("Ask Pi anything"),
207
- tipDivider,
208
- paint(bold("Commands")),
209
- muted(cmd0),
210
- muted(cmd1),
211
- muted(cmd2),
212
- muted(cmd3),
213
- "",
214
- ];
215
-
216
- const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
217
- for (let i = 0; i < leftLines.length; i++) {
218
- const content = useTips
219
- ? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
220
- : padRight(leftLines[i] ?? "", leftWidth);
221
- lines.push(boxedLine(content, width, paint));
222
- }
223
- lines.push(borderLine("╰", "", "╯", width, paint));
224
- return lines.map((line) => truncateToWidth(line, width, ""));
225
- }
226
-
227
- invalidate(): void {}
228
-
229
- dispose(): void {}
230
- }
231
-
232
- export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
233
- let header: OpenTuiHeader | undefined;
234
- ctx.ui.setHeader((tui) => {
235
- header?.dispose();
236
- header = new OpenTuiHeader(pi, ctx, tui);
237
- return header;
238
- });
239
- return () => {
240
- header?.dispose();
241
- header = undefined;
242
- ctx.ui.setHeader(undefined);
243
- };
244
- }
1
+ import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import {
4
+ center,
5
+ collectPiCommandNames,
6
+ formatCwd,
7
+ formatModelLabel,
8
+ formatThinkingLabel,
9
+ headerColumnWidths,
10
+ padRight,
11
+ pickSlashCommandTips,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ } from "./utils.ts";
15
+
16
+ const LOGO = [
17
+ " ",
18
+ " ",
19
+ "█████████ ",
20
+ "███ ███ ",
21
+ "██████ ███",
22
+ "███ ███",
23
+ " ",
24
+ ];
25
+
26
+ function renderLogo(paintBrand: (text: string) => string): string[] {
27
+ return LOGO.map((line) => line.replace(/███/g, paintBrand));
28
+ }
29
+
30
+ function borderLine(
31
+ left: string,
32
+ label: string,
33
+ right: string,
34
+ width: number,
35
+ paint: (text: string) => string,
36
+ ): string {
37
+ if (width <= 1) return "";
38
+ if (width < 8 || label.length === 0) {
39
+ return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
40
+ }
41
+
42
+ const before = "─── ";
43
+ const after = " ─────";
44
+ const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
45
+ const fill = Math.max(0, width - 2 - fixedWidth);
46
+ return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
47
+ }
48
+
49
+ function boxedLine(content: string, width: number, paint: (text: string) => string): string {
50
+ if (width <= 2) return truncateToWidth(content, width, "");
51
+ return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
52
+ }
53
+
54
+ function twoColumn(
55
+ left: string,
56
+ right: string,
57
+ leftWidth: number,
58
+ rightWidth: number,
59
+ paint: (text: string) => string,
60
+ ): string {
61
+ return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
62
+ }
63
+
64
+ export class OpenTuiHeader implements Component {
65
+ private readonly pi: ExtensionAPI;
66
+ private readonly ctx: ExtensionContext;
67
+ private readonly tipCommands: string[];
68
+
69
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
70
+ this.pi = pi;
71
+ this.ctx = ctx;
72
+ const pool = collectPiCommandNames(pi.getCommands());
73
+ this.tipCommands = pickSlashCommandTips(pool, {
74
+ fixed: ["open-tui"],
75
+ count: 3,
76
+ });
77
+ }
78
+
79
+ render(width: number): string[] {
80
+ const theme = this.ctx.ui.theme;
81
+ const paint = (s: string) => theme.fg("accent", s);
82
+ const muted = (s: string) => theme.fg("muted", s);
83
+ const dim = (s: string) => theme.fg("dim", s);
84
+ const bold = (s: string) => theme.bold(s);
85
+
86
+ if (width < 24) return [truncateToWidth(paint(`Pi v${VERSION}`), width, "")];
87
+
88
+ const innerWidth = width - 2;
89
+ const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
90
+ const model = formatModelLabel(this.ctx.model);
91
+ const effort = formatThinkingLabel(this.pi.getThinkingLevel());
92
+ const cwd = formatCwd(this.ctx.cwd);
93
+
94
+ const leftLines = [
95
+ ...renderLogo(paint).map((line) => center(line, leftWidth)),
96
+ center(bold("Let's build something great"), leftWidth),
97
+ center(muted(`${model} · ${effort}`), leftWidth),
98
+ center(dim(cwd), leftWidth),
99
+ ];
100
+
101
+ const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
102
+ const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
103
+ const tipLines = [
104
+ "",
105
+ paint(bold("Welcome")),
106
+ muted("Ask Pi anything"),
107
+ tipDivider,
108
+ paint(bold("Commands")),
109
+ muted(cmd0),
110
+ muted(cmd1),
111
+ muted(cmd2),
112
+ muted(cmd3),
113
+ "",
114
+ ];
115
+
116
+ const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
117
+ for (let i = 0; i < leftLines.length; i++) {
118
+ const content = useTips
119
+ ? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
120
+ : padRight(leftLines[i] ?? "", leftWidth);
121
+ lines.push(boxedLine(content, width, paint));
122
+ }
123
+ lines.push(borderLine("╰", "", "╯", width, paint));
124
+ return lines.map((line) => truncateToWidth(line, width, ""));
125
+ }
126
+
127
+ invalidate(): void {}
128
+ }
129
+
130
+ export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
131
+ ctx.ui.setHeader((tui) => new OpenTuiHeader(pi, ctx, tui));
132
+ return () => ctx.ui.setHeader(undefined);
133
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
3
+ import { installCopyButtons, markCopyScope } from "./copy-buttons.ts";
3
4
  import { installEditor } from "./editor.ts";
4
5
  import { installFooter } from "./footer.ts";
5
6
  import { installHeader } from "./header.ts";
@@ -32,12 +33,7 @@ export function getPendingUiChange(enabled: boolean, active: boolean): PendingUi
32
33
  }
33
34
 
34
35
  function isTuiContext(ctx: ExtensionContext): boolean {
35
- try {
36
- const mode = (ctx as ExtensionContext & { mode?: string }).mode;
37
- return ctx.hasUI && (mode === undefined || mode === "tui");
38
- } catch {
39
- return false;
40
- }
36
+ return ctx.hasUI && ctx.mode === "tui";
41
37
  }
42
38
 
43
39
  export default function (pi: ExtensionAPI) {
@@ -54,6 +50,7 @@ export default function (pi: ExtensionAPI) {
54
50
  let cleanupHeader: (() => void) | undefined;
55
51
  let cleanupFooter: (() => void) | undefined;
56
52
  let editor: ReturnType<typeof installEditor> | undefined;
53
+ let copyButtons: ReturnType<typeof installCopyButtons> | undefined;
57
54
  let pendingUiChange: PendingUiChange | undefined;
58
55
 
59
56
  // thinking-peek state (rendered by Pi inside its hidden-thinking block)
@@ -65,15 +62,6 @@ export default function (pi: ExtensionAPI) {
65
62
 
66
63
  const getThinkingLevel = () => (sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off");
67
64
 
68
- /** True while the agent is running (false during session-restore replay). */
69
- const isAgentIdle = (ctx: ExtensionContext): boolean => {
70
- try {
71
- return (ctx as ExtensionContext & { isIdle?: () => boolean }).isIdle?.() === true;
72
- } catch {
73
- return false;
74
- }
75
- };
76
-
77
65
  /** Pi decides whether this label is visible; our toggle only controls updates. */
78
66
  const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
79
67
 
@@ -131,6 +119,7 @@ export default function (pi: ExtensionAPI) {
131
119
  void scheduleGitRefresh(ctx);
132
120
  },
133
121
  getLiveTps: () => turnTelemetry.getLiveTps(),
122
+ canEmbedInEditor: () => editor?.isFramed() ?? false,
134
123
  },
135
124
  );
136
125
  cleanupFooter = footer.cleanup;
@@ -143,6 +132,11 @@ export default function (pi: ExtensionAPI) {
143
132
  render: footer.renderInline,
144
133
  },
145
134
  );
135
+ copyButtons = installCopyButtons({
136
+ getTui: editor.getTui,
137
+ getTheme: () => ctx.ui.theme,
138
+ isEnabled: () => config.copyButtons,
139
+ });
146
140
  active = true;
147
141
  }
148
142
  };
@@ -152,9 +146,11 @@ export default function (pi: ExtensionAPI) {
152
146
  if (active) {
153
147
  cleanupHeader?.();
154
148
  cleanupFooter?.();
149
+ copyButtons?.cleanup();
155
150
  editor?.cleanup();
156
151
  cleanupHeader = undefined;
157
152
  cleanupFooter = undefined;
153
+ copyButtons = undefined;
158
154
  editor = undefined;
159
155
  requestFooterRender = undefined;
160
156
  resetPeek();
@@ -224,7 +220,6 @@ export default function (pi: ExtensionAPI) {
224
220
  pi.on("session_start", async (_event, ctx) => {
225
221
  sessionLifecycle.start();
226
222
  lastCtx = ctx;
227
- state.sessionStartEpoch = Date.now();
228
223
  state.workingSince = undefined;
229
224
  state.lastDoneIn = undefined;
230
225
  invalidateUsageCache();
@@ -275,7 +270,7 @@ export default function (pi: ExtensionAPI) {
275
270
 
276
271
  pi.on("message_start", (event, ctx) => {
277
272
  turnTelemetry.handle(event);
278
- if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
273
+ if (!sessionLifecycle.isCurrent() || ctx.isIdle()) return;
279
274
  const role = event.message?.role;
280
275
  if (role === "user") {
281
276
  // Restore Pi's native label until real thinking arrives. The task epoch is
@@ -290,7 +285,7 @@ export default function (pi: ExtensionAPI) {
290
285
 
291
286
  pi.on("message_update", (event, ctx) => {
292
287
  turnTelemetry.handle(event);
293
- if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
288
+ if (!sessionLifecycle.isCurrent() || ctx.isIdle()) return;
294
289
  if (!isPeekEnabled()) return;
295
290
  const message = event.message;
296
291
  if (!message || message.role !== "assistant") return;
@@ -370,28 +365,33 @@ export default function (pi: ExtensionAPI) {
370
365
  refreshInteractiveState(ctx);
371
366
  });
372
367
 
373
- if (typeof pi.registerToolRenderer === "function") {
374
- registerToolView(
375
- pi,
376
- () => {
377
- // Resumed sessions rebuild their rows before session_start fires, so read the file once here.
378
- if (!configLoaded) {
379
- config = loadConfig();
380
- configLoaded = true;
381
- }
382
- return config.enabled ? config.toolView.mode : "native";
383
- },
384
- () => resolveGlyphs(config.icons.mode),
385
- );
386
- }
368
+ registerToolView(
369
+ pi,
370
+ () => {
371
+ // Resumed sessions rebuild their rows before session_start fires, so read the file once here.
372
+ if (!configLoaded) {
373
+ config = loadConfig();
374
+ configLoaded = true;
375
+ }
376
+ return config.enabled ? config.toolView.mode : "native";
377
+ },
378
+ () => resolveGlyphs(config.icons.mode),
379
+ );
380
+
381
+ pi.registerMarkdownTransformer(markCopyScope);
387
382
 
388
383
  registerSettingsCommand(pi, {
389
384
  getConfig: () => config,
390
385
  onConfigChanged: (newConfig) => {
391
386
  const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
392
387
  const headerChanged = config.header !== newConfig.header;
388
+ const copyButtonsChanged = config.copyButtons !== newConfig.copyButtons;
393
389
  const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
394
- saveConfig(newConfig);
390
+ if (!saveConfig(newConfig)) {
391
+ lastCtx?.ui.notify(newConfig.settingsLanguage === "zh"
392
+ ? "open-tui 设置本次生效,但未保存到磁盘。请检查配置目录权限和可用空间。"
393
+ : "open-tui settings apply for this session but could not be saved. Check configuration directory permissions and free space.", "warning");
394
+ }
395
395
  config = newConfig;
396
396
  if (newConfig.thinkingPeek.lines === 0 || !newConfig.enabled) {
397
397
  resetPeek();
@@ -406,6 +406,7 @@ export default function (pi: ExtensionAPI) {
406
406
  cleanupHeader?.();
407
407
  cleanupHeader = newConfig.header ? installHeader(pi, lastCtx) : undefined;
408
408
  }
409
+ if (copyButtonsChanged) copyButtons?.refresh();
409
410
  if (lastCtx) {
410
411
  pendingUiChange = getPendingUiChange(newConfig.enabled, active);
411
412
  }
@@ -31,10 +31,9 @@ const RUNTIMES: readonly RuntimeDef[] = [
31
31
  { name: "swift", files: ["Package.swift"], versionCommand: { cmd: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+)/ } },
32
32
  { name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
33
33
  { name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
34
- { name: "c", files: ["Makefile", "CMakeLists.txt"] },
35
34
  { name: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"], versionCommand: { cmd: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
36
35
  { name: "php", files: ["composer.json"], versionCommand: { cmd: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
37
- { name: "haskell", files: ["stack.yaml", "cabal.project", ".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
36
+ { name: "haskell", files: ["stack.yaml", "cabal.project"], extensions: [".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
38
37
  { name: "julia", files: ["Project.toml", "Manifest.toml"], versionCommand: { cmd: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/ } },
39
38
  { name: "lua", files: ["stylua.toml", ".luarc.json"], versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ } },
40
39
  { name: "elixir", files: ["mix.exs"], versionCommand: { cmd: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
@@ -42,13 +41,13 @@ const RUNTIMES: readonly RuntimeDef[] = [
42
41
  { name: "gleam", files: ["gleam.toml"], versionCommand: { cmd: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/ } },
43
42
  { name: "crystal", files: ["shard.yml"], versionCommand: { cmd: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
44
43
  { name: "dart", files: ["pubspec.yaml"], versionCommand: { cmd: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
45
- { name: "nim", files: ["nim.cfg", ".nimble"] },
44
+ { name: "nim", files: ["nim.cfg"], extensions: [".nimble"] },
46
45
  { name: "zig", files: ["build.zig"], versionCommand: { cmd: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
47
- { name: "ocaml", files: [".opam", "dune", "dune-project"] },
46
+ { name: "ocaml", files: ["dune", "dune-project"], extensions: [".opam"] },
48
47
  { name: "clojure", files: ["project.clj", "deps.edn"] },
49
- { name: "scala", files: ["build.sbt", ".scala", ".metals"] },
48
+ { name: "scala", files: ["build.sbt", ".metals"], extensions: [".scala"] },
50
49
  { name: "perl", files: ["Makefile.PL", "cpanfile"] },
51
- { name: "r", files: [".Rproj", "DESCRIPTION"] },
50
+ { name: "r", files: ["DESCRIPTION"], extensions: [".Rproj"] },
52
51
  { name: "elm", files: ["elm.json"] },
53
52
  { name: "haxe", files: ["haxelib.json", ".haxerc"] },
54
53
  { name: "vagrant", files: ["Vagrantfile"] },
@@ -65,7 +64,6 @@ const RUNTIMES: readonly RuntimeDef[] = [
65
64
  { name: "v", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
66
65
  { name: "xmake", files: ["xmake.lua"] },
67
66
  { name: "gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
68
- { name: "maven", files: ["pom.xml"] },
69
67
  { name: "cmake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
70
68
  { name: "meson", files: ["meson.build"], env: "MESON_DEVENV" },
71
69
  { name: "nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
@@ -76,7 +74,7 @@ const RUNTIMES: readonly RuntimeDef[] = [
76
74
  { name: "pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
77
75
  { name: "typst", files: ["template.typ"], extensions: [".typ"] },
78
76
  { name: "buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
79
- { name: "dotnet", files: [".csproj", ".fsproj", "global.json", "Directory.Build.props"] },
77
+ { name: "dotnet", files: ["global.json", "Directory.Build.props"], extensions: [".csproj", ".fsproj"] },
80
78
  { name: "cobol", files: [], extensions: [".cbl", ".cob"] },
81
79
  ];
82
80
 
@@ -124,16 +122,17 @@ function matchesDef(cwd: string, def: RuntimeDef): boolean {
124
122
  async function fetchVersion(def: RuntimeDef, cwd: string): Promise<string | undefined> {
125
123
  if (!def.versionCommand) return undefined;
126
124
  try {
127
- const { stdout } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
125
+ const { stdout, stderr } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
128
126
  cwd,
129
127
  timeout: VERSION_TIMEOUT_MS,
130
128
  maxBuffer: 64 * 1024,
131
129
  });
130
+ const output = `${stdout}\n${stderr}`;
132
131
  if (def.versionCommand.pattern) {
133
- const match = stdout.match(def.versionCommand.pattern);
132
+ const match = output.match(def.versionCommand.pattern);
134
133
  return match?.[1];
135
134
  }
136
- return stdout.trim() || undefined;
135
+ return output.trim() || undefined;
137
136
  } catch {
138
137
  return undefined;
139
138
  }