pi-minimal 0.1.0 → 0.2.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.
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/publish.yml +178 -14
- package/.github/workflows/release.yml +42 -0
- package/CHANGELOG.md +63 -0
- package/README.md +26 -1
- package/SECURITY.md +21 -0
- package/dist/activity.js +0 -3
- package/dist/cwd.js +92 -52
- package/dist/footer.js +26 -27
- package/dist/git-state.js +63 -0
- package/dist/index.js +48 -6
- package/dist/tool-rows.js +143 -12
- package/dist/tools.js +143 -77
- package/dist/transcript.js +50 -0
- package/dist/turn-rule.js +119 -0
- package/package.json +14 -3
- package/release-notes.md +30 -0
- package/src/activity.ts +0 -4
- package/src/cwd.ts +99 -51
- package/src/footer.ts +32 -32
- package/src/git-state.ts +71 -0
- package/src/index.ts +50 -6
- package/src/tool-rows.ts +170 -19
- package/src/tools.ts +156 -106
- package/src/transcript.ts +51 -0
- package/src/turn-rule.ts +135 -0
- package/test/activity.test.ts +4 -15
- 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
|
@@ -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,17 @@ 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";
|
|
11
14
|
export default function piMinimalExtension(pi) {
|
|
12
15
|
const stopWorkingStatus = installWorkingStatus(pi);
|
|
16
|
+
const stopTurnRule = installTurnRule(pi);
|
|
17
|
+
installToolRenderers(pi);
|
|
18
|
+
installTranscript(pi);
|
|
13
19
|
let fastTimer;
|
|
14
20
|
let slowTimer;
|
|
15
21
|
let stealTimer;
|
|
@@ -17,6 +23,9 @@ export default function piMinimalExtension(pi) {
|
|
|
17
23
|
let siblings;
|
|
18
24
|
let generation = 0;
|
|
19
25
|
let hideWidgets = true;
|
|
26
|
+
let busy = false;
|
|
27
|
+
let currentCwd = process.cwd();
|
|
28
|
+
const gitState = createGitState(pi, () => currentCwd);
|
|
20
29
|
const stopTimers = () => {
|
|
21
30
|
if (fastTimer)
|
|
22
31
|
clearInterval(fastTimer);
|
|
@@ -103,16 +112,37 @@ export default function piMinimalExtension(pi) {
|
|
|
103
112
|
},
|
|
104
113
|
chrome: () => {
|
|
105
114
|
installHeader(ctx);
|
|
106
|
-
|
|
115
|
+
installChromeFooter(ctx);
|
|
107
116
|
},
|
|
108
117
|
});
|
|
109
118
|
};
|
|
119
|
+
const contextPct = (ctx) => {
|
|
120
|
+
const usage = ctx.getContextUsage?.();
|
|
121
|
+
const window = usage?.contextWindow || ctx.model?.contextWindow;
|
|
122
|
+
if (typeof usage?.tokens !== "number" || !window)
|
|
123
|
+
return undefined;
|
|
124
|
+
return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)));
|
|
125
|
+
};
|
|
126
|
+
const installChromeFooter = (ctx) => {
|
|
127
|
+
installFooter(ctx, {
|
|
128
|
+
busy: () => busy,
|
|
129
|
+
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
130
|
+
contextPct: () => contextPct(ctx),
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const installStatusLine = (ctx) => {
|
|
134
|
+
installCwd(ctx, {
|
|
135
|
+
gitState: gitState.get,
|
|
136
|
+
siblings: () => siblings,
|
|
137
|
+
onGitChange: gitState.onChange,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
110
140
|
const pinChrome = (ctx, reason) => {
|
|
111
141
|
hideForeignChrome(ctx, reason);
|
|
112
142
|
if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome)
|
|
113
143
|
return;
|
|
114
144
|
ctx.ui.setWidget("tw-cwd", undefined);
|
|
115
|
-
|
|
145
|
+
installStatusLine(ctx);
|
|
116
146
|
};
|
|
117
147
|
pi.on("session_start", async (_event, ctx) => {
|
|
118
148
|
if (ctx.mode !== "tui")
|
|
@@ -123,10 +153,11 @@ export default function piMinimalExtension(pi) {
|
|
|
123
153
|
projectTrusted: ctx.isProjectTrusted(),
|
|
124
154
|
});
|
|
125
155
|
hideWidgets = config.hideGauntletWidgets;
|
|
156
|
+
currentCwd = ctx.cwd;
|
|
126
157
|
installHeader(ctx);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
installChromeFooter(ctx);
|
|
159
|
+
installStatusLine(ctx);
|
|
160
|
+
void gitState.refresh(true);
|
|
130
161
|
pinChrome(ctx, "session_start");
|
|
131
162
|
ctx.ui.setHiddenThinkingLabel("◆ Thought");
|
|
132
163
|
ctx.ui.setWorkingIndicator();
|
|
@@ -139,15 +170,26 @@ export default function piMinimalExtension(pi) {
|
|
|
139
170
|
await probe(ctx);
|
|
140
171
|
}
|
|
141
172
|
});
|
|
173
|
+
pi.on("agent_start", () => {
|
|
174
|
+
busy = true;
|
|
175
|
+
});
|
|
176
|
+
pi.on("agent_end", () => {
|
|
177
|
+
busy = false;
|
|
178
|
+
void gitState.refresh();
|
|
179
|
+
});
|
|
142
180
|
pi.on("turn_end", (_event, ctx) => {
|
|
143
181
|
pinChrome(ctx, "turn_end");
|
|
182
|
+
void gitState.refresh();
|
|
144
183
|
});
|
|
145
184
|
pi.on("tool_execution_end", (_event, ctx) => {
|
|
146
185
|
pinChrome(ctx, "tool_execution_end");
|
|
186
|
+
void gitState.refresh();
|
|
147
187
|
});
|
|
148
188
|
pi.on("session_shutdown", async () => {
|
|
189
|
+
busy = false;
|
|
149
190
|
stopTimers();
|
|
150
191
|
stopWorkingStatus();
|
|
192
|
+
stopTurnRule();
|
|
151
193
|
stopLiveWatch();
|
|
152
194
|
generation += 1;
|
|
153
195
|
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
|
}
|
package/dist/tools.js
CHANGED
|
@@ -1,103 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { activityBullet, rowIsRunning } from "./activity.js";
|
|
1
|
+
import { Container, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { rowIsRunning } from "./activity.js";
|
|
4
3
|
import { createLiveWatch } from "./live-watch.js";
|
|
5
|
-
import {
|
|
4
|
+
import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
|
|
6
5
|
const live = createLiveWatch(400);
|
|
6
|
+
/** Left indent of every tool row, in cells. */
|
|
7
|
+
const ROW_INDENT = 2;
|
|
8
|
+
/** Max lines of output shown when a row is expanded. */
|
|
9
|
+
const EXPANDED_MAX_LINES = 400;
|
|
7
10
|
export function stopLiveWatch() {
|
|
8
11
|
live.stop();
|
|
9
12
|
}
|
|
10
13
|
function liveId(context, item) {
|
|
11
14
|
return context.toolCallId || item;
|
|
12
15
|
}
|
|
13
|
-
function
|
|
14
|
-
if (
|
|
15
|
-
return "";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.join("\n");
|
|
16
|
+
function rowStatus(context) {
|
|
17
|
+
if (context.isError)
|
|
18
|
+
return "error";
|
|
19
|
+
if (context.state.outcome?.errorLine)
|
|
20
|
+
return "error";
|
|
21
|
+
return rowIsRunning(context) ? "running" : "ok";
|
|
20
22
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
+
function statusColor(status) {
|
|
24
|
+
if (status === "error")
|
|
25
|
+
return "error";
|
|
26
|
+
if (status === "running")
|
|
27
|
+
return "warning";
|
|
28
|
+
return "success";
|
|
23
29
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
function expandedLines(kind, result) {
|
|
31
|
+
let text = resultText(result);
|
|
32
|
+
if (kind === "bash")
|
|
33
|
+
text = bashBody(text).body;
|
|
34
|
+
if (!text)
|
|
35
|
+
return [];
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
if (lines.length > EXPANDED_MAX_LINES) {
|
|
38
|
+
const hidden = lines.length - EXPANDED_MAX_LINES;
|
|
39
|
+
return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`];
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
function outputBlock(kind, result, theme) {
|
|
44
|
+
const lines = expandedLines(kind, result);
|
|
45
|
+
if (lines.length === 0)
|
|
46
|
+
return new Container();
|
|
47
|
+
const indent = " ".repeat(ROW_INDENT + 2);
|
|
48
|
+
const rule = theme.fg("borderMuted", "│");
|
|
49
|
+
return {
|
|
50
|
+
render(width) {
|
|
51
|
+
const inner = Math.max(0, width - indent.length - 2);
|
|
52
|
+
return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`);
|
|
53
|
+
},
|
|
54
|
+
invalidate() { },
|
|
55
|
+
};
|
|
29
56
|
}
|
|
30
|
-
function
|
|
57
|
+
function errorLineBlock(line, theme) {
|
|
58
|
+
if (!line)
|
|
59
|
+
return undefined;
|
|
60
|
+
const indent = " ".repeat(ROW_INDENT + 2);
|
|
61
|
+
const rule = theme.fg("error", "│");
|
|
62
|
+
return {
|
|
63
|
+
render(width) {
|
|
64
|
+
const inner = Math.max(0, width - indent.length - 2);
|
|
65
|
+
return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`];
|
|
66
|
+
},
|
|
67
|
+
invalidate() { },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function activityRow(kind, args, theme, context) {
|
|
31
71
|
const item = toolItem(kind, args);
|
|
32
72
|
const id = liveId(context, item);
|
|
33
|
-
const row = nextGroupedRow(kind, item, id);
|
|
34
73
|
const running = rowIsRunning(context);
|
|
35
74
|
context.state.running = running;
|
|
36
|
-
if (running)
|
|
75
|
+
if (running) {
|
|
76
|
+
context.state.startedAt ??= Date.now();
|
|
37
77
|
live.watch(id, context.invalidate);
|
|
38
|
-
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
39
80
|
live.unwatch(id);
|
|
40
|
-
|
|
81
|
+
}
|
|
41
82
|
return {
|
|
42
83
|
render(width) {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
84
|
+
const status = rowStatus(context);
|
|
85
|
+
const blink = status === "running" && live.has(id);
|
|
86
|
+
const glyph = rowGlyph(status, blink ? live.frame : 0);
|
|
87
|
+
const outcome = context.state.outcome;
|
|
88
|
+
const diff = status !== "running" ? outcome?.diff : undefined;
|
|
89
|
+
let right = "";
|
|
90
|
+
if (status === "running") {
|
|
91
|
+
const started = context.state.startedAt;
|
|
92
|
+
right = started ? formatDuration(Date.now() - started) : "";
|
|
50
93
|
}
|
|
51
|
-
|
|
52
|
-
|
|
94
|
+
else if (outcome) {
|
|
95
|
+
right = outcome.text;
|
|
96
|
+
if (diff) {
|
|
97
|
+
const stats = `+${diff.added} −${diff.removed}`;
|
|
98
|
+
right = right ? `${stats} · ${right}` : stats;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const inner = Math.max(8, width - ROW_INDENT);
|
|
102
|
+
const row = layoutRow(inner, kind, item, right);
|
|
103
|
+
let coloredRight;
|
|
104
|
+
if (status === "running")
|
|
105
|
+
coloredRight = theme.fg("warning", row.outcome);
|
|
106
|
+
else if (status === "error")
|
|
107
|
+
coloredRight = theme.fg("error", row.outcome);
|
|
108
|
+
else if (diff && row.outcome) {
|
|
109
|
+
const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length);
|
|
110
|
+
coloredRight =
|
|
111
|
+
theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest);
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
coloredRight = theme.fg("dim", row.outcome);
|
|
115
|
+
const line = " ".repeat(ROW_INDENT) +
|
|
116
|
+
theme.fg(statusColor(status), glyph) +
|
|
117
|
+
" " +
|
|
118
|
+
theme.fg("dim", row.verb) +
|
|
119
|
+
" " +
|
|
120
|
+
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
121
|
+
" ".repeat(row.gap) +
|
|
122
|
+
coloredRight;
|
|
123
|
+
return [line];
|
|
53
124
|
},
|
|
54
125
|
invalidate() { },
|
|
55
126
|
};
|
|
56
127
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
pi.registerTool({
|
|
61
|
-
...tool,
|
|
62
|
-
renderShell: "self",
|
|
63
|
-
renderCall(args, theme, context) {
|
|
64
|
-
return activityBlock(kind, args, theme, context);
|
|
65
|
-
},
|
|
66
|
-
renderResult(result, options, _theme, context) {
|
|
67
|
-
const renderContext = context;
|
|
68
|
-
if (!renderContext.isPartial) {
|
|
69
|
-
renderContext.state.done = true;
|
|
70
|
-
renderContext.state.running = false;
|
|
71
|
-
live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
|
|
72
|
-
}
|
|
73
|
-
return expandedContent(result, options);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
// A missing built-in tool leaves the rest of the compact renderers available.
|
|
79
|
-
}
|
|
128
|
+
const TOOL_KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
|
|
129
|
+
export function isToolKind(name) {
|
|
130
|
+
return TOOL_KINDS.has(name);
|
|
80
131
|
}
|
|
81
|
-
export function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
132
|
+
export function compactRenderers(kind) {
|
|
133
|
+
return {
|
|
134
|
+
renderShell: "self",
|
|
135
|
+
renderCall(args, theme, context) {
|
|
136
|
+
return activityRow(kind, args, theme, context);
|
|
137
|
+
},
|
|
138
|
+
renderResult(result, options, theme, context) {
|
|
139
|
+
const renderContext = context;
|
|
140
|
+
const typedResult = result;
|
|
141
|
+
if (!renderContext.isPartial) {
|
|
142
|
+
renderContext.state.done = true;
|
|
143
|
+
renderContext.state.running = false;
|
|
144
|
+
renderContext.state.outcome = formatOutcome(kind, renderContext.args, typedResult, Boolean(renderContext.isError), renderContext.durationMs);
|
|
145
|
+
live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
|
|
146
|
+
}
|
|
147
|
+
const container = new Container();
|
|
148
|
+
const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme);
|
|
149
|
+
if (errorLine && !options.expanded)
|
|
150
|
+
container.addChild(errorLine);
|
|
151
|
+
if (options.expanded)
|
|
152
|
+
container.addChild(outputBlock(kind, typedResult, theme));
|
|
153
|
+
return container;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
159
|
+
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
160
|
+
*/
|
|
161
|
+
export function installToolRenderers(pi) {
|
|
162
|
+
if (typeof pi.registerToolRenderer !== "function")
|
|
163
|
+
return;
|
|
164
|
+
pi.registerToolRenderer((toolName, next) => {
|
|
165
|
+
if (!isToolKind(toolName))
|
|
166
|
+
return next();
|
|
167
|
+
return compactRenderers(toolName);
|
|
168
|
+
});
|
|
103
169
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const THINKING_GUTTER = "┊ ";
|
|
2
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
3
|
+
const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/;
|
|
4
|
+
/**
|
|
5
|
+
* Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
|
|
6
|
+
* Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
|
|
7
|
+
* they keep parsing, and nothing inside a fenced code block is touched.
|
|
8
|
+
*/
|
|
9
|
+
export function gutterThinking(markdown) {
|
|
10
|
+
if (!markdown)
|
|
11
|
+
return markdown;
|
|
12
|
+
const lines = markdown.split("\n");
|
|
13
|
+
const out = [];
|
|
14
|
+
let inFence = false;
|
|
15
|
+
let atParagraphStart = true;
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
if (FENCE.test(line)) {
|
|
18
|
+
inFence = !inFence;
|
|
19
|
+
out.push(line);
|
|
20
|
+
atParagraphStart = !inFence;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (inFence) {
|
|
24
|
+
out.push(line);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (line.trim() === "") {
|
|
28
|
+
out.push(line);
|
|
29
|
+
atParagraphStart = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
|
|
33
|
+
out.push(`${THINKING_GUTTER}${line}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out.push(line);
|
|
37
|
+
}
|
|
38
|
+
atParagraphStart = false;
|
|
39
|
+
}
|
|
40
|
+
return out.join("\n");
|
|
41
|
+
}
|
|
42
|
+
export function installTranscript(pi) {
|
|
43
|
+
if (typeof pi.registerMarkdownTransformer !== "function")
|
|
44
|
+
return;
|
|
45
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
46
|
+
if (context.messageType !== "assistant-thinking")
|
|
47
|
+
return markdown;
|
|
48
|
+
return gutterThinking(markdown);
|
|
49
|
+
});
|
|
50
|
+
}
|