pi-minimal 0.1.0 → 0.2.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/.github/dependabot.yml +11 -0
- package/.github/workflows/publish.yml +178 -14
- package/.github/workflows/release.yml +42 -0
- package/CHANGELOG.md +81 -0
- package/README.md +28 -1
- package/SECURITY.md +21 -0
- package/dist/activity.js +0 -3
- package/dist/chrome.js +15 -0
- package/dist/code-box.js +69 -0
- package/dist/cwd.js +95 -53
- package/dist/footer.js +30 -29
- package/dist/git-state.js +63 -0
- package/dist/index.js +59 -6
- package/dist/tool-rows.js +143 -12
- package/dist/tools.js +146 -77
- package/dist/transcript.js +50 -0
- package/dist/turn-rule.js +121 -0
- package/package.json +14 -3
- package/release-notes.md +32 -0
- package/src/activity.ts +0 -4
- package/src/chrome.ts +18 -0
- package/src/code-box.ts +86 -0
- package/src/cwd.ts +104 -52
- package/src/footer.ts +38 -34
- package/src/git-state.ts +71 -0
- package/src/index.ts +60 -6
- package/src/tool-rows.ts +170 -19
- package/src/tools.ts +160 -105
- package/src/transcript.ts +51 -0
- package/src/turn-rule.ts +139 -0
- package/test/activity.test.ts +4 -15
- package/test/code-box.test.ts +55 -0
- package/test/footer.test.ts +38 -0
- package/test/git-state.test.ts +52 -0
- package/test/index.test.ts +40 -4
- package/test/package.test.ts +3 -2
- package/test/theme.test.ts +45 -0
- package/test/tool-rows.test.ts +109 -0
- package/test/tools.test.ts +49 -42
- package/test/transcript.test.ts +22 -0
- package/test/turn-rule.test.ts +33 -0
- package/themes/minimal-dark.json +84 -0
package/dist/cwd.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import * as chrome from "./chrome.js";
|
|
4
|
+
import { formatGitState } from "./git-state.js";
|
|
5
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
6
|
+
const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
|
|
7
|
+
export function formatCwd(cwd) {
|
|
5
8
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
6
9
|
if (!home)
|
|
7
10
|
return cwd;
|
|
@@ -12,7 +15,7 @@ function formatCwd(cwd) {
|
|
|
12
15
|
}
|
|
13
16
|
return cwd;
|
|
14
17
|
}
|
|
15
|
-
function formatTokens(n) {
|
|
18
|
+
export function formatTokens(n) {
|
|
16
19
|
if (n < 1000)
|
|
17
20
|
return String(n);
|
|
18
21
|
if (n < 10000)
|
|
@@ -23,7 +26,7 @@ function formatTokens(n) {
|
|
|
23
26
|
return `${(n / 1e6).toFixed(1)}M`;
|
|
24
27
|
return `${Math.round(n / 1e6)}M`;
|
|
25
28
|
}
|
|
26
|
-
function modelIcon(id) {
|
|
29
|
+
export function modelIcon(id) {
|
|
27
30
|
const s = id.toLowerCase();
|
|
28
31
|
if (s.includes("grok"))
|
|
29
32
|
return "✦";
|
|
@@ -38,7 +41,7 @@ function modelIcon(id) {
|
|
|
38
41
|
return "◈";
|
|
39
42
|
return "◆";
|
|
40
43
|
}
|
|
41
|
-
function shortModel(id) {
|
|
44
|
+
export function shortModel(id) {
|
|
42
45
|
return (id.split("/").pop() || id).replace(/^claude-/, "");
|
|
43
46
|
}
|
|
44
47
|
function thinkingColor(level) {
|
|
@@ -48,56 +51,95 @@ function thinkingColor(level) {
|
|
|
48
51
|
return "accent";
|
|
49
52
|
return "dim";
|
|
50
53
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
/** Context pressure band used for the meter and percentage colour. */
|
|
55
|
+
export function contextBand(pct) {
|
|
56
|
+
if (pct >= 90)
|
|
57
|
+
return "error";
|
|
58
|
+
if (pct >= 70)
|
|
59
|
+
return "warning";
|
|
60
|
+
return "accent";
|
|
55
61
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
export function meter(percent, fill, empty, cells = 8) {
|
|
63
|
+
const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)));
|
|
64
|
+
return fill.repeat(filled) + empty.repeat(cells - filled);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fit `left` and `right` into `width`. Right-hand segments are dropped from the end first (they are
|
|
68
|
+
* the least important), then the left is truncated.
|
|
69
|
+
*/
|
|
70
|
+
export function splitRow(left, rightSegments, width, sep = " ") {
|
|
71
|
+
const segments = [...rightSegments.filter((s) => visibleWidth(s) > 0)];
|
|
72
|
+
let right = segments.join(sep);
|
|
73
|
+
while (segments.length > 0 && visibleWidth(left) + 2 + visibleWidth(right) > width) {
|
|
74
|
+
segments.pop();
|
|
75
|
+
right = segments.join(sep);
|
|
61
76
|
}
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
let L = left;
|
|
78
|
+
if (visibleWidth(L) + (right ? 2 : 0) + visibleWidth(right) > width) {
|
|
79
|
+
L = truncateToWidth(L, Math.max(0, width - (right ? 2 : 0) - visibleWidth(right)), "");
|
|
64
80
|
}
|
|
65
|
-
const gap = Math.max(
|
|
66
|
-
return L + " ".repeat(gap) +
|
|
81
|
+
const gap = right ? Math.max(2, width - visibleWidth(L) - visibleWidth(right)) : 0;
|
|
82
|
+
return L + " ".repeat(gap) + right;
|
|
83
|
+
}
|
|
84
|
+
function siblingColor(status) {
|
|
85
|
+
if (status === "idle" || status === "done")
|
|
86
|
+
return "success";
|
|
87
|
+
if (status === "working")
|
|
88
|
+
return "accent";
|
|
89
|
+
if (status === "blocked")
|
|
90
|
+
return "error";
|
|
91
|
+
return "muted";
|
|
67
92
|
}
|
|
68
|
-
export function installCwd(ctx) {
|
|
69
|
-
ctx.ui.setWidget("tw-cwd", (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
export function installCwd(ctx, sources) {
|
|
94
|
+
ctx.ui.setWidget("tw-cwd", (tui, theme) => {
|
|
95
|
+
const unsub = sources.onGitChange(() => tui.requestRender());
|
|
96
|
+
return {
|
|
97
|
+
dispose: unsub,
|
|
98
|
+
invalidate() { },
|
|
99
|
+
render(width) {
|
|
100
|
+
const inner = Math.max(8, width - margin() * 2);
|
|
101
|
+
const id = ctx.model?.id || "no-model";
|
|
102
|
+
const icon = theme.fg("accent", modelIcon(id));
|
|
103
|
+
const name = theme.fg("text", shortModel(id));
|
|
104
|
+
const thinking = ctx.thinkingLevel || "";
|
|
105
|
+
const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : "";
|
|
106
|
+
const usage = ctx.getContextUsage?.();
|
|
107
|
+
const window = usage?.contextWindow || ctx.model?.contextWindow;
|
|
108
|
+
const used = usage?.tokens;
|
|
109
|
+
let bar = theme.fg("dim", "░░░░░░░░");
|
|
110
|
+
let pctStr = theme.fg("dim", "?%");
|
|
111
|
+
let tokenStr = theme.fg("dim", "?/?");
|
|
112
|
+
if (typeof used === "number" && window) {
|
|
113
|
+
const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)));
|
|
114
|
+
const band = contextBand(pct);
|
|
115
|
+
bar = meter(pct, theme.fg(band, "▓"), theme.fg("dim", "░"));
|
|
116
|
+
pctStr = theme.fg(band === "accent" ? "muted" : band, `${pct}%`);
|
|
117
|
+
tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`);
|
|
118
|
+
}
|
|
119
|
+
else if (used === null && window) {
|
|
120
|
+
tokenStr = theme.fg("dim", `?/${formatTokens(window)}`);
|
|
121
|
+
}
|
|
122
|
+
const midot = theme.fg("dim", " · ");
|
|
123
|
+
const model = [`${icon} ${name}`, think].filter(Boolean).join(midot);
|
|
124
|
+
const context = `${bar} ${pctStr} ${tokenStr}`;
|
|
125
|
+
const left = `${model} ${context}`;
|
|
126
|
+
const right = [];
|
|
127
|
+
if (ctx.cwd)
|
|
128
|
+
right.push(theme.fg("muted", formatCwd(ctx.cwd)));
|
|
129
|
+
const git = formatGitState(sources.gitState());
|
|
130
|
+
if (git.branch) {
|
|
131
|
+
const dirty = git.dirty ? ` ${theme.fg("warning", git.dirty)}` : "";
|
|
132
|
+
right.push(`${theme.fg("dim", "⎇")} ${theme.fg("muted", git.branch)}${dirty}`);
|
|
133
|
+
}
|
|
134
|
+
const siblings = sources.siblings();
|
|
135
|
+
if (siblings && siblings.length > 0) {
|
|
136
|
+
right.push(siblings
|
|
137
|
+
.map((s) => `${theme.fg("text", s.name)} ${theme.fg(siblingColor(s.status), s.status)}`)
|
|
138
|
+
.join(theme.fg("dim", " · ")));
|
|
139
|
+
}
|
|
140
|
+
const row = splitRow(left, right, inner);
|
|
141
|
+
return [`${" ".repeat(margin())}${row}`];
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}, { placement: "aboveEditor" });
|
|
103
145
|
}
|
package/dist/footer.js
CHANGED
|
@@ -1,43 +1,44 @@
|
|
|
1
1
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth
|
|
3
|
-
import
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import * as chrome from "./chrome.js";
|
|
4
4
|
import { formatTrackerLines } from "./tracker.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
6
|
+
const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
|
|
7
|
+
/**
|
|
8
|
+
* Key hints are shown only when nothing else needs the line: idle agent, empty editor. Otherwise
|
|
9
|
+
* the footer collapses to zero lines (tracker lines excepted).
|
|
10
|
+
*/
|
|
11
|
+
export function footerHints(sources, hints) {
|
|
12
|
+
if (sources.busy())
|
|
13
|
+
return [];
|
|
14
|
+
if (sources.editorText().trim().length > 0)
|
|
15
|
+
return [];
|
|
16
|
+
const pct = sources.contextPct();
|
|
17
|
+
const out = [...hints];
|
|
18
|
+
if (pct !== undefined && pct >= 70)
|
|
19
|
+
out.push("/compact");
|
|
20
|
+
return out;
|
|
13
21
|
}
|
|
14
|
-
export function installFooter(ctx,
|
|
22
|
+
export function installFooter(ctx, sources) {
|
|
15
23
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
16
24
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
17
25
|
return {
|
|
18
26
|
dispose: unsub,
|
|
19
27
|
invalidate() { },
|
|
20
28
|
render(width) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return `${name} ${st}`;
|
|
32
|
-
})
|
|
33
|
-
.join(theme.fg("dim", " · "));
|
|
29
|
+
const inner = Math.max(8, width - margin() * 2);
|
|
30
|
+
const out = [];
|
|
31
|
+
const hints = footerHints(sources, [
|
|
32
|
+
keyHint("tui.input.submit", "send"),
|
|
33
|
+
keyHint("app.thinking.cycle", "thinking"),
|
|
34
|
+
keyHint("app.tools.expand", "expand tools"),
|
|
35
|
+
].filter(Boolean));
|
|
36
|
+
if (hints.length > 0) {
|
|
37
|
+
const line = theme.fg("dim", hints.join(" · "));
|
|
38
|
+
out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`);
|
|
34
39
|
}
|
|
35
|
-
const inner = Math.max(8, width - CHROME_MARGIN * 2);
|
|
36
|
-
const pad = Math.max(1, inner - visibleWidth(left) - visibleWidth(right));
|
|
37
|
-
const line = truncateToWidth(left + " ".repeat(pad) + right, inner, "");
|
|
38
|
-
const out = [`${" ".repeat(CHROME_MARGIN)}${line}`];
|
|
39
40
|
for (const extra of formatTrackerLines(ctx, theme)) {
|
|
40
|
-
out.push(`${" ".repeat(
|
|
41
|
+
out.push(`${" ".repeat(margin())}${truncateToWidth(extra, inner, "")}`);
|
|
41
42
|
}
|
|
42
43
|
return out;
|
|
43
44
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function parseBranch(stdout) {
|
|
2
|
+
const line = stdout.trim().split("\n")[0]?.trim();
|
|
3
|
+
if (!line || line === "HEAD")
|
|
4
|
+
return undefined;
|
|
5
|
+
return line;
|
|
6
|
+
}
|
|
7
|
+
export function parsePorcelain(stdout) {
|
|
8
|
+
return stdout.split("\n").filter((line) => line.trim().length > 0).length;
|
|
9
|
+
}
|
|
10
|
+
export function formatGitState(state) {
|
|
11
|
+
if (!state?.branch)
|
|
12
|
+
return { branch: "", dirty: "" };
|
|
13
|
+
const dirty = state.dirty && state.dirty > 0 ? `±${state.dirty}` : "";
|
|
14
|
+
return { branch: state.branch, dirty };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Polls git for the current branch and dirty count. Call `refresh()` after events that may have
|
|
18
|
+
* changed the tree (tool results, turn end); `get()` is always synchronous.
|
|
19
|
+
*/
|
|
20
|
+
export function createGitState(pi, cwd, minIntervalMs = 5000) {
|
|
21
|
+
let state;
|
|
22
|
+
let lastRun = 0;
|
|
23
|
+
let inFlight;
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
const refresh = (force = false) => {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (inFlight)
|
|
28
|
+
return inFlight;
|
|
29
|
+
if (!force && now - lastRun < minIntervalMs)
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
lastRun = now;
|
|
32
|
+
inFlight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 });
|
|
35
|
+
if (branchRun.code !== 0) {
|
|
36
|
+
state = undefined;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const branch = parseBranch(branchRun.stdout);
|
|
40
|
+
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 });
|
|
41
|
+
const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined;
|
|
42
|
+
state = { branch, dirty };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
state = undefined;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
inFlight = undefined;
|
|
49
|
+
for (const listener of listeners)
|
|
50
|
+
listener();
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
return inFlight;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
get: () => state,
|
|
57
|
+
refresh,
|
|
58
|
+
onChange(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => listeners.delete(listener);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,11 +5,21 @@ import { installFooter } from "./footer.js";
|
|
|
5
5
|
import { installCwd } from "./cwd.js";
|
|
6
6
|
import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
|
|
7
7
|
import { hideGauntletWidgets } from "./tracker.js";
|
|
8
|
-
import {
|
|
8
|
+
import { installToolRenderers, stopLiveWatch } from "./tools.js";
|
|
9
9
|
import { applyReclaim, reclaimPolicy } from "./reclaim.js";
|
|
10
10
|
import { installWorkingStatus } from "./working-status.js";
|
|
11
|
+
import { installTurnRule } from "./turn-rule.js";
|
|
12
|
+
import { installTranscript } from "./transcript.js";
|
|
13
|
+
import { createGitState } from "./git-state.js";
|
|
14
|
+
import { installCodeBox } from "./code-box.js";
|
|
15
|
+
import { setChromeMargin } from "./chrome.js";
|
|
16
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
11
17
|
export default function piMinimalExtension(pi) {
|
|
12
18
|
const stopWorkingStatus = installWorkingStatus(pi);
|
|
19
|
+
const stopTurnRule = installTurnRule(pi);
|
|
20
|
+
installToolRenderers(pi);
|
|
21
|
+
installTranscript(pi);
|
|
22
|
+
installCodeBox();
|
|
13
23
|
let fastTimer;
|
|
14
24
|
let slowTimer;
|
|
15
25
|
let stealTimer;
|
|
@@ -17,6 +27,9 @@ export default function piMinimalExtension(pi) {
|
|
|
17
27
|
let siblings;
|
|
18
28
|
let generation = 0;
|
|
19
29
|
let hideWidgets = true;
|
|
30
|
+
let busy = false;
|
|
31
|
+
let currentCwd = process.cwd();
|
|
32
|
+
const gitState = createGitState(pi, () => currentCwd);
|
|
20
33
|
const stopTimers = () => {
|
|
21
34
|
if (fastTimer)
|
|
22
35
|
clearInterval(fastTimer);
|
|
@@ -103,16 +116,37 @@ export default function piMinimalExtension(pi) {
|
|
|
103
116
|
},
|
|
104
117
|
chrome: () => {
|
|
105
118
|
installHeader(ctx);
|
|
106
|
-
|
|
119
|
+
installChromeFooter(ctx);
|
|
107
120
|
},
|
|
108
121
|
});
|
|
109
122
|
};
|
|
123
|
+
const contextPct = (ctx) => {
|
|
124
|
+
const usage = ctx.getContextUsage?.();
|
|
125
|
+
const window = usage?.contextWindow || ctx.model?.contextWindow;
|
|
126
|
+
if (typeof usage?.tokens !== "number" || !window)
|
|
127
|
+
return undefined;
|
|
128
|
+
return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)));
|
|
129
|
+
};
|
|
130
|
+
const installChromeFooter = (ctx) => {
|
|
131
|
+
installFooter(ctx, {
|
|
132
|
+
busy: () => busy,
|
|
133
|
+
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
134
|
+
contextPct: () => contextPct(ctx),
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const installStatusLine = (ctx) => {
|
|
138
|
+
installCwd(ctx, {
|
|
139
|
+
gitState: gitState.get,
|
|
140
|
+
siblings: () => siblings,
|
|
141
|
+
onGitChange: gitState.onChange,
|
|
142
|
+
});
|
|
143
|
+
};
|
|
110
144
|
const pinChrome = (ctx, reason) => {
|
|
111
145
|
hideForeignChrome(ctx, reason);
|
|
112
146
|
if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome)
|
|
113
147
|
return;
|
|
114
148
|
ctx.ui.setWidget("tw-cwd", undefined);
|
|
115
|
-
|
|
149
|
+
installStatusLine(ctx);
|
|
116
150
|
};
|
|
117
151
|
pi.on("session_start", async (_event, ctx) => {
|
|
118
152
|
if (ctx.mode !== "tui")
|
|
@@ -123,10 +157,18 @@ export default function piMinimalExtension(pi) {
|
|
|
123
157
|
projectTrusted: ctx.isProjectTrusted(),
|
|
124
158
|
});
|
|
125
159
|
hideWidgets = config.hideGauntletWidgets;
|
|
160
|
+
currentCwd = ctx.cwd;
|
|
161
|
+
try {
|
|
162
|
+
const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
163
|
+
setChromeMargin(settings.getOutputPad());
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Keep the default margin when settings are unavailable.
|
|
167
|
+
}
|
|
126
168
|
installHeader(ctx);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
169
|
+
installChromeFooter(ctx);
|
|
170
|
+
installStatusLine(ctx);
|
|
171
|
+
void gitState.refresh(true);
|
|
130
172
|
pinChrome(ctx, "session_start");
|
|
131
173
|
ctx.ui.setHiddenThinkingLabel("◆ Thought");
|
|
132
174
|
ctx.ui.setWorkingIndicator();
|
|
@@ -139,15 +181,26 @@ export default function piMinimalExtension(pi) {
|
|
|
139
181
|
await probe(ctx);
|
|
140
182
|
}
|
|
141
183
|
});
|
|
184
|
+
pi.on("agent_start", () => {
|
|
185
|
+
busy = true;
|
|
186
|
+
});
|
|
187
|
+
pi.on("agent_end", () => {
|
|
188
|
+
busy = false;
|
|
189
|
+
void gitState.refresh();
|
|
190
|
+
});
|
|
142
191
|
pi.on("turn_end", (_event, ctx) => {
|
|
143
192
|
pinChrome(ctx, "turn_end");
|
|
193
|
+
void gitState.refresh();
|
|
144
194
|
});
|
|
145
195
|
pi.on("tool_execution_end", (_event, ctx) => {
|
|
146
196
|
pinChrome(ctx, "tool_execution_end");
|
|
197
|
+
void gitState.refresh();
|
|
147
198
|
});
|
|
148
199
|
pi.on("session_shutdown", async () => {
|
|
200
|
+
busy = false;
|
|
149
201
|
stopTimers();
|
|
150
202
|
stopWorkingStatus();
|
|
203
|
+
stopTurnRule();
|
|
151
204
|
stopLiveWatch();
|
|
152
205
|
generation += 1;
|
|
153
206
|
siblings = undefined;
|
package/dist/tool-rows.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
/** Width of the verb column so items align across rows. */
|
|
3
|
+
export const VERB_WIDTH = 5;
|
|
4
|
+
const BASH_STATUS = /^Command (?:exited with code (\d+)|terminated without an exit code)$/;
|
|
5
5
|
export function shortName(path) {
|
|
6
6
|
const parts = path.split("/").filter(Boolean);
|
|
7
7
|
if (parts.length <= 1)
|
|
@@ -22,13 +22,144 @@ export function toolItem(kind, args) {
|
|
|
22
22
|
"";
|
|
23
23
|
return path ? shortName(path) : kind;
|
|
24
24
|
}
|
|
25
|
-
export function
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
export function resultText(result) {
|
|
26
|
+
if (!result?.content)
|
|
27
|
+
return "";
|
|
28
|
+
return result.content
|
|
29
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
30
|
+
.map((part) => part.text)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
export function rowGlyph(status, frame) {
|
|
34
|
+
if (status === "running")
|
|
35
|
+
return frame === 1 ? "◓" : "◐";
|
|
36
|
+
return status === "error" ? "✗" : "✓";
|
|
37
|
+
}
|
|
38
|
+
export function formatDuration(ms) {
|
|
39
|
+
if (ms === undefined || !Number.isFinite(ms))
|
|
40
|
+
return "";
|
|
41
|
+
if (ms < 1000)
|
|
42
|
+
return `${Math.round(ms / 100) / 10}s`;
|
|
43
|
+
if (ms < 60_000)
|
|
44
|
+
return `${Math.round(ms / 100) / 10}s`;
|
|
45
|
+
const minutes = Math.floor(ms / 60_000);
|
|
46
|
+
const seconds = Math.round((ms % 60_000) / 1000);
|
|
47
|
+
return `${minutes}m ${seconds}s`;
|
|
48
|
+
}
|
|
49
|
+
function plural(n, word, pluralWord = `${word}s`) {
|
|
50
|
+
return `${n} ${n === 1 ? word : pluralWord}`;
|
|
51
|
+
}
|
|
52
|
+
function countLines(text) {
|
|
53
|
+
if (!text)
|
|
54
|
+
return 0;
|
|
55
|
+
return text.split("\n").filter((line) => line.length > 0).length;
|
|
56
|
+
}
|
|
57
|
+
/** Strip pi's bash status trailer and truncation note from output text. */
|
|
58
|
+
export function bashBody(text) {
|
|
59
|
+
const lines = text.split("\n");
|
|
60
|
+
let exitCode;
|
|
61
|
+
while (lines.length > 0) {
|
|
62
|
+
const last = lines[lines.length - 1].trim();
|
|
63
|
+
const match = BASH_STATUS.exec(last);
|
|
64
|
+
if (match) {
|
|
65
|
+
exitCode = match[1] !== undefined ? Number(match[1]) : undefined;
|
|
66
|
+
lines.pop();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (last.startsWith("[Showing ") && last.endsWith("]")) {
|
|
70
|
+
lines.pop();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (last === "") {
|
|
74
|
+
lines.pop();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return { body: lines.join("\n"), exitCode };
|
|
80
|
+
}
|
|
81
|
+
export function diffStats(diff) {
|
|
82
|
+
let added = 0;
|
|
83
|
+
let removed = 0;
|
|
84
|
+
for (const line of diff.split("\n")) {
|
|
85
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
86
|
+
continue;
|
|
87
|
+
if (line.startsWith("+"))
|
|
88
|
+
added += 1;
|
|
89
|
+
else if (line.startsWith("-"))
|
|
90
|
+
removed += 1;
|
|
91
|
+
}
|
|
92
|
+
return { added, removed };
|
|
93
|
+
}
|
|
94
|
+
export function formatOutcome(kind, args, result, isError, durationMs) {
|
|
95
|
+
const text = resultText(result);
|
|
96
|
+
const dur = formatDuration(durationMs);
|
|
97
|
+
const join = (...parts) => parts.filter(Boolean).join(" · ");
|
|
98
|
+
if (kind === "bash") {
|
|
99
|
+
const { body, exitCode } = bashBody(text);
|
|
100
|
+
if (isError || (exitCode !== undefined && exitCode !== 0)) {
|
|
101
|
+
const code = exitCode !== undefined ? `exit ${exitCode}` : "failed";
|
|
102
|
+
return { text: join(code, dur), errorLine: firstLine(body) };
|
|
103
|
+
}
|
|
104
|
+
const lines = countLines(body);
|
|
105
|
+
const summary = lines === 0 || body === "(no output)" ? "ok" : plural(lines, "line");
|
|
106
|
+
return { text: join(summary, dur) };
|
|
107
|
+
}
|
|
108
|
+
if (isError)
|
|
109
|
+
return { text: join("failed", dur), errorLine: firstLine(text) };
|
|
110
|
+
if (kind === "edit") {
|
|
111
|
+
const details = result?.details;
|
|
112
|
+
const diff = typeof details?.diff === "string" ? diffStats(details.diff) : undefined;
|
|
113
|
+
return diff ? { text: dur, diff } : { text: join("edited", dur) };
|
|
114
|
+
}
|
|
115
|
+
if (kind === "write") {
|
|
116
|
+
const bytes = typeof args?.content === "string" ? Buffer.byteLength(args.content, "utf8") : undefined;
|
|
117
|
+
return { text: join(bytes === undefined ? "written" : formatBytes(bytes), dur) };
|
|
118
|
+
}
|
|
119
|
+
if (kind === "read") {
|
|
120
|
+
return { text: join(plural(countLines(text), "line"), dur) };
|
|
121
|
+
}
|
|
122
|
+
if (kind === "grep") {
|
|
123
|
+
if (/^No matches found/m.test(text))
|
|
124
|
+
return { text: join("0 matches", dur) };
|
|
125
|
+
return { text: join(plural(countLines(text), "match"), dur) };
|
|
126
|
+
}
|
|
127
|
+
// find / ls
|
|
128
|
+
return { text: join(plural(countLines(text), "entry", "entries"), dur) };
|
|
129
|
+
}
|
|
130
|
+
export function firstLine(text) {
|
|
131
|
+
for (const line of text.split("\n")) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (trimmed && trimmed !== "(no output)")
|
|
134
|
+
return trimmed;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
export function formatBytes(n) {
|
|
139
|
+
if (n < 1024)
|
|
140
|
+
return `${n} B`;
|
|
141
|
+
if (n < 1024 * 1024)
|
|
142
|
+
return `${Math.round(n / 102.4) / 10} KB`;
|
|
143
|
+
return `${Math.round(n / (1024 * 102.4)) / 10} MB`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Lay out one row: `<glyph> <verb> <item> ... <outcome>`. The item is truncated so the outcome
|
|
147
|
+
* stays visible; the outcome is dropped only when the row is too narrow to show both.
|
|
148
|
+
*/
|
|
149
|
+
export function layoutRow(width, verb, item, outcome) {
|
|
150
|
+
const verbCol = verb.padEnd(VERB_WIDTH);
|
|
151
|
+
// glyph + space + verb column + space
|
|
152
|
+
const prefixWidth = 2 + VERB_WIDTH + 1;
|
|
153
|
+
const outcomeWidth = visibleWidth(outcome);
|
|
154
|
+
const minGap = 2;
|
|
155
|
+
let shownOutcome = outcome;
|
|
156
|
+
let itemRoom = width - prefixWidth - (outcomeWidth ? outcomeWidth + minGap : 0);
|
|
157
|
+
if (itemRoom < 12 && outcomeWidth) {
|
|
158
|
+
shownOutcome = "";
|
|
159
|
+
itemRoom = width - prefixWidth;
|
|
31
160
|
}
|
|
32
|
-
|
|
33
|
-
|
|
161
|
+
const shownItem = truncateToWidth(item, Math.max(0, itemRoom), "…");
|
|
162
|
+
const used = prefixWidth + visibleWidth(shownItem) + visibleWidth(shownOutcome);
|
|
163
|
+
const gap = shownOutcome ? Math.max(minGap, width - used) : 0;
|
|
164
|
+
return { verb: verbCol, item: shownItem, gap, outcome: shownOutcome };
|
|
34
165
|
}
|