pi-minimal 0.3.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -10,6 +10,28 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.1] - 2026-10-25
14
+
15
+ ### Added
16
+ - Compact rows for tools that bring no renderer of their own, such as most MCP tools and
17
+ `subagents_enable`: `✓ mcp re_mcp · query_prometheus query=… step=1m 12 lines · 1.4s`.
18
+ The verb is `mcp`, `agent` (subagent tools) or `tool`; up to three scalar arguments show as
19
+ `key=value`; a short single-line result shows verbatim, otherwise a line count. Failures show
20
+ their first line underneath; `ctrl+o` shows the full result. Tools that draw themselves
21
+ (the `subagent` live view, pi-gauntlet's trackers) keep their own renderer.
22
+
23
+ ### Changed
24
+ - Bash rows drop `cd <session cwd> && ` and shorten other `cd <dir> && ` prefixes to the
25
+ directory name (`pi-subagents › sed -n …`), so the part that differs between rows is visible
26
+ and the outcome column stays put. Multi-line commands such as heredocs say how many lines follow
27
+ (`python3 - <<'EOF' · +4 lines`).
28
+ - The thinking gutter `┊` now runs down every line of a thinking paragraph, including lines the
29
+ terminal wraps: paragraphs are pre-wrapped to the content width the host renders at (re-done on
30
+ resize). Previously only the first line of each paragraph had it, so wrapped thinking sat flush
31
+ with prose. Inline code stays whole; lists, quotes and fenced code are untouched.
32
+ - The task tracker line is a segmented bar coloured by status (`Tasks ▰▰▰▰▱ 4/5`) instead of a
33
+ run of check marks; past 12 tasks it scales to 10 segments.
34
+
13
35
  ## [0.3.0] - 2026-10-25
14
36
 
15
37
  ### 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.
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { installCwd } from "./cwd.js";
8
8
  import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
9
9
  import { hideGauntletWidgets } from "./tracker.js";
10
10
  import { installToolRenderers, 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";
@@ -170,6 +171,7 @@ export default function piMinimalExtension(pi) {
170
171
  });
171
172
  hideWidgets = config.hideGauntletWidgets;
172
173
  currentCwd = ctx.cwd;
174
+ setSessionCwd(ctx.cwd);
173
175
  try {
174
176
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
175
177
  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
@@ -158,6 +158,122 @@ export function compactRenderers(kind) {
158
158
  },
159
159
  };
160
160
  }
161
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
162
+ function rowLine(theme, width, status, frame, verb, item, right) {
163
+ const inner = Math.max(8, width - rowIndent());
164
+ const row = layoutRow(inner, verb, item, right);
165
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
166
+ return (" ".repeat(rowIndent()) +
167
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
168
+ " " +
169
+ theme.fg("dim", row.verb) +
170
+ " " +
171
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
172
+ " ".repeat(row.gap) +
173
+ theme.fg(rightColor, row.outcome));
174
+ }
175
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
176
+ const OUTCOME_TEXT_MAX = 24;
177
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
178
+ export function genericVerb(toolName) {
179
+ if (toolName.startsWith("mcp__"))
180
+ return "mcp";
181
+ if (/subagent/.test(toolName))
182
+ return "agent";
183
+ return "tool";
184
+ }
185
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
186
+ export function genericItem(toolName, args) {
187
+ let json;
188
+ try {
189
+ json = JSON.stringify(args ?? {}) ?? "";
190
+ }
191
+ catch {
192
+ json = "";
193
+ }
194
+ const params = nestedItem({ name: toolName, args: json });
195
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
196
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName;
197
+ return params ? `${label} ${params}` : label;
198
+ }
199
+ export function genericOutcome(result, isError, durationMs) {
200
+ const text = resultText(result);
201
+ const dur = formatDuration(durationMs);
202
+ const join = (...parts) => parts.filter(Boolean).join(" \u00b7 ");
203
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
204
+ if (isError)
205
+ return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" };
206
+ if (lines.length === 0)
207
+ return { text: join("ok", dur) };
208
+ const first = lines[0].trim();
209
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX)
210
+ return { text: join(first, dur) };
211
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) };
212
+ }
213
+ /**
214
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
215
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
216
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
217
+ */
218
+ export function genericRenderers(toolName) {
219
+ const verb = genericVerb(toolName);
220
+ return {
221
+ renderShell: "self",
222
+ renderCall(args, theme, rawContext) {
223
+ const context = rawContext;
224
+ const id = context.toolCallId || toolName;
225
+ const item = genericItem(toolName, args);
226
+ const running = rowIsRunning(context);
227
+ context.state.running = running;
228
+ if (running) {
229
+ context.state.startedAt ??= Date.now();
230
+ live.watch(id, context.invalidate);
231
+ }
232
+ else
233
+ live.unwatch(id);
234
+ return {
235
+ render(width) {
236
+ const status = rowStatus(context);
237
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
238
+ const right = status === "running"
239
+ ? context.state.startedAt
240
+ ? formatDuration(Date.now() - context.state.startedAt)
241
+ : ""
242
+ : (context.state.outcome?.text ?? "");
243
+ return [rowLine(theme, width, status, frame, verb, item, right)];
244
+ },
245
+ invalidate() { },
246
+ };
247
+ },
248
+ renderResult(result, options, theme, rawContext) {
249
+ const context = rawContext;
250
+ const typed = result;
251
+ if (!context.isPartial) {
252
+ context.state.done = true;
253
+ context.state.running = false;
254
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs);
255
+ live.unwatch(context.toolCallId || toolName);
256
+ }
257
+ const container = new Container();
258
+ const expanded = Boolean(options.expanded);
259
+ const errorLine = context.state.outcome?.errorLine;
260
+ if (errorLine && !expanded)
261
+ container.addChild(errorLineBlock(errorLine, theme));
262
+ if (expanded) {
263
+ const text = resultText(typed);
264
+ if (text) {
265
+ const indent = " ".repeat(rowIndent() + 2);
266
+ container.addChild({ render: (width) => ruledLines(text, width, theme, indent), invalidate() { } });
267
+ }
268
+ }
269
+ return container;
270
+ },
271
+ };
272
+ }
273
+ /** True when a tool definition draws its own call or result. */
274
+ function hasOwnRenderer(definition) {
275
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function";
276
+ }
161
277
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
162
278
  const NESTED_PREVIEW = 8;
