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.
@@ -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 { stopLiveWatch, wrapBuiltinTools } from "./tools.js";
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
- installFooter(ctx, () => siblings);
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
- installCwd(ctx);
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
- installFooter(ctx, () => siblings);
128
- installCwd(ctx);
129
- wrapBuiltinTools(pi, ctx);
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
- let activityGroup;
2
- export function resetActivityGroup() {
3
- activityGroup = undefined;
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 nextGroupedRow(verb, item, id) {
26
- if (activityGroup && activityGroup.verb === verb) {
27
- activityGroup.seen.add(id);
28
- if (id === activityGroup.leadId)
29
- return { header: verb, item };
30
- return { header: undefined, item };
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
- activityGroup = { verb, leadId: id, seen: new Set([id]) };
33
- return { header: verb, item };
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 { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
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 { nextGroupedRow, toolItem } from "./tool-rows.js";
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 contentText(result) {
14
- if (!result?.content)
15
- return "";
16
- return result.content
17
- .filter((part) => part.type === "text" && typeof part.text === "string")
18
- .map((part) => part.text)
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 collapsedResult() {
22
- return new Container();
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 expandedContent(result, options) {
25
- if (!options.expanded)
26
- return collapsedResult();
27
- const text = contentText(result);
28
- return text ? new Text(text, 0, 0) : collapsedResult();
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 activityBlock(kind, args, theme, context) {
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
- else
78
+ }
79
+ else {
39
80
  live.unwatch(id);
40
- const color = context.isError ? "error" : running ? "warning" : "dim";
81
+ }
41
82
  return {
42
83
  render(width) {
43
- const pad = 1;
44
- const inner = Math.max(0, width - pad);
45
- const lines = [];
46
- if (row.header) {
47
- const blink = Boolean(context.state.running) && live.has(id);
48
- const bullet = activityBullet(blink, live.frame);
49
- lines.push(" ".repeat(pad) + theme.fg(color, truncateToWidth(`${bullet} ${row.header}`, inner)));
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
- lines.push(" ".repeat(pad) + " " + theme.fg(color, truncateToWidth(row.item, Math.max(0, inner - 2))));
52
- return lines;
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
- function wrapOne(pi, kind, factory) {
58
- try {
59
- const tool = factory();
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 wrapBuiltinTools(pi, ctx) {
82
- let commandPrefix;
83
- let shellPath;
84
- let autoResizeImages;
85
- try {
86
- const settings = SettingsManager.create(ctx.cwd, undefined, {
87
- projectTrusted: ctx.isProjectTrusted(),
88
- });
89
- commandPrefix = settings.getShellCommandPrefix();
90
- shellPath = settings.getShellPath();
91
- autoResizeImages = settings.getImageAutoResize();
92
- }
93
- catch {
94
- // Tool wrapping still works when optional shell or image settings are unavailable.
95
- }
96
- wrapOne(pi, "bash", () => createBashToolDefinition(ctx.cwd, { commandPrefix, shellPath }));
97
- wrapOne(pi, "read", () => createReadToolDefinition(ctx.cwd, { autoResizeImages }));
98
- wrapOne(pi, "edit", () => createEditToolDefinition(ctx.cwd));
99
- wrapOne(pi, "write", () => createWriteToolDefinition(ctx.cwd));
100
- wrapOne(pi, "grep", () => createGrepToolDefinition(ctx.cwd));
101
- wrapOne(pi, "find", () => createFindToolDefinition(ctx.cwd));
102
- wrapOne(pi, "ls", () => createLsToolDefinition(ctx.cwd));
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
+ }