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/release-notes.md CHANGED
@@ -1,32 +1,23 @@
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.
10
2
 
11
- ### 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`.
3
+ - `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
4
+ change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
5
+ block when the row is expanded with `ctrl+o`, where the bare "Successfully
6
+ replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
7
+ capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
8
+ the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
9
+ `toolDiffContext` colours and keep pi's line-number gutter; when one line
10
+ replaces one line, only the changed tokens are inverse-highlighted, as in pi's
11
+ own edit renderer. Failed edits and results without a diff are unchanged.
21
12
 
22
13
  ## 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
14
+ * feat: editDiff setting — show edit diffs always, never, or behind ctrl+o (0.4.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/18
24
15
 
25
16
 
26
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.3...v0.3.0
17
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.1...v0.4.0
27
18
 
28
19
  ---
29
20
  **Install / upgrade**
30
21
  ```bash
31
- pi install npm:pi-minimal@0.3.0 # or: pi update --extensions
22
+ pi install npm:pi-minimal@0.4.0 # or: pi update --extensions
32
23
  ```
package/src/config.ts CHANGED
@@ -2,12 +2,18 @@ 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
4
 
5
+ /** When an `edit` row shows its diff: inline as soon as the edit lands, never, or only while expanded with ctrl+o. */
6
+ export type EditDiffMode = "always" | "never" | "expanded"
7
+ export const EDIT_DIFF_MODES: readonly EditDiffMode[] = ["always", "never", "expanded"]
8
+
5
9
  export type PiMinimalConfig = {
6
10
  hideGauntletWidgets: boolean
11
+ editDiff: EditDiffMode
7
12
  }
8
13
 
9
14
  const DEFAULTS: PiMinimalConfig = {
10
15
  hideGauntletWidgets: true,
16
+ editDiff: "expanded",
11
17
  }
12
18
 
13
19
  export function loadPiMinimalConfig(options: {
@@ -44,16 +50,20 @@ function applyLayer(config: PiMinimalConfig, path: string): void {
44
50
  return
45
51
  }
46
52
 
53
+ // Each key is validated on its own so one bad value does not discard the rest of the layer.
47
54
  const record = parsed as Record<string, unknown>
48
- if (!Object.hasOwn(record, "hideGauntletWidgets")) {
49
- return
55
+ if (Object.hasOwn(record, "hideGauntletWidgets")) {
56
+ const value = record.hideGauntletWidgets
57
+ if (typeof value === "boolean") config.hideGauntletWidgets = value
58
+ else console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
50
59
  }
51
-
52
- const value = record.hideGauntletWidgets
53
- if (typeof value !== "boolean") {
54
- console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
55
- return
60
+ if (Object.hasOwn(record, "editDiff")) {
61
+ const value = record.editDiff
62
+ if (isEditDiffMode(value)) config.editDiff = value
63
+ else console.error(`pi-minimal: editDiff must be one of ${EDIT_DIFF_MODES.map((mode) => `"${mode}"`).join(", ")} in ${path}`)
56
64
  }
65
+ }
57
66
 
58
- config.hideGauntletWidgets = value
67
+ function isEditDiffMode(value: unknown): value is EditDiffMode {
68
+ return typeof value === "string" && (EDIT_DIFF_MODES as readonly string[]).includes(value)
59
69
  }
package/src/index.ts 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, type Sibling } 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, type ReclaimReason } from "./reclaim.js"
12
13
  import { installWorkingStatus } from "./working-status.js"
13
14
  import { installTurnRule } from "./turn-rule.js"
@@ -166,7 +167,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
166
167
  projectTrusted: ctx.isProjectTrusted(),
167
168
  })
168
169
  hideWidgets = config.hideGauntletWidgets
170
+ setEditDiffMode(config.editDiff)
169
171
  currentCwd = ctx.cwd
172
+ setSessionCwd(ctx.cwd)
170
173
  try {
171
174
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
172
175
  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
@@ -3,9 +3,10 @@ import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui
3
3
  import { rowIsRunning } from "./activity.js"
4
4
  import * as chrome from "./chrome.js"
5
5
  import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
6
+ import { diffWords } from "diff"
7
+ import type { EditDiffMode } from "./config.js"
6
8
  import { createLiveWatch } from "./live-watch.js"
7
9
  import {
8
-
9
10
  bashBody,
10
11
  formatDuration,
11
12
  formatOutcome,
@@ -52,6 +53,15 @@ const live = createLiveWatch(400)
52
53
  const rowIndent = () => margin() + 1
53
54
  /** Max lines of output shown when a row is expanded. */
54
55
  const EXPANDED_MAX_LINES = 400
56
+ /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
57
+ const INLINE_DIFF_MAX_LINES = 40
58
+
59
+ let editDiffMode: EditDiffMode = "expanded"
60
+
61
+ /** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
62
+ export function setEditDiffMode(mode: EditDiffMode): void {
63
+ editDiffMode = mode
64
+ }
55
65
 
56
66
  export function stopLiveWatch(): void {
57
67
  live.stop()
@@ -99,6 +109,86 @@ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme:
99
109
  }
100
110
  }
101
111
 
112
+ function editDiffText(result: ToolResultLike | undefined): string | undefined {
113
+ const details = result?.details as { diff?: unknown } | undefined
114
+ return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
115
+ }
116
+
117
+ type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
118
+
119
+ /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
120
+ function parseDiffLine(line: string): DiffLine | undefined {
121
+ const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
122
+ if (!match) return undefined
123
+ return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
124
+ }
125
+
126
+ /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
127
+ function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
128
+ let out = { removed: "", added: "" }
129
+ let firstRemoved = true
130
+ let firstAdded = true
131
+ for (const part of diffWords(removed, added)) {
132
+ if (part.removed || part.added) {
133
+ const key = part.removed ? "removed" : "added"
134
+ let value = part.value
135
+ if (part.removed ? firstRemoved : firstAdded) {
136
+ const ws = /^(\s*)/.exec(value)?.[1] ?? ""
137
+ value = value.slice(ws.length)
138
+ out = { ...out, [key]: out[key] + ws }
139
+ if (part.removed) firstRemoved = false
140
+ else firstAdded = false
141
+ }
142
+ if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
143
+ } else {
144
+ out = { removed: out.removed + part.value, added: out.added + part.value }
145
+ }
146
+ }
147
+ return out
148
+ }
149
+
150
+ /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
151
+ function colorDiffLines(lines: string[], theme: Theme): string[] {
152
+ const out: string[] = []
153
+ for (let i = 0; i < lines.length; ) {
154
+ const parsed = parseDiffLine(lines[i]!)
155
+ if (!parsed) { out.push(theme.fg("toolDiffContext", lines[i]!)); i++; continue }
156
+ if (parsed.prefix === " ") { out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`)); i++; continue }
157
+ const removed: DiffLine[] = []
158
+ const added: DiffLine[] = []
159
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
160
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
161
+ if (removed.length === 1 && added.length === 1) {
162
+ const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
163
+ out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
164
+ out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
165
+ continue
166
+ }
167
+ for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
168
+ for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
169
+ }
170
+ return out
171
+ }
172
+
173
+ /** The edit's diff in the `│`-ruled block. Inline views are capped; expanded views show up to the usual limit. */
174
+ function diffBlock(diff: string, theme: Theme, expanded: boolean) {
175
+ const all = diff.replace(/\n$/, "").split("\n")
176
+ const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES
177
+ const lines = colorDiffLines(all.length > max ? all.slice(0, max) : all, theme)
178
+ const hidden = all.length - lines.length
179
+ const indent = " ".repeat(rowIndent() + 2)
180
+ const rule = theme.fg("borderMuted", "│")
181
+ return {
182
+ render(width: number): string[] {
183
+ const inner = Math.max(0, width - indent.length - 2)
184
+ const out = lines.map((line) => `${indent}${rule} ${truncateToWidth(line, inner)}`)
185
+ if (hidden > 0) out.push(`${indent}${rule} ${theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`)}`)
186
+ return out
187
+ },
188
+ invalidate() {},
189
+ }
190
+ }
191
+
102
192
  function errorLineBlock(line: string | undefined, theme: Theme) {
103
193
  if (!line) return undefined
104
194
  const indent = " ".repeat(rowIndent() + 2)
@@ -196,14 +286,135 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
196
286
  live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
197
287
  }
198
288
  const container = new Container()
289
+ const expanded = Boolean((options as RenderOptions).expanded)
199
290
  const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
200
- if (errorLine && !(options as RenderOptions).expanded) container.addChild(errorLine)
201
- if ((options as RenderOptions).expanded) container.addChild(outputBlock(kind, typedResult, theme))
291
+ if (errorLine && !expanded) container.addChild(errorLine)
292
+ const diff = kind === "edit" && !renderContext.isError && !renderContext.isPartial ? editDiffText(typedResult) : undefined
293
+ const showDiff = diff !== undefined && (editDiffMode === "always" || (editDiffMode === "expanded" && expanded))
294
+ if (showDiff) container.addChild(diffBlock(diff, theme, expanded))
295
+ else if (expanded) container.addChild(outputBlock(kind, typedResult, theme))
202
296
  return container
203
297
  },
204
298
  }
205
299
  }
206
300
 
301
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
302
+ function rowLine(theme: Theme, width: number, status: RowStatus, frame: 0 | 1, verb: string, item: string, right: string): string {
303
+ const inner = Math.max(8, width - rowIndent())
304
+ const row = layoutRow(inner, verb, item, right)
305
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
306
+ return (
307
+ " ".repeat(rowIndent()) +
308
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
309
+ " " +
310
+ theme.fg("dim", row.verb) +
311
+ " " +
312
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
313
+ " ".repeat(row.gap) +
314
+ theme.fg(rightColor, row.outcome)
315
+ )
316
+ }
317
+
318
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
319
+ const OUTCOME_TEXT_MAX = 24
320
+
321
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
322
+ export function genericVerb(toolName: string): string {
323
+ if (toolName.startsWith("mcp__")) return "mcp"
324
+ if (/subagent/.test(toolName)) return "agent"
325
+ return "tool"
326
+ }
327
+
328
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
329
+ export function genericItem(toolName: string, args: unknown): string {
330
+ let json: string
331
+ try {
332
+ json = JSON.stringify(args ?? {}) ?? ""
333
+ } catch {
334
+ json = ""
335
+ }
336
+ const params = nestedItem({ name: toolName, args: json })
337
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName)
338
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName
339
+ return params ? `${label} ${params}` : label
340
+ }
341
+
342
+ export function genericOutcome(result: ToolResultLike | undefined, isError: boolean, durationMs: number | undefined): Outcome {
343
+ const text = resultText(result)
344
+ const dur = formatDuration(durationMs)
345
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" \u00b7 ")
346
+ const lines = text.split("\n").filter((line) => line.trim().length > 0)
347
+ if (isError) return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" }
348
+ if (lines.length === 0) return { text: join("ok", dur) }
349
+ const first = lines[0].trim()
350
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX) return { text: join(first, dur) }
351
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) }
352
+ }
353
+
354
+ /**
355
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
356
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
357
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
358
+ */
359
+ export function genericRenderers(toolName: string): ToolRenderers {
360
+ const verb = genericVerb(toolName)
361
+ return {
362
+ renderShell: "self",
363
+ renderCall(args, theme, rawContext) {
364
+ const context = rawContext as RenderContext
365
+ const id = context.toolCallId || toolName
366
+ const item = genericItem(toolName, args)
367
+ const running = rowIsRunning(context)
368
+ context.state.running = running
369
+ if (running) {
370
+ context.state.startedAt ??= Date.now()
371
+ live.watch(id, context.invalidate)
372
+ } else live.unwatch(id)
373
+ return {
374
+ render(width: number): string[] {
375
+ const status = rowStatus(context)
376
+ const frame = status === "running" && live.has(id) ? live.frame : 0
377
+ const right =
378
+ status === "running"
379
+ ? context.state.startedAt
380
+ ? formatDuration(Date.now() - context.state.startedAt)
381
+ : ""
382
+ : (context.state.outcome?.text ?? "")
383
+ return [rowLine(theme, width, status, frame, verb, item, right)]
384
+ },
385
+ invalidate() {},
386
+ }
387
+ },
388
+ renderResult(result, options, theme, rawContext) {
389
+ const context = rawContext as RenderContext
390
+ const typed = result as ToolResultLike
391
+ if (!context.isPartial) {
392
+ context.state.done = true
393
+ context.state.running = false
394
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs)
395
+ live.unwatch(context.toolCallId || toolName)
396
+ }
397
+ const container = new Container()
398
+ const expanded = Boolean((options as RenderOptions).expanded)
399
+ const errorLine = context.state.outcome?.errorLine
400
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
401
+ if (expanded) {
402
+ const text = resultText(typed)
403
+ if (text) {
404
+ const indent = " ".repeat(rowIndent() + 2)
405
+ container.addChild({ render: (width: number) => ruledLines(text, width, theme, indent), invalidate() {} })
406
+ }
407
+ }
408
+ return container
409
+ },
410
+ }
411
+ }
412
+
413
+ /** True when a tool definition draws its own call or result. */
414
+ function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
415
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
416
+ }
417
+
207
418
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
208
419
  const NESTED_PREVIEW = 8
209
420
  /** Width of the nested tool-name column. */
@@ -255,19 +466,7 @@ export function codemodeRenderers(): ToolRenderers {
255
466
  const calls = state.calls ?? []
256
467
  const right =
257
468
  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
- ]
469
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)]
271
470
  const expanded = Boolean(context.expanded)
272
471
  const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
273
472
  const childIndent = " ".repeat(rowIndent() + 4)
@@ -347,7 +546,10 @@ export function installToolRenderers(pi: ExtensionAPI): void {
347
546
  if (typeof pi.registerToolRenderer !== "function") return
348
547
  pi.registerToolRenderer((toolName, next) => {
349
548
  if (toolName === "codemode") return codemodeRenderers()
350
- if (!isToolKind(toolName)) return next()
351
- return compactRenderers(toolName)
549
+ if (isToolKind(toolName)) return compactRenderers(toolName)
550
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
551
+ const definition = next()
552
+ if (hasOwnRenderer(definition)) return definition
553
+ return { ...definition, ...genericRenderers(toolName) }
352
554
  })
353
555
  }
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"}' }),