163
279
  /** Width of the nested tool-name column. */
@@ -209,19 +325,7 @@ export function codemodeRenderers() {
209
325
  const frame = status === "running" && live.has(id) ? live.frame : 0;
210
326
  const calls = state.calls ?? [];
211
327
  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
- ];
328
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)];
225
329
  const expanded = Boolean(context.expanded);
226
330
  const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
227
331
  const childIndent = " ".repeat(rowIndent() + 4);
@@ -303,8 +407,12 @@ export function installToolRenderers(pi) {
303
407
  pi.registerToolRenderer((toolName, next) => {
304
408
  if (toolName === "codemode")
305
409
  return codemodeRenderers();
306
- if (!isToolKind(toolName))
307
- return next();
308
- return compactRenderers(toolName);
410
+ if (isToolKind(toolName))
411
+ return compactRenderers(toolName);
412
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
413
+ const definition = next();
414
+ if (hasOwnRenderer(definition))
415
+ return definition;
416
+ return { ...definition, ...genericRenderers(toolName) };
309
417
  });
310
418
  }
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.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,32 +1,31 @@
1
1
  ### Added
2
- - `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
3
- with one child row per nested call. Built-in tools inside a script read exactly like their
4
- top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
5
- `key=value` instead of raw JSON. Children update live while the script runs. The script source
6
- and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
7
- status colours. A failed nested call or script error shows its first line under the row, like
8
- `bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
9
- highlighted script, JSON arguments, an output preview and a `Took …` timer.
2
+ - Compact rows for tools that bring no renderer of their own, such as most MCP tools and
3
+ `subagents_enable`: `✓ mcp re_mcp · query_prometheus query=… step=1m 12 lines · 1.4s`.
4
+ The verb is `mcp`, `agent` (subagent tools) or `tool`; up to three scalar arguments show as
5
+ `key=value`; a short single-line result shows verbatim, otherwise a line count. Failures show
6
+ their first line underneath; `ctrl+o` shows the full result. Tools that draw themselves
7
+ (the `subagent` live view, pi-gauntlet's trackers) keep their own renderer.
10
8
 
11
9
  ### Changed
12
- - `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
13
- 52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
14
- 4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
15
- - `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
16
- (now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
17
- spinner or a check mark.
18
- - `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
19
- `toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
20
- their slab; failures keep `toolErrorBg`.
10
+ - Bash rows drop `cd <session cwd> && ` and shorten other `cd <dir> && ` prefixes to the
11
+ directory name (`pi-subagents › sed -n …`), so the part that differs between rows is visible
12
+ and the outcome column stays put. Multi-line commands such as heredocs say how many lines follow
13
+ (`python3 - <<'EOF' · +4 lines`).
14
+ - The thinking gutter `┊` now runs down every line of a thinking paragraph, including lines the
15
+ terminal wraps: paragraphs are pre-wrapped to the content width the host renders at (re-done on
16
+ resize). Previously only the first line of each paragraph had it, so wrapped thinking sat flush
17
+ with prose. Inline code stays whole; lists, quotes and fenced code are untouched.
18
+ - The task tracker line is a segmented bar coloured by status (`Tasks ▰▰▰▰▱ 4/5`) instead of a
19
+ run of check marks; past 12 tasks it scales to 10 segments.
21
20
 
22
21
  ## What's Changed
23
- * feat: render codemode as a tool row; raise minimal-dark contrast (0.3.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/15
22
+ * feat: compact rows for renderer-less tools, shorter bash items, task bar, wrapped thinking gutter (0.3.1) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/17
24
23
 
25
24
 
26
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.3...v0.3.0
25
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.0...v0.3.1
27
26
 
28
27
  ---
29
28
  **Install / upgrade**
30
29
  ```bash
31
- pi install npm:pi-minimal@0.3.0 # or: pi update --extensions
30
+ pi install npm:pi-minimal@0.3.1 # or: pi update --extensions
32
31
  ```
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { installCwd } from "./cwd.js"
8
8
  import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
9
9
  import { hideGauntletWidgets } from "./tracker.js"
10
10
  import { installToolRenderers, stopLiveWatch } from "./tools.js"
11
+ import { setSessionCwd } from "./tool-rows.js"
11
12
  import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
12
13
  import { installWorkingStatus } from "./working-status.js"
13
14
  import { installTurnRule } from "./turn-rule.js"
@@ -167,6 +168,7 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
167
168
  })
168
169
  hideWidgets = config.hideGauntletWidgets
169
170
  currentCwd = ctx.cwd
171
+ setSessionCwd(ctx.cwd)
170
172
  try {
171
173
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
172
174
  setChromeMargin(settings.getOutputPad())
package/src/tool-rows.ts CHANGED
@@ -31,9 +31,46 @@ export function shortName(path: string): string {
31
31
  return parts.slice(-2).join("/")
32
32
  }
33
33
 
34
+ let sessionCwd: string | undefined
35
+ let homeDir: string | undefined = process.env.HOME
36
+
37
+ /** The session cwd, so `cd <cwd> && \u2026` can be dropped from bash rows. */
38
+ export function setSessionCwd(cwd: string | undefined, home = process.env.HOME): void {
39
+ sessionCwd = cwd
40
+ homeDir = home
41
+ }
42
+
43
+ const CD_PREFIX = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*/
44
+
45
+ function expandHome(path: string): string {
46
+ const unquoted = path.replace(/^(["'])(.*)\1$/, "$2").replace(/\/+$/, "")
47
+ if (homeDir && (unquoted === "~" || unquoted.startsWith("~/"))) return homeDir + unquoted.slice(1)
48
+ return unquoted
49
+ }
50
+
51
+ /**
52
+ * The item column of a bash row. Long agent commands usually start with `cd <repo> && `, which
53
+ * pushes the part that differs between rows off the screen: drop it when it is the session cwd,
54
+ * otherwise shorten it to the directory name (`pi-subagents \u203a sed -n \u2026`). Multi-line
55
+ * commands (heredocs, scripts) say how many lines follow instead of ending at `<<'EOF'`.
56
+ */
57
+ export function bashItem(command: string): string {
58
+ const lines = command.replace(/\r/g, "").trim().split("\n")
59
+ let first = lines[0]?.trim() ?? ""
60
+ const cd = CD_PREFIX.exec(first)
61
+ if (cd && first.length > cd[0].length) {
62
+ const target = expandHome(cd[1])
63
+ const rest = first.slice(cd[0].length)
64
+ const cwd = sessionCwd?.replace(/\/+$/, "")
65
+ first = cwd && target === cwd ? rest : `${target.split("/").filter(Boolean).at(-1) ?? target} \u203a ${rest}`
66
+ }
67
+ const more = lines.slice(1).filter((line) => line.trim()).length
68
+ return more > 0 ? `${first} \u00b7 +${more} line${more === 1 ? "" : "s"}` : first
69
+ }
70
+
34
71
  export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
35
72
  if (kind === "bash") {
36
- const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : ""
73
+ const command = typeof args?.command === "string" ? bashItem(args.command) : ""
37
74
  return command || "bash"
38
75
  }
39
76
  if (kind === "grep" || kind === "find") {
package/src/tools.ts CHANGED
@@ -5,7 +5,6 @@ import * as chrome from "./chrome.js"
5
5
  import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
6
6
  import { createLiveWatch } from "./live-watch.js"
7
7
  import {
8
-
9
8
  bashBody,
10
9
  formatDuration,
11
10
  formatOutcome,
@@ -204,6 +203,123 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
204
203
  }
205
204
  }
206
205
 
206
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
207
+ function rowLine(theme: Theme, width: number, status: RowStatus, frame: 0 | 1, verb: string, item: string, right: string): string {
208
+ const inner = Math.max(8, width - rowIndent())
209
+ const row = layoutRow(inner, verb, item, right)
210
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
211
+ return (
212
+ " ".repeat(rowIndent()) +
213
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
214
+ " " +
215
+ theme.fg("dim", row.verb) +
216
+ " " +
217
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
218
+ " ".repeat(row.gap) +
219
+ theme.fg(rightColor, row.outcome)
220
+ )
221
+ }
222
+
223
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
224
+ const OUTCOME_TEXT_MAX = 24
225
+
226
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
227
+ export function genericVerb(toolName: string): string {
228
+ if (toolName.startsWith("mcp__")) return "mcp"
229
+ if (/subagent/.test(toolName)) return "agent"
230
+ return "tool"
231
+ }
232
+
233
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
234
+ export function genericItem(toolName: string, args: unknown): string {
235
+ let json: string
236
+ try {
237
+ json = JSON.stringify(args ?? {}) ?? ""
238
+ } catch {
239
+ json = ""
240
+ }
241
+ const params = nestedItem({ name: toolName, args: json })
242
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName)
243
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName
244
+ return params ? `${label} ${params}` : label
245
+ }
246
+
247
+ export function genericOutcome(result: ToolResultLike | undefined, isError: boolean, durationMs: number | undefined): Outcome {
248
+ const text = resultText(result)
249
+ const dur = formatDuration(durationMs)
250
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" \u00b7 ")
251
+ const lines = text.split("\n").filter((line) => line.trim().length > 0)
252
+ if (isError) return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" }
253
+ if (lines.length === 0) return { text: join("ok", dur) }
254
+ const first = lines[0].trim()
255
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX) return { text: join(first, dur) }
256
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) }
257
+ }
258
+
259
+ /**
260
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
261
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
262
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
263
+ */
264
+ export function genericRenderers(toolName: string): ToolRenderers {
265
+ const verb = genericVerb(toolName)
266
+ return {
267
+ renderShell: "self",
268
+ renderCall(args, theme, rawContext) {
269
+ const context = rawContext as RenderContext
270
+ const id = context.toolCallId || toolName
271
+ const item = genericItem(toolName, args)
272
+ const running = rowIsRunning(context)
273
+ context.state.running = running
274
+ if (running) {
275
+ context.state.startedAt ??= Date.now()
276
+ live.watch(id, context.invalidate)
277
+ } else live.unwatch(id)
278
+ return {
279
+ render(width: number): string[] {
280
+ const status = rowStatus(context)
281
+ const frame = status === "running" && live.has(id) ? live.frame : 0
282
+ const right =
283
+ status === "running"
284
+ ? context.state.startedAt
285
+ ? formatDuration(Date.now() - context.state.startedAt)
286
+ : ""
287
+ : (context.state.outcome?.text ?? "")
288
+ return [rowLine(theme, width, status, frame, verb, item, right)]
289
+ },
290
+ invalidate() {},
291
+ }
292
+ },
293
+ renderResult(result, options, theme, rawContext) {
294
+ const context = rawContext as RenderContext
295
+ const typed = result as ToolResultLike
296
+ if (!context.isPartial) {
297
+ context.state.done = true
298
+ context.state.running = false
299
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs)
300
+ live.unwatch(context.toolCallId || toolName)
301
+ }
302
+ const container = new Container()
303
+ const expanded = Boolean((options as RenderOptions).expanded)
304
+ const errorLine = context.state.outcome?.errorLine
305
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
306
+ if (expanded) {
307
+ const text = resultText(typed)
308
+ if (text) {
309
+ const indent = " ".repeat(rowIndent() + 2)
310
+ container.addChild({ render: (width: number) => ruledLines(text, width, theme, indent), invalidate() {} })
311
+ }
312
+ }
313
+ return container
314
+ },
315
+ }
316
+ }
317
+
318
+ /** True when a tool definition draws its own call or result. */
319
+ function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
320
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
321
+ }
322
+
207
323
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
208
324
  const NESTED_PREVIEW = 8
