pi-minimal 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -10,6 +10,42 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.0] - 2026-10-27
14
+
15
+ ### Added
16
+
17
+ - `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
18
+ change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
19
+ block when the row is expanded with `ctrl+o`, where the bare "Successfully
20
+ replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
21
+ capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
22
+ the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
23
+ `toolDiffContext` colours and keep pi's line-number gutter; when one line
24
+ replaces one line, only the changed tokens are inverse-highlighted, as in pi's
25
+ own edit renderer. Failed edits and results without a diff are unchanged.
26
+
27
+ ## [0.3.1] - 2026-10-25
28
+
29
+ ### Added
30
+ - Compact rows for tools that bring no renderer of their own, such as most MCP tools and
31
+ `subagents_enable`: `✓ mcp re_mcp · query_prometheus query=… step=1m 12 lines · 1.4s`.
32
+ The verb is `mcp`, `agent` (subagent tools) or `tool`; up to three scalar arguments show as
33
+ `key=value`; a short single-line result shows verbatim, otherwise a line count. Failures show
34
+ their first line underneath; `ctrl+o` shows the full result. Tools that draw themselves
35
+ (the `subagent` live view, pi-gauntlet's trackers) keep their own renderer.
36
+
37
+ ### Changed
38
+ - Bash rows drop `cd <session cwd> && ` and shorten other `cd <dir> && ` prefixes to the
39
+ directory name (`pi-subagents › sed -n …`), so the part that differs between rows is visible
40
+ and the outcome column stays put. Multi-line commands such as heredocs say how many lines follow
41
+ (`python3 - <<'EOF' · +4 lines`).
42
+ - The thinking gutter `┊` now runs down every line of a thinking paragraph, including lines the
43
+ terminal wraps: paragraphs are pre-wrapped to the content width the host renders at (re-done on
44
+ resize). Previously only the first line of each paragraph had it, so wrapped thinking sat flush
45
+ with prose. Inline code stays whole; lists, quotes and fenced code are untouched.
46
+ - The task tracker line is a segmented bar coloured by status (`Tasks ▰▰▰▰▱ 4/5`) instead of a
47
+ run of check marks; past 12 tasks it scales to 10 segments.
48
+
13
49
  ## [0.3.0] - 2026-10-25
14
50
 
15
51
  ### Added
package/README.md CHANGED
@@ -30,9 +30,9 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
30
30
 
31
31
  ## What it changes
32
32
 
33
- - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output.
33
+ - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output. Tools without a renderer of their own (most MCP tools) get the same one-line row; tools that draw themselves keep their look. Bash rows drop a leading `cd <session cwd> &&` and shorten other `cd` prefixes to the directory name.
34
34
  - **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
35
- - **Thinking gutter** – thinking paragraphs are prefixed with `┊` so they read as secondary.
35
+ - **Thinking gutter** – every line of a thinking paragraph, wrapped lines included, starts with `┊`, so thinking reads as a column beside prose.
36
36
  - **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr siblings.
37
37
  - **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
38
38
  - **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
@@ -49,10 +49,13 @@ The package loads JSON in this order:
49
49
 
50
50
  Project values override global values. A missing file, omitted key, or invalid value does not override the lower-precedence value.
51
51
 
52
- The only supported key is `hideGauntletWidgets`. It defaults to `true`.
52
+ Supported keys:
53
+
54
+ - `hideGauntletWidgets` (boolean, default `true`).
55
+ - `editDiff` (`"always"` | `"never"` | `"expanded"`, default `"expanded"`) – when an `edit` row shows its diff. `"expanded"` puts it behind `ctrl+o`; `"always"` shows it inline as the edit lands (first 40 lines, the rest behind `ctrl+o`); `"never"` keeps the `+3 −1` stats only.
53
56
 
54
57
  ```json