209
325
  /** Width of the nested tool-name column. */
@@ -255,19 +371,7 @@ export function codemodeRenderers(): ToolRenderers {
255
371
  const calls = state.calls ?? []
256
372
  const right =
257
373
  status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
258
- const inner = Math.max(8, width - rowIndent())
259
- const row = layoutRow(inner, "code", callSummary(calls), right)
260
- const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
261
- const lines = [
262
- " ".repeat(rowIndent()) +
263
- theme.fg(statusColor(status), rowGlyph(status, frame)) +
264
- " " +
265
- theme.fg("dim", row.verb) +
266
- " " +
267
- theme.fg(status === "error" ? "text" : "muted", row.item) +
268
- " ".repeat(row.gap) +
269
- theme.fg(rightColor, row.outcome),
270
- ]
374
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)]
271
375
  const expanded = Boolean(context.expanded)
272
376
  const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
273
377
  const childIndent = " ".repeat(rowIndent() + 4)
@@ -347,7 +451,10 @@ export function installToolRenderers(pi: ExtensionAPI): void {
347
451
  if (typeof pi.registerToolRenderer !== "function") return
348
452
  pi.registerToolRenderer((toolName, next) => {
349
453
  if (toolName === "codemode") return codemodeRenderers()
350
- if (!isToolKind(toolName)) return next()
351
- return compactRenderers(toolName)
454
+ if (isToolKind(toolName)) return compactRenderers(toolName)
455
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
456
+ const definition = next()
457
+ if (hasOwnRenderer(definition)) return definition
458
+ return { ...definition, ...genericRenderers(toolName) }
352
459
  })
353
460
  }