55
- { "hideGauntletWidgets": true }
58
+ { "hideGauntletWidgets": true, "editDiff": "always" }
56
59
  ```
57
60
 
58
61
  ## Optional footer activity API
package/dist/config.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
+ export const EDIT_DIFF_MODES = ["always", "never", "expanded"];
4
5
  const DEFAULTS = {
5
6
  hideGauntletWidgets: true,
7
+ editDiff: "expanded",
6
8
  };
7
9
  export function loadPiMinimalConfig(options) {
8
10
  const config = { ...DEFAULTS };
@@ -32,14 +34,23 @@ function applyLayer(config, path) {
32
34
  console.error(`pi-minimal: expected an object in ${path}`);
33
35
  return;
34
36
  }
37
+ // Each key is validated on its own so one bad value does not discard the rest of the layer.
35
38
  const record = parsed;
36
- if (!Object.hasOwn(record, "hideGauntletWidgets")) {
37
- return;
38
- }
39
- const value = record.hideGauntletWidgets;
40
- if (typeof value !== "boolean") {
41
- console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`);
42
- return;
39
+ if (Object.hasOwn(record, "hideGauntletWidgets")) {
40
+ const value = record.hideGauntletWidgets;
41
+ if (typeof value === "boolean")
42
+ config.hideGauntletWidgets = value;
43
+ else
44
+ console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`);
45
+ }
46
+ if (Object.hasOwn(record, "editDiff")) {
47
+ const value = record.editDiff;
48
+ if (isEditDiffMode(value))
49
+ config.editDiff = value;
50
+ else
51
+ console.error(`pi-minimal: editDiff must be one of ${EDIT_DIFF_MODES.map((mode) => `"${mode}"`).join(", ")} in ${path}`);
43
52
  }
44
- config.hideGauntletWidgets = value;
53
+ }
54
+ function isEditDiffMode(value) {
55
+ return typeof value === "string" && EDIT_DIFF_MODES.includes(value);
45
56
  }
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import { bindFooterContributionEvents } from "./footer-contribution-events.js";
7
7
  import { installCwd } from "./cwd.js";
8
8
  import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
9
9
  import { hideGauntletWidgets } from "./tracker.js";
10
- import { installToolRenderers, stopLiveWatch } from "./tools.js";
10
+ import { installToolRenderers, setEditDiffMode, stopLiveWatch } from "./tools.js";
11
+ import { setSessionCwd } from "./tool-rows.js";
11
12
  import { applyReclaim, reclaimPolicy } from "./reclaim.js";
12
13
  import { installWorkingStatus } from "./working-status.js";
13
14
  import { installTurnRule } from "./turn-rule.js";
@@ -169,7 +170,9 @@ export default function piMinimalExtension(pi) {
169
170
  projectTrusted: ctx.isProjectTrusted(),
170
171
  });
171
172
  hideWidgets = config.hideGauntletWidgets;
173
+ setEditDiffMode(config.editDiff);
172
174
  currentCwd = ctx.cwd;
175
+ setSessionCwd(ctx.cwd);
173
176
  try {
174
177
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
175
178
  setChromeMargin(settings.getOutputPad());
package/dist/tool-rows.js CHANGED
@@ -8,9 +8,42 @@ export function shortName(path) {
8
8
  return path || "";
9
9
  return parts.slice(-2).join("/");
10
10
  }
11
+ let sessionCwd;
12
+ let homeDir = process.env.HOME;
13
+ /** The session cwd, so `cd <cwd> && \u2026` can be dropped from bash rows. */
14
+ export function setSessionCwd(cwd, home = process.env.HOME) {
15
+ sessionCwd = cwd;
16
+ homeDir = home;
17
+ }
18
+ const CD_PREFIX = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*/;
19
+ function expandHome(path) {
20
+ const unquoted = path.replace(/^(["'])(.*)\1$/, "$2").replace(/\/+$/, "");
21
+ if (homeDir && (unquoted === "~" || unquoted.startsWith("~/")))
22
+ return homeDir + unquoted.slice(1);
23
+ return unquoted;
24
+ }
25
+ /**
26
+ * The item column of a bash row. Long agent commands usually start with `cd <repo> && `, which
27
+ * pushes the part that differs between rows off the screen: drop it when it is the session cwd,
28
+ * otherwise shorten it to the directory name (`pi-subagents \u203a sed -n \u2026`). Multi-line
29
+ * commands (heredocs, scripts) say how many lines follow instead of ending at `<<'EOF'`.
30
+ */
31
+ export function bashItem(command) {
32
+ const lines = command.replace(/\r/g, "").trim().split("\n");
33
+ let first = lines[0]?.trim() ?? "";
34
+ const cd = CD_PREFIX.exec(first);
35
+ if (cd && first.length > cd[0].length) {
36
+ const target = expandHome(cd[1]);
37
+ const rest = first.slice(cd[0].length);
38
+ const cwd = sessionCwd?.replace(/\/+$/, "");
39
+ first = cwd && target === cwd ? rest : `${target.split("/").filter(Boolean).at(-1) ?? target} \u203a ${rest}`;
40
+ }
41
+ const more = lines.slice(1).filter((line) => line.trim()).length;
42
+ return more > 0 ? `${first} \u00b7 +${more} line${more === 1 ? "" : "s"}` : first;
43
+ }
11
44
  export function toolItem(kind, args) {
12
45
  if (kind === "bash") {
13
- const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : "";
46
+ const command = typeof args?.command === "string" ? bashItem(args.command) : "";
14
47
  return command || "bash";
15
48
  }
16
49
  if (kind === "grep" || kind === "find") {
package/dist/tools.js CHANGED
@@ -2,6 +2,7 @@ import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui
2
2
  import { rowIsRunning } from "./activity.js";
3
3
  import * as chrome from "./chrome.js";
4
4
  import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput } from "./codemode-rows.js";
5
+ import { diffWords } from "diff";
5
6
  import { createLiveWatch } from "./live-watch.js";
6
7
  import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
7
8
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
@@ -11,6 +12,13 @@ const live = createLiveWatch(400);
11
12
  const rowIndent = () => margin() + 1;
12
13
  /** Max lines of output shown when a row is expanded. */
13
14
  const EXPANDED_MAX_LINES = 400;
15
+ /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
16
+ const INLINE_DIFF_MAX_LINES = 40;
17
+ let editDiffMode = "expanded";
18
+ /** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
19
+ export function setEditDiffMode(mode) {
20
+ editDiffMode = mode;
21
+ }
14
22
  export function stopLiveWatch() {
15
23
  live.stop();
16
24
  }
@@ -58,6 +66,97 @@ function outputBlock(kind, result, theme) {
58
66
  invalidate() { },
59
67
  };
60
68
  }
69
+ function editDiffText(result) {
70
+ const details = result?.details;
71
+ return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined;
72
+ }
73
+ /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
74
+ function parseDiffLine(line) {
75
+ const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line);
76
+ if (!match)
77
+ return undefined;
78
+ return { prefix: match[1], lineNum: match[2], content: match[3].replace(/\t/g, " ") };
79
+ }
80
+ /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
81
+ function highlightPair(removed, added, theme) {
82
+ let out = { removed: "", added: "" };
83
+ let firstRemoved = true;
84
+ let firstAdded = true;
85
+ for (const part of diffWords(removed, added)) {
86
+ if (part.removed || part.added) {
87
+ const key = part.removed ? "removed" : "added";
88
+ let value = part.value;
89
+ if (part.removed ? firstRemoved : firstAdded) {
90
+ const ws = /^(\s*)/.exec(value)?.[1] ?? "";
91
+ value = value.slice(ws.length);
92
+ out = { ...out, [key]: out[key] + ws };
93
+ if (part.removed)
94
+ firstRemoved = false;
95
+ else
96
+ firstAdded = false;
97
+ }
98
+ if (value)
99
+ out = { ...out, [key]: out[key] + theme.inverse(value) };
100
+ }
101
+ else {
102
+ out = { removed: out.removed + part.value, added: out.added + part.value };
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
108
+ function colorDiffLines(lines, theme) {
109
+ const out = [];
110
+ for (let i = 0; i < lines.length;) {
111
+ const parsed = parseDiffLine(lines[i]);
112
+ if (!parsed) {
113
+ out.push(theme.fg("toolDiffContext", lines[i]));
114
+ i++;
115
+ continue;
116
+ }
117
+ if (parsed.prefix === " ") {
118
+ out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`));
119
+ i++;
120
+ continue;
121
+ }
122
+ const removed = [];
123
+ const added = [];
124
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? ""))
125
+ removed.push(p);
126
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? ""))
127
+ added.push(p);
128
+ if (removed.length === 1 && added.length === 1) {
129
+ const pair = highlightPair(removed[0].content, added[0].content, theme);
130
+ out.push(theme.fg("toolDiffRemoved", `-${removed[0].lineNum} ${pair.removed}`));
131
+ out.push(theme.fg("toolDiffAdded", `+${added[0].lineNum} ${pair.added}`));
132
+ continue;
133
+ }
134
+ for (const line of removed)
135
+ out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`));
136
+ for (const line of added)
137
+ out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`));
138
+ }
139
+ return out;
140
+ }
141
+ /** The edit's diff in the `│`-ruled block. Inline views are capped; expanded views show up to the usual limit. */
142
+ function diffBlock(diff, theme, expanded) {
143
+ const all = diff.replace(/\n$/, "").split("\n");
144
+ const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES;
145
+ const lines = colorDiffLines(all.length > max ? all.slice(0, max) : all, theme);
146
+ const hidden = all.length - lines.length;
147
+ const indent = " ".repeat(rowIndent() + 2);
148
+ const rule = theme.fg("borderMuted", "│");
149
+ return {
150
+ render(width) {
151
+ const inner = Math.max(0, width - indent.length - 2);
152
+ const out = lines.map((line) => `${indent}${rule} ${truncateToWidth(line, inner)}`);
153
+ if (hidden > 0)
154
+ out.push(`${indent}${rule} ${theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`)}`);
155
+ return out;
156
+ },
157
+ invalidate() { },
158
+ };
159
+ }
61
160
  function errorLineBlock(line, theme) {
62
161
  if (!line)
63
162
  return undefined;
@@ -149,15 +248,136 @@ export function compactRenderers(kind) {
149
248
  live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
150
249
  }
151
250
  const container = new Container();
251
+ const expanded = Boolean(options.expanded);
152
252
  const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme);
153
- if (errorLine && !options.expanded)
253
+ if (errorLine && !expanded)
154
254
  container.addChild(errorLine);
155
- if (options.expanded)
255
+ const diff = kind === "edit" && !renderContext.isError && !renderContext.isPartial ? editDiffText(typedResult) : undefined;
256
+ const showDiff = diff !== undefined && (editDiffMode === "always" || (editDiffMode === "expanded" && expanded));
257
+ if (showDiff)
258
+ container.addChild(diffBlock(diff, theme, expanded));
259
+ else if (expanded)
156
260
  container.addChild(outputBlock(kind, typedResult, theme));
157
261
  return container;
158
262
  },
159
263
  };
160
264
  }
265
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
266
+ function rowLine(theme, width, status, frame, verb, item, right) {
267
+ const inner = Math.max(8, width - rowIndent());
268
+ const row = layoutRow(inner, verb, item, right);
269
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
270
+ return (" ".repeat(rowIndent()) +
271
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
272
+ " " +
273
+ theme.fg("dim", row.verb) +
274
+ " " +
275
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
276
+ " ".repeat(row.gap) +
277
+ theme.fg(rightColor, row.outcome));
278
+ }
279
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
280
+ const OUTCOME_TEXT_MAX = 24;
281
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
282
+ export function genericVerb(toolName) {
283
+ if (toolName.startsWith("mcp__"))
284
+ return "mcp";
285
+ if (/subagent/.test(toolName))
286
+ return "agent";
287
+ return "tool";
288
+ }
289
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
290
+ export function genericItem(toolName, args) {
291
+ let json;
292
+ try {
293
+ json = JSON.stringify(args ?? {}) ?? "";
294
+ }
295
+ catch {
296
+ json = "";
297
+ }
298
+ const params = nestedItem({ name: toolName, args: json });
299
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
300
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName;
301
+ return params ? `${label} ${params}` : label;
302
+ }
303
+ export function genericOutcome(result, isError, durationMs) {
304
+ const text = resultText(result);
305
+ const dur = formatDuration(durationMs);
306
+ const join = (...parts) => parts.filter(Boolean).join(" \u00b7 ");
307
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
308
+ if (isError)
309
+ return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" };
310
+ if (lines.length === 0)
311
+ return { text: join("ok", dur) };
312
+ const first = lines[0].trim();
313
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX)
314
+ return { text: join(first, dur) };
315
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) };
316
+ }
317
+ /**
318
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
319
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
320
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
321
+ */
322
+ export function genericRenderers(toolName) {
323
+ const verb = genericVerb(toolName);
324
+ return {
325
+ renderShell: "self",
326
+ renderCall(args, theme, rawContext) {
327
+ const context = rawContext;
328
+ const id = context.toolCallId || toolName;
329
+ const item = genericItem(toolName, args);
330
+ const running = rowIsRunning(context);
331
+ context.state.running = running;
332
+ if (running) {
333
+ context.state.startedAt ??= Date.now();
334
+ live.watch(id, context.invalidate);
335
+ }
336
+ else
337
+ live.unwatch(id);
338
+ return {
339
+ render(width) {
340
+ const status = rowStatus(context);
341
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
342
+ const right = status === "running"
343
+ ? context.state.startedAt
344
+ ? formatDuration(Date.now() - context.state.startedAt)
345
+ : ""
346
+ : (context.state.outcome?.text ?? "");
347
+ return [rowLine(theme, width, status, frame, verb, item, right)];
348
+ },
349
+ invalidate() { },
350
+ };
351
+ },
352
+ renderResult(result, options, theme, rawContext) {
353
+ const context = rawContext;
354
+ const typed = result;
355
+ if (!context.isPartial) {
356
+ context.state.done = true;
357
+ context.state.running = false;
358
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs);
359
+ live.unwatch(context.toolCallId || toolName);
360
+ }
361
+ const container = new Container();
362
+ const expanded = Boolean(options.expanded);
363
+ const errorLine = context.state.outcome?.errorLine;
364
+ if (errorLine && !expanded)
365
+ container.addChild(errorLineBlock(errorLine, theme));
366
+ if (expanded) {
367
+ const text = resultText(typed);
368
+ if (text) {
369
+ const indent = " ".repeat(rowIndent() + 2);
370
+ container.addChild({ render: (width) => ruledLines(text, width, theme, indent), invalidate() { } });
371
+ }
372
+ }
373
+ return container;
374
+ },
375
+ };
376
+ }
377
+ /** True when a tool definition draws its own call or result. */
378
+ function hasOwnRenderer(definition) {
379
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function";
380
+ }
161
381
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
162
382
  const NESTED_PREVIEW = 8;
163
383
  /** Width of the nested tool-name column. */
@@ -209,19 +429,7 @@ export function codemodeRenderers() {
209
429
  const frame = status === "running" && live.has(id) ? live.frame : 0;
210
430
  const calls = state.calls ?? [];
211
431
  const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
212
- const inner = Math.max(8, width - rowIndent());
213
- const row = layoutRow(inner, "code", callSummary(calls), right);
214
- const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
215
- const lines = [
216
- " ".repeat(rowIndent()) +
217
- theme.fg(statusColor(status), rowGlyph(status, frame)) +
218
- " " +
219
- theme.fg("dim", row.verb) +
220
- " " +
221
- theme.fg(status === "error" ? "text" : "muted", row.item) +
222
- " ".repeat(row.gap) +
223
- theme.fg(rightColor, row.outcome),
224
- ];
432
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)];
225
433
  const expanded = Boolean(context.expanded);
226
434
  const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
227
435
  const childIndent = " ".repeat(rowIndent() + 4);
@@ -303,8 +511,12 @@ export function installToolRenderers(pi) {
303
511
  pi.registerToolRenderer((toolName, next) => {
304
512
  if (toolName === "codemode")
305
513
  return codemodeRenderers();
306
- if (!isToolKind(toolName))
307
- return next();
308
- return compactRenderers(toolName);
514
+ if (isToolKind(toolName))
515
+ return compactRenderers(toolName);
516
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
517
+ const definition = next();
518
+ if (hasOwnRenderer(definition))
519
+ return definition;
520
+ return { ...definition, ...genericRenderers(toolName) };
309
521
  });
310
522
  }
package/dist/tracker.js CHANGED
@@ -40,20 +40,24 @@ export function formatTrackerLines(ctx, theme) {
40
40
  }
41
41
  if (tasks && tasks.length > 0) {
42
42
  const done = tasks.filter((t) => t.status === "complete").length;
43
- const icons = tasks
44
- .map((t) => {
45
- if (t.status === "complete")
46
- return theme.fg("success", "✓");
47
- if (t.status === "in_progress")
48
- return theme.fg("warning", "→");
49
- if (t.status === "failed")
50
- return theme.fg("error", "✗");
51
- return theme.fg("dim", "○");
52
- })
53
- .join("");
43
+ // One segment per task, coloured by status, so the list reads as a single bar instead of a
44
+ // row of check marks. Past 12 tasks the bar is scaled to 10 segments (status order kept).
45
+ const segment = (status) => {
46
+ if (status === "complete")
47
+ return theme.fg("success", "▰");
48
+ if (status === "in_progress")
49
+ return theme.fg("warning", "▰");
50
+ if (status === "failed")
51
+ return theme.fg("error", "▰");
52
+ return theme.fg("dim", "▱");
53
+ };
54
+ const shown = tasks.length <= 12
55
+ ? tasks.map((t) => t.status)
56
+ : Array.from({ length: 10 }, (_, i) => tasks[Math.floor((i * tasks.length) / 10)]?.status);
57
+ const icons = shown.map(segment).join("");
54
58
  const current = tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending");
55
59
  const name = current ? theme.fg("text", ` ${current.name}`) : "";
56
- lines.push(`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `(${done}/${tasks.length})`)}${name}`);
60
+ lines.push(`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `${done}/${tasks.length}`)}${name}`);
57
61
  }
58
62
  return lines;
59
63
  }
@@ -1,23 +1,77 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  export const THINKING_GUTTER = "┊ ";
2
3
  const FENCE = /^\s*(```|~~~)/;