package/src/tracker.ts CHANGED
@@ -67,19 +67,24 @@ export function formatTrackerLines(
67
67
  }
68
68
  if (tasks && tasks.length > 0) {
69
69
  const done = tasks.filter((t) => t.status === "complete").length
70
- const icons = tasks
71
- .map((t) => {
72
- if (t.status === "complete") return theme.fg("success", "✓")
73
- if (t.status === "in_progress") return theme.fg("warning", "→")
74
- if (t.status === "failed") return theme.fg("error", "✗")
75
- return theme.fg("dim", "○")
76
- })
77
- .join("")
70
+ // One segment per task, coloured by status, so the list reads as a single bar instead of a
71
+ // row of check marks. Past 12 tasks the bar is scaled to 10 segments (status order kept).
72
+ const segment = (status: string | undefined) => {
73
+ if (status === "complete") return theme.fg("success", "▰")
74
+ if (status === "in_progress") return theme.fg("warning", "▰")
75
+ if (status === "failed") return theme.fg("error", "▰")
76
+ return theme.fg("dim", "▱")
77
+ }
78
+ const shown =
79
+ tasks.length <= 12
80
+ ? tasks.map((t) => t.status)
81
+ : Array.from({ length: 10 }, (_, i) => tasks[Math.floor((i * tasks.length) / 10)]?.status)
82
+ const icons = shown.map(segment).join("")
78
83
  const current =
79
84
  tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending")
80
85
  const name = current ? theme.fg("text", ` ${current.name}`) : ""
81
86
  lines.push(
82
- `${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `(${done}/${tasks.length})`)}${name}`,
87
+ `${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `${done}/${tasks.length}`)}${name}`,
83
88
  )
84
89
  }
85
90
  return lines
package/src/transcript.ts CHANGED
@@ -1,51 +1,106 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { visibleWidth } from "@earendil-works/pi-tui"
2
3
 
3
4
  export const THINKING_GUTTER = "┊ "
4
5
 
5
6
  const FENCE = /^\s*(```|~~~)/
6
7
  const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/
8
+ /** Below this width wrapping would only make thinking harder to read; prefix source lines instead. */
9
+ const MIN_WRAP_WIDTH = 20
10
+ /** An inline code span stays whole; everything else splits on spaces. */
11
+ const TOKEN = /`[^`]*`|[^\s`]+|`/g
12
+
13
+ function stripGutter(line: string): string {
14
+ return line.startsWith(THINKING_GUTTER) ? line.slice(THINKING_GUTTER.length) : line
15
+ }
16
+
17
+ /** Split a word wider than `room` into `room`-wide pieces (URLs, long paths). */
18
+ function splitWide(word: string, room: number): string[] {
19
+ if (word.startsWith("`") || visibleWidth(word) <= room) return [word]
20
+ const pieces: string[] = []
21
+ let piece = ""
22
+ for (const char of word) {
23
+ if (visibleWidth(piece + char) > room) {
24
+ pieces.push(piece)
25
+ piece = ""
26
+ }
27
+ piece += char
28
+ }
29
+ if (piece) pieces.push(piece)
30
+ return pieces
31
+ }
32
+
33
+ /**
34
+ * Greedy word wrap at `room` cells. Markdown markers (`*`, backticks) count toward the width
35
+ * but are removed when rendered, so lines can only come out shorter than `room`, never wider.
36
+ */
37
+ export function wrapLine(line: string, room: number): string[] {
38
+ const words = (line.match(TOKEN) ?? []).flatMap((word) => splitWide(word, room))
39
+ if (words.length === 0) return [""]
40
+ const out: string[] = []
41
+ let current = ""
42
+ for (const word of words) {
43
+ if (!current) current = word
44
+ else if (visibleWidth(current) + 1 + visibleWidth(word) <= room) current += ` ${word}`
45
+ else {
46
+ out.push(current)
47
+ current = word
48
+ }
49
+ }
50
+ out.push(current)
51
+ return out
52
+ }
7
53
 
8
54
  /**
9
- * Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
10
- * Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
11
- * they keep parsing, and nothing inside a fenced code block is touched.
55
+ * Put the thinking gutter on every rendered line of every plain paragraph, so thinking reads as a
56
+ * column beside prose rather than a bullet on its first line. With `width` (the content width the
57
+ * host will render at) paragraph lines are pre-wrapped to `width - 2` so wrapped lines carry the
58
+ * gutter too; pi-tui keeps single newlines as line breaks. Without a usable width only source
59
+ * lines are prefixed.
60
+ *
61
+ * Block syntax (heading, list, quote, table, fence, rule) is left untouched so it still parses,
62
+ * including a list or quote that interrupts a paragraph, and nothing inside fenced code changes.
12
63
  */
13
- export function gutterThinking(markdown: string): string {
14
- if (!markdown) return markdown
15
- const lines = markdown.split("\n")
16
- const out: string[] = []
17
- let inFence = false
18
- let atParagraphStart = true
19
- for (const line of lines) {
20
- if (FENCE.test(line)) {
21
- inFence = !inFence
22
- out.push(line)
23
- atParagraphStart = !inFence
24
- continue
25
- }
26
- if (inFence) {
27
- out.push(line)
28
- continue
29
- }
30
- if (line.trim() === "") {
31
- out.push(line)
32
- atParagraphStart = true
33
- continue
34
- }
35
- if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
36
- out.push(`${THINKING_GUTTER}${line}`)
37
- } else {
38
- out.push(line)
39
- }
40
- atParagraphStart = false
41
- }
42
- return out.join("\n")
64
+ export function gutterThinking(markdown: string, width?: number): string {
65
+ if (!markdown) return markdown
66
+ const room = width !== undefined && width >= MIN_WRAP_WIDTH ? width - visibleWidth(THINKING_GUTTER) : undefined
67
+ const out: string[] = []
68
+ let inFence = false
69
+ /** "para": inside a plain paragraph; "block": inside block syntax until the next blank line. */
70
+ let mode: "none" | "para" | "block" = "none"
71
+ for (const line of markdown.split("\n")) {
72
+ if (FENCE.test(line)) {
73
+ inFence = !inFence
74
+ out.push(line)
75
+ mode = "none"
76
+ continue
77
+ }
78
+ if (inFence) {
79
+ out.push(line)
80
+ continue
81
+ }
82
+ if (line.trim() === "") {
83
+ out.push(line)
84
+ mode = "none"
85
+ continue
86
+ }
87
+ const body = stripGutter(line)
88
+ if (mode === "block" || BLOCK_START.test(body)) {
89
+ out.push(line)
90
+ mode = "block"
91
+ continue
92
+ }
93
+ mode = "para"
94
+ if (room === undefined) out.push(`${THINKING_GUTTER}${body}`)
95
+ else for (const piece of wrapLine(body, room)) out.push(`${THINKING_GUTTER}${piece}`)
96
+ }
97
+ return out.join("\n")
43
98
  }
44
99
 
45
100
  export function installTranscript(pi: ExtensionAPI): void {
46
- if (typeof pi.registerMarkdownTransformer !== "function") return
47
- pi.registerMarkdownTransformer((markdown, context) => {
48
- if (context.messageType !== "assistant-thinking") return markdown
49
- return gutterThinking(markdown)
50
- })
101
+ if (typeof pi.registerMarkdownTransformer !== "function") return
102
+ pi.registerMarkdownTransformer((markdown, context) => {
103
+ if (context.messageType !== "assistant-thinking") return markdown
104
+ return gutterThinking(markdown, context.availableWidth)
105
+ })
51
106
  }
@@ -21,7 +21,7 @@ test("parseArgs reads complete JSON and recovers keys from a preview cut at 200
21
21
  })
22
22
 
23
23
  test("nestedItem reads built-ins like their top-level rows and others as key=value", () => {
24
- assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status")
24
+ assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status · +1 line")
25
25
  assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
26
26
  assert.equal(
27
27
  nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
@@ -1,23 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import test from "node:test"
3
- import { readFileSync } from "node:fs"
4
-
5
- test("package manifest exposes the committed extension", () => {
6
- const pkg = JSON.parse(readFileSync("package.json", "utf8"))
7
- assert.equal(pkg.name, "pi-minimal")
8
- assert.equal(pkg.version, "0.3.0")
9
- const lock = JSON.parse(readFileSync("package-lock.json", "utf8"))
10
- assert.equal(lock.version, pkg.version, "lockfile root version matches package")
11
- assert.equal(lock.packages[""].version, pkg.version, "lockfile package version matches package")
12
- assert.ok(readFileSync("CHANGELOG.md", "utf8").includes(`## [${pkg.version}] - `), "version has a dated release entry")
13
- assert.equal(pkg.license, "MIT")
14
- assert.equal(pkg.type, "module")
15
- assert.deepEqual(pkg.engines, { node: ">=22.19.0" })
16
- assert.deepEqual(pkg.keywords, ["pi-package"])
17
- assert.deepEqual(pkg.repository, { type: "git", url: "git+https://github.com/raviy06/pi-minimal.git" })
18
- assert.deepEqual(pkg.pi, { extensions: ["./dist/index.js"], themes: ["./themes/minimal-dark.json"] })
19
- assert.deepEqual(pkg.peerDependencies, {
20
- "@earendil-works/pi-coding-agent": "*",
21
- "@earendil-works/pi-tui": "*",
22
- })
23
- })
@@ -8,6 +8,7 @@ import {
8
8
  formatOutcome,
9
9
  layoutRow,
10
10
  rowGlyph,
11
+ setSessionCwd,
11
12
  shortName,
12
13
  toolItem,
13
14
  } from "../src/tool-rows.js"
@@ -15,7 +16,7 @@ import {
15
16
  const text = (s: string) => ({ content: [{ type: "text", text: s }] })
16
17
 
17
18
  test("toolItem picks the first command line for bash and a two-segment path otherwise", () => {
18
- assert.equal(toolItem("bash", { command: "git status\ngit diff" }), "git status")
19
+ assert.equal(toolItem("bash", { command: "git status" }), "git status")
19
20
  assert.equal(toolItem("read", { path: "/a/b/c/file.ts" }), "c/file.ts")
20
21
  assert.equal(toolItem("grep", { pattern: "foo" }), "foo")
21
22
  assert.equal(toolItem("ls", {}), "ls")
@@ -107,3 +108,18 @@ test("layoutRow drops the outcome when the row is too narrow", () => {
107
108
  assert.equal(row.outcome, "")
108
109
  assert.equal(row.gap, 0)
109
110
  })
111
+
112
+ test("bash items drop cd into the session cwd, shorten other cd targets, and count extra lines", () => {
113
+ setSessionCwd("/Users/me/repo", "/Users/me")
114
+ try {
115
+ assert.equal(toolItem("bash", { command: "cd /Users/me/repo && npm test" }), "npm test")
116
+ assert.equal(toolItem("bash", { command: "cd ~/repo/ && npm test" }), "npm test")
117
+ assert.equal(toolItem("bash", { command: "cd ~/Dev/src/github.com/raviy06/pi-subagents && sed -n 1,2p a.ts" }), "pi-subagents › sed -n 1,2p a.ts")
118
+ assert.equal(toolItem("bash", { command: 'cd "/tmp/with space" && ls' }), "with space › ls")
119
+ assert.equal(toolItem("bash", { command: "cd /tmp" }), "cd /tmp", "a bare cd stays as written")
120
+ assert.equal(toolItem("bash", { command: "cd ~/repo && python3 - <<'EOF'\nprint(1)\n\nprint(2)\nEOF" }), "python3 - <<'EOF' · +3 lines")
121
+ assert.equal(toolItem("bash", { command: "git status\ngit diff" }), "git status · +1 line")
122
+ } finally {
123
+ setSessionCwd(undefined)
124
+ }
125
+ })
@@ -1,7 +1,17 @@
1
1
  import assert from "node:assert/strict"
2
2
  import test from "node:test"
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
- import { codemodeRenderers, compactRenderers, installToolRenderers, isToolKind, stopLiveWatch } from "../src/tools.js"
4
+ import {
5
+ codemodeRenderers,
6
+ compactRenderers,
7
+ genericItem,
8
+ genericOutcome,
9
+ genericRenderers,
10
+ genericVerb,
11
+ installToolRenderers,
12
+ isToolKind,
13
+ stopLiveWatch,
14
+ } from "../src/tools.js"
5
15
 
6
16
  test("isToolKind recognises the built-in tools only", () => {
7
17
  for (const name of ["bash", "read", "edit", "write", "grep", "find", "ls"]) assert.equal(isToolKind(name), true)
@@ -19,7 +29,12 @@ test("installToolRenderers registers a resolver at load time that only claims bu
19
29
  installToolRenderers(pi)
20
30
  assert.ok(resolver, "resolver registered synchronously, before session_start")
21
31
  const fallback = { renderShell: "default" }
22
- assert.equal(resolver!("mcp__foo__bar", () => fallback), fallback)
32
+ const own = { renderCall() {}, renderResult() {} }
33
+ assert.equal(resolver!("subagent", () => own), own, "tools with their own renderer keep it")
34
+ const generic = resolver!("mcp__foo__bar", () => fallback) as { renderShell?: string; renderCall?: unknown }
35
+ assert.equal(generic.renderShell, "self", "renderer-less tools get the compact row")
36
+ assert.equal(typeof generic.renderCall, "function")
37
+ assert.equal((resolver!("mcp__foo__bar", () => undefined) as { renderShell?: string }).renderShell, "self")
23
38
  const codemode = resolver!("codemode", () => fallback) as { renderShell?: string }
24
39
  assert.equal(codemode.renderShell, "self")
25
40
  const mine = resolver!("bash", () => fallback) as { renderShell?: string }
@@ -90,7 +105,7 @@ test("codemode renders one parent row plus one child row per nested call, no scr
90
105
  assert.equal(lines.length, 3)
91
106
  assert.match(lines[0], /✓ code\s+2 calls · plan_tracker, bash\s+2 lines · 1.2s$/)
92
107
  assert.match(lines[1], /✓ plan_tracker action=update index=6 status=complete\s+0.3s$/)
93
- assert.match(lines[2], /✓ bash\s+cd ~\/r && sed -n 1,2p a.md\s+0.6s$/)
108
+ assert.match(lines[2], /✓ bash\s+r › sed -n 1,2p a.md\s+0.6s$/)
94
109
  assert.ok(lines.every((line) => !line.includes("await")), "script hidden when collapsed")
95
110
  assert.deepEqual(body.render(100), [])
96
111
  for (const line of lines) assert.ok(line.length <= 100)
@@ -123,3 +138,41 @@ test("codemode shows nested calls live while the script is still running", () =>
123
138
  assert.equal((context.state as { done?: boolean }).done, undefined)
124
139
  stopLiveWatch()
125
140
  })
141
+
142
+ test("generic rows label MCP and agent tools and read arguments as key=value", () => {
143
+ assert.equal(genericVerb("mcp__re_mcp__query_prometheus"), "mcp")
144
+ assert.equal(genericVerb("subagents_enable"), "agent")
145
+ assert.equal(genericVerb("todo"), "tool")
146
+ assert.equal(genericItem("mcp__re_mcp__query_prometheus", { query: "sum(rate(x[5m]))", limit: 5 }), "re_mcp · query_prometheus query=sum(rate(x[5m])) limit=5")
147
+ assert.equal(genericItem("subagents_enable", {}), "subagents_enable")
148
+ assert.equal(genericItem("todo", { action: "add", text: "line one\nline two" }), "todo action=add text=line one")
149
+ })
150
+
151
+ test("generic outcomes show a short first line verbatim, otherwise a line count", () => {
152
+ const text = (s: string) => ({ content: [{ type: "text", text: s }] })
153
+ assert.deepEqual(genericOutcome(text("Updated (9/9 done)"), false, 300), { text: "Updated (9/9 done) · 0.3s" })
154
+ assert.deepEqual(genericOutcome(text("Enabled: subagent. If your tool list includes subagent, call it now."), false, 0), { text: "1 line · 0s" })
155
+ assert.deepEqual(genericOutcome(text("a\nb\n"), false, 1400), { text: "2 lines · 1.4s" })
156
+ assert.deepEqual(genericOutcome(text(""), false, undefined), { text: "ok" })
157
+ assert.deepEqual(genericOutcome(text("\nboom\nmore"), true, 100), { text: "failed · 0.1s", errorLine: "boom" })
158
+ })
159
+
160
+ test("generic renderer draws one row, an error line on failure, and output on expand", () => {
161
+ const r = genericRenderers("mcp__re_mcp__query_prometheus")
162
+ const make = (extra: Record<string, unknown> = {}) => ({ toolCallId: "g1", invalidate() {}, state: {}, isPartial: false, isError: false, durationMs: 1400, ...extra })
163
+ const args = { query: "up" }
164
+ let context = make()
165
+ r.renderResult!({ content: [{ type: "text", text: "a\nb\nc" }] } as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
166
+ const [line, ...rest] = r.renderCall!(args as never, plainTheme, context as never).render(90)
167
+ assert.match(line, /✓ mcp\s+re_mcp · query_prometheus query=up\s+3 lines · 1.4s$/)
168
+ assert.equal(rest.length, 0)
169
+
170
+ context = make({ isError: true })
171
+ const failed = r.renderResult!({ content: [{ type: "text", text: "401 unauthorized" }] } as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
172
+ assert.match(r.renderCall!(args as never, plainTheme, context as never).render(90)[0], /✗ mcp .*failed · 1.4s$/)
173
+ assert.match(failed.render(90).join(""), /│ 401 unauthorized/)
174
+
175
+ context = make({ expanded: true })
176
+ const open = r.renderResult!({ content: [{ type: "text", text: "a\nb" }] } as never, { expanded: true, isPartial: false } as never, plainTheme, context as never)
177
+ assert.deepEqual(open.render(90).map((l) => l.trim()), ["│ a", "│ b"])
178
+ })
@@ -102,3 +102,21 @@ test("hideGauntletWidgets clears phase_tracker and plan_tracker", () => {
102
102
  ["plan_tracker", undefined],
103
103
  ])
104
104
  })
105
+
106
+ test("task line is a segmented bar coloured by status, scaled past 12 tasks", () => {
107
+ const colour = { fg: (name: string, s: string) => `<${name}>${s}` }
108
+ const tasks = [
109
+ { name: "a", status: "complete" },
110
+ { name: "b", status: "failed" },
111
+ { name: "c", status: "in_progress" },
112
+ { name: "d", status: "pending" },
113
+ ] as never
114
+ const [line] = formatTrackerLines(ctxFrom({ tasks }), colour)
115
+ assert.ok(line.includes("<success>▰<error>▰<warning>▰<dim>▱"), line)
116
+ assert.ok(line.includes("1/4"), line)
117
+ assert.ok(!line.includes("✓"), "no check-mark run")
118
+ const many = Array.from({ length: 40 }, (_, i) => ({ name: `t${i}`, status: i < 20 ? "complete" : "pending" })) as never
119
+ const [scaled] = formatTrackerLines(ctxFrom({ tasks: many }), theme)
120
+ assert.equal((scaled.match(/[▰▱]/g) ?? []).length, 10)
121
+ assert.ok(scaled.includes("▰▰▰▰▰▱▱▱▱▱"), scaled)
122
+ })
@@ -1,10 +1,12 @@
1
1
  import assert from "node:assert/strict"
2
2
  import test from "node:test"
3
- import { gutterThinking } from "../src/transcript.js"
3
+ import { stripVTControlCharacters } from "node:util"
4
+ import { Markdown, visibleWidth } from "@earendil-works/pi-tui"
5
+ import { gutterThinking, wrapLine } from "../src/transcript.js"
4
6
 
5
- test("gutterThinking prefixes paragraph starts only", () => {
7
+ test("gutterThinking prefixes every line of a plain paragraph", () => {
6
8
  const input = "First thought\ncontinues here.\n\nSecond thought."
7
- assert.equal(gutterThinking(input), "┊ First thought\ncontinues here.\n\n┊ Second thought.")
9
+ assert.equal(gutterThinking(input), "┊ First thought\n┊ continues here.\n\n┊ Second thought.")
8
10
  })
9
11
 
10
12
  test("gutterThinking leaves block syntax and fenced code alone", () => {
@@ -15,8 +17,53 @@ test("gutterThinking leaves block syntax and fenced code alone", () => {
15
17
  )
16
18
  })
17
19
 
20
+ test("a list or quote interrupting a paragraph keeps its syntax", () => {
21
+ assert.equal(gutterThinking("Plan:\n- one\n- two\n\nDone."), "┊ Plan:\n- one\n- two\n\n┊ Done.")
22
+ assert.equal(gutterThinking("Note\n> quoted\nlazy line"), "┊ Note\n> quoted\nlazy line")
23
+ })
24
+
18
25
  test("gutterThinking is idempotent and handles empty input", () => {
19
26
  assert.equal(gutterThinking(""), "")
20
27
  const once = gutterThinking("a\n\nb")
21
28
  assert.equal(gutterThinking(once), once)
29
+ const wrapped = gutterThinking("one two three four five six seven eight nine ten eleven", 24)
30
+ assert.equal(gutterThinking(wrapped, 24), wrapped)
31
+ })
32
+
33
+ test("with a width, paragraphs are pre-wrapped so wrapped lines carry the gutter", () => {
34
+ const out = gutterThinking("alpha beta gamma delta epsilon zeta eta theta iota kappa", 24)
35
+ assert.deepEqual(out.split("\n"), ["┊ alpha beta gamma delta", "┊ epsilon zeta eta theta", "┊ iota kappa"])
36
+ for (const line of out.split("\n")) assert.ok(visibleWidth(line) <= 24, line)
37
+ })
38
+
39
+ test("narrow or unknown widths fall back to prefixing source lines", () => {
40
+ const long = "alpha beta gamma delta epsilon zeta eta theta"
41
+ assert.equal(gutterThinking(long, 10), `┊ ${long}`)
42
+ assert.equal(gutterThinking(long), `┊ ${long}`)
43
+ })
44
+
45
+ test("wrapLine keeps inline code whole and splits over-wide words", () => {
46
+ assert.deepEqual(wrapLine("use `ctx.ui.confirm here` now", 12), ["use", "`ctx.ui.confirm here`", "now"])
47
+ assert.deepEqual(wrapLine("see https://example.com/a/very/long/path", 12), ["see", "https://exam", "ple.com/a/ve", "ry/long/path"])
48
+ assert.deepEqual(wrapLine("", 12), [""])
49
+ })
50
+
51
+ test("rendered through pi-tui Markdown, every line has the gutter and fits the width", () => {
52
+ const theme = new Proxy({}, { get: () => (s: string) => s }) as never
53
+ const thinking =
54
+ "There's an existing **/subagents** admin TUI with field editing, including `override removal`. " +
55
+ "I should check what it covers so the roster feature doesn't duplicate it — maybe I can hook into it instead, " +
56
+ "like adding model/fallback editing there.\n\nThen the `/roster` command."
57
+ for (const width of [40, 80, 120]) {
58
+ const md = new Markdown(thinking, 0, 0, theme, { color: (s: string) => s, italic: true }, {
59
+ transform: (text: string, available: number) => gutterThinking(text, available),
60
+ } as never)
61
+ const lines = md.render(width).map((line) => stripVTControlCharacters(line).trimEnd())
62
+ const content = lines.filter((line) => line.length > 0)
63
+ assert.ok(content.length >= 3, `width ${width}: ${JSON.stringify(lines)}`)
64
+ for (const line of content) {
65
+ assert.ok(line.startsWith("┊ "), `width ${width}: ${JSON.stringify(line)}`)
66
+ assert.ok(visibleWidth(line) <= width, `width ${width}: ${line}`)
67
+ }
68
+ }
22
69
  })