3
4
  const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/;
5
+ /** Below this width wrapping would only make thinking harder to read; prefix source lines instead. */
6
+ const MIN_WRAP_WIDTH = 20;
7
+ /** An inline code span stays whole; everything else splits on spaces. */
8
+ const TOKEN = /`[^`]*`|[^\s`]+|`/g;
9
+ function stripGutter(line) {
10
+ return line.startsWith(THINKING_GUTTER) ? line.slice(THINKING_GUTTER.length) : line;
11
+ }
12
+ /** Split a word wider than `room` into `room`-wide pieces (URLs, long paths). */
13
+ function splitWide(word, room) {
14
+ if (word.startsWith("`") || visibleWidth(word) <= room)
15
+ return [word];
16
+ const pieces = [];
17
+ let piece = "";
18
+ for (const char of word) {
19
+ if (visibleWidth(piece + char) > room) {
20
+ pieces.push(piece);
21
+ piece = "";
22
+ }
23
+ piece += char;
24
+ }
25
+ if (piece)
26
+ pieces.push(piece);
27
+ return pieces;
28
+ }
29
+ /**
30
+ * Greedy word wrap at `room` cells. Markdown markers (`*`, backticks) count toward the width
31
+ * but are removed when rendered, so lines can only come out shorter than `room`, never wider.
32
+ */
33
+ export function wrapLine(line, room) {
34
+ const words = (line.match(TOKEN) ?? []).flatMap((word) => splitWide(word, room));
35
+ if (words.length === 0)
36
+ return [""];
37
+ const out = [];
38
+ let current = "";
39
+ for (const word of words) {
40
+ if (!current)
41
+ current = word;
42
+ else if (visibleWidth(current) + 1 + visibleWidth(word) <= room)
43
+ current += ` ${word}`;
44
+ else {
45
+ out.push(current);
46
+ current = word;
47
+ }
48
+ }
49
+ out.push(current);
50
+ return out;
51
+ }
4
52
  /**
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.
53
+ * Put the thinking gutter on every rendered line of every plain paragraph, so thinking reads as a
54
+ * column beside prose rather than a bullet on its first line. With `width` (the content width the
55
+ * host will render at) paragraph lines are pre-wrapped to `width - 2` so wrapped lines carry the
56
+ * gutter too; pi-tui keeps single newlines as line breaks. Without a usable width only source
57
+ * lines are prefixed.
58
+ *
59
+ * Block syntax (heading, list, quote, table, fence, rule) is left untouched so it still parses,
60
+ * including a list or quote that interrupts a paragraph, and nothing inside fenced code changes.
8
61
  */
9
- export function gutterThinking(markdown) {
62
+ export function gutterThinking(markdown, width) {
10
63
  if (!markdown)
11
64
  return markdown;
12
- const lines = markdown.split("\n");
65
+ const room = width !== undefined && width >= MIN_WRAP_WIDTH ? width - visibleWidth(THINKING_GUTTER) : undefined;
13
66
  const out = [];
14
67
  let inFence = false;
15
- let atParagraphStart = true;
16
- for (const line of lines) {
68
+ /** "para": inside a plain paragraph; "block": inside block syntax until the next blank line. */
69
+ let mode = "none";
70
+ for (const line of markdown.split("\n")) {
17
71
  if (FENCE.test(line)) {
18
72
  inFence = !inFence;
19
73
  out.push(line);
20
- atParagraphStart = !inFence;
74
+ mode = "none";
21
75
  continue;
22
76
  }
23
77
  if (inFence) {
@@ -26,16 +80,21 @@ export function gutterThinking(markdown) {
26
80
  }
27
81
  if (line.trim() === "") {
28
82
  out.push(line);
29
- atParagraphStart = true;
83
+ mode = "none";
30
84
  continue;
31
85
  }
32
- if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
33
- out.push(`${THINKING_GUTTER}${line}`);
34
- }
35
- else {
86
+ const body = stripGutter(line);
87
+ if (mode === "block" || BLOCK_START.test(body)) {
36
88
  out.push(line);
89
+ mode = "block";
90
+ continue;
37
91
  }
38
- atParagraphStart = false;
92
+ mode = "para";
93
+ if (room === undefined)
94
+ out.push(`${THINKING_GUTTER}${body}`);
95
+ else
96
+ for (const piece of wrapLine(body, room))
97
+ out.push(`${THINKING_GUTTER}${piece}`);
39
98
  }
40
99
  return out.join("\n");
41
100
  }
@@ -45,6 +104,6 @@ export function installTranscript(pi) {
45
104
  pi.registerMarkdownTransformer((markdown, context) => {
46
105
  if (context.messageType !== "assistant-thinking")
47
106
  return markdown;
48
- return gutterThinking(markdown);
107
+ return gutterThinking(markdown, context.availableWidth);
49
108
  });
50
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -44,5 +44,8 @@
44
44
  "test": "tsc -p tsconfig.tests.json && node --test test-dist/test/*.test.js",
45
45
  "lint": "eslint .",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit"
47
+ },
48
+ "dependencies": {
49
+ "diff": "^8.0.4"
47
50
  }
48
51
  }