pi-minimal 0.2.3 → 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/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
  }
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { callSummary, codemodeOutcome, nestedCalls, nestedItem, parseArgs, scriptOutput } from "../src/codemode-rows.js"
4
+
5
+ const result = (text: string, calls: unknown[] = []) => ({
6
+ content: [
7
+ { type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
8
+ { type: "text", text },
9
+ ],
10
+ details: { calls },
11
+ })
12
+
13
+ test("parseArgs reads complete JSON and recovers keys from a preview cut at 200 chars", () => {
14
+ assert.deepEqual(parseArgs('{"action":"update","index":6}'), { action: "update", index: 6 })
15
+ const cut = '{"command":"cd ~/repo && sed -n 168,180p docs/a.md && echo \\"===\\" && rg -n x docs/b.md | sed -n 1,30p && ech...'
16
+ assert.equal(parseArgs(cut).command, 'cd ~/repo && sed -n 168,180p docs/a.md && echo "===" && rg -n x docs/b.md | sed -n 1,30p && ech...')
17
+ assert.deepEqual(parseArgs(""), {})
18
+ assert.deepEqual(parseArgs("[1,2]"), {})
19
+ // A cut inside an escape must not throw.
20
+ assert.equal(typeof parseArgs('{"command":"echo \\').command, "string")
21
+ })
22
+
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 · +1 line")
25
+ assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
26
+ assert.equal(
27
+ nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
28
+ "action=update index=6 status=complete",
29
+ )
30
+ assert.equal(nestedItem({ name: "mcp__x__q", args: '{"query":"a","nested":{"b":1},"limit":5,"extra":1,"more":2}' }), "query=a limit=5 extra=1")
31
+ assert.equal(nestedItem({ name: "subagents_enable", args: "{}" }), "")
32
+ })
33
+
34
+ test("callSummary names tools in first-call order with repeat counts", () => {
35
+ assert.equal(callSummary([]), "script")
36
+ assert.equal(callSummary([{ name: "bash" }]), "1 call · bash")
37
+ assert.equal(
38
+ callSummary([{ name: "plan_tracker" }, { name: "plan_tracker" }, { name: "bash" }]),
39
+ "3 calls · plan_tracker ×2, bash",
40
+ )
41
+ })
42
+
43
+ test("scriptOutput drops pi-core's script header; nestedCalls tolerates missing details", () => {
44
+ assert.equal(scriptOutput(result("hello\n")), "hello")
45
+ assert.deepEqual(nestedCalls({ content: [] }), [])
46
+ assert.deepEqual(nestedCalls({ content: [], details: { calls: [null, { name: "bash" }] } }), [{ name: "bash" }])
47
+ })
48
+
49
+ test("codemodeOutcome counts output lines and surfaces the first failure", () => {
50
+ assert.deepEqual(codemodeOutcome(result("a\nb\n\nc"), false, 1200), { text: "3 lines · 1.2s", failed: false })
51
+ assert.deepEqual(codemodeOutcome(result(""), false, 300), { text: "ok · 0.3s", failed: false })
52
+ const failed = codemodeOutcome(
53
+ result("TEST_EXIT=1", [{ name: "bash", status: "ok" }, { name: "bash", status: "error", error: "\nboom: exit 2\nmore" }]),
54
+ false,
55
+ 274_000,
56
+ )
57
+ assert.deepEqual(failed, { text: "1 failed · 4m 34s", failed: true, errorLine: "boom: exit 2" })
58
+ const scriptError = codemodeOutcome(result("partial\nScript error: ReferenceError: x is not defined"), true, 50)
59
+ assert.equal(scriptError.failed, true)
60
+ assert.equal(scriptError.errorLine, "ReferenceError: x is not defined")
61
+ })
@@ -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.2.3")
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
- })
@@ -43,3 +43,31 @@ test("minimal-dark frames code quietly and neutralises tool backgrounds", () =>
43
43
  assert.equal(theme.colors.toolSuccessBg, theme.colors.toolPendingBg)
44
44
  assert.notEqual(theme.colors.mdHeading, theme.colors.warning)
45
45
  })
46
+
47
+ const lightness = (value: string) => {
48
+ const resolved = value in theme.vars ? theme.vars[value] : value
49
+ const m = /okhsl\(\s*[\d.]+\s+[\d.]+%\s+([\d.]+)%\s*\)/.exec(resolved)
50
+ assert.ok(m, `okhsl literal for ${value}`)
51
+ return Number(m![1]) / 100
52
+ }
53
+
54
+ test("minimal-dark keeps a readable grey ladder: text > muted > thinking > dim > faint", () => {
55
+ const ladder = [theme.vars.text, theme.vars.muted, theme.colors.thinkingText, theme.vars.dim, theme.vars.faint].map(lightness)
56
+ for (let i = 1; i < ladder.length; i++) assert.ok(ladder[i] < ladder[i - 1], `step ${i}: ${ladder.join(" > ")}`)
57
+ // Readable tiers stay at or above L .56 (~4.4:1 on bg L .11, estimating Y ≈ L³).
58
+ assert.ok(lightness(theme.vars.dim) >= 0.56)
59
+ assert.ok(lightness(theme.colors.thinkingText) >= 0.56)
60
+ })
61
+
62
+ test("minimal-dark keeps status hues out of syntax colours", () => {
63
+ const status = new Set([theme.colors.success, theme.colors.warning, theme.colors.error])
64
+ for (const role of ["syntaxFunction", "syntaxNumber", "syntaxString", "syntaxVariable"]) {
65
+ assert.ok(!status.has(theme.colors[role]), `${role} reuses a status colour`)
66
+ }
67
+ })
68
+
69
+ test("minimal-dark draws no slab behind tool rows; only failures get a background", () => {
70
+ assert.equal(theme.colors.toolSuccessBg, "bg")
71
+ assert.equal(theme.colors.toolPendingBg, "bg")
72
+ assert.notEqual(theme.colors.toolErrorBg, "bg")
73
+ })
@@ -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 { compactRenderers, installToolRenderers, isToolKind } 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,14 @@ 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!("codemode", () => 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")
38
+ const codemode = resolver!("codemode", () => fallback) as { renderShell?: string }
39
+ assert.equal(codemode.renderShell, "self")
23
40
  const mine = resolver!("bash", () => fallback) as { renderShell?: string }
24
41
  assert.equal(mine.renderShell, "self")
25
42
  assert.equal(typeof (mine as { renderCall?: unknown }).renderCall, "function")
@@ -53,3 +70,109 @@ test("compactRenderers render a one-line row with the outcome once the result la
53
70
  const [line] = row.render(60)
54
71
  assert.match(line, /✓ bash\s+ls\s+2 lines · 1.2s$/)
55
72
  })
73
+
74
+ function codemodeContext(extra: Record<string, unknown> = {}) {
75
+ return {
76
+ args: { code: "await tools.bash({command:'ls'})\ntext(1)" },
77
+ toolCallId: "c1",
78
+ invalidate() {},
79
+ state: {} as Record<string, unknown>,
80
+ isPartial: false,
81
+ isError: false,
82
+ durationMs: 1200,
83
+ ...extra,
84
+ }
85
+ }
86
+
87
+ const plainTheme = { fg: (_c: string, s: string) => s } as never
88
+ const codemodeResult = (calls: unknown[], output = "a\nb") => ({
89
+ content: [
90
+ { type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
91
+ { type: "text", text: output },
92
+ ],
93
+ details: { calls },
94
+ })
95
+
96
+ test("codemode renders one parent row plus one child row per nested call, no script", () => {
97
+ const r = codemodeRenderers()
98
+ const context = codemodeContext()
99
+ const calls = [
100
+ { name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}', status: "ok", durationMs: 309 },
101
+ { name: "bash", args: '{"command":"cd ~/r && sed -n 1,2p a.md"}', status: "ok", durationMs: 636 },
102
+ ]
103
+ const body = r.renderResult!(codemodeResult(calls) as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
104
+ const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
105
+ assert.equal(lines.length, 3)
106
+ assert.match(lines[0], /✓ code\s+2 calls · plan_tracker, bash\s+2 lines · 1.2s$/)
107
+ assert.match(lines[1], /✓ plan_tracker action=update index=6 status=complete\s+0.3s$/)
108
+ assert.match(lines[2], /✓ bash\s+r › sed -n 1,2p a.md\s+0.6s$/)
109
+ assert.ok(lines.every((line) => !line.includes("await")), "script hidden when collapsed")
110
+ assert.deepEqual(body.render(100), [])
111
+ for (const line of lines) assert.ok(line.length <= 100)
112
+ })
113
+
114
+ test("codemode shows the first failure under the row, and script + output when expanded", () => {
115
+ const r = codemodeRenderers()
116
+ const context = codemodeContext({ durationMs: 274_000 })
117
+ const calls = [{ name: "bash", args: '{"command":"npm test"}', status: "error", durationMs: 274_000, error: "fail 2" }]
118
+ const collapsed = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
119
+ const [row, child] = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
120
+ assert.match(row, /✗ code\s+1 call · bash\s+1 failed · 4m 34s$/)
121
+ assert.match(child, /✗ bash npm test\s+failed · 4m 34s$/)
122
+ assert.match(collapsed.render(100).join("\n"), /│ fail 2/)
123
+
124
+ const open = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: true, isPartial: false } as never, plainTheme, { ...context, expanded: true } as never)
125
+ const text = open.render(100).join("\n")
126
+ assert.match(text, /\/\/ script · 2 lines/)
127
+ assert.match(text, /await tools.bash/)
128
+ assert.match(text, /\/\/ output\n.*TEST_EXIT=1/)
129
+ })
130
+
131
+ test("codemode shows nested calls live while the script is still running", () => {
132
+ const r = codemodeRenderers()
133
+ const context = codemodeContext({ isPartial: true, durationMs: undefined })
134
+ r.renderResult!(codemodeResult([{ name: "bash", args: '{"command":"sleep 5"}', status: "running" }], "") as never, { expanded: false, isPartial: true } as never, plainTheme, context as never)
135
+ const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(80)
136
+ assert.match(lines[0], /^\s+[◐◓] code\s+1 call · bash/)
137
+ assert.match(lines[1], /[◐◓] bash sleep 5/)
138
+ assert.equal((context.state as { done?: boolean }).done, undefined)
139
+ stopLiveWatch()
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
  })
@@ -7,8 +7,8 @@
7
7
  "panel": "okhsl(230 5% 17%)",
8
8
  "text": "okhsl(234 3% 90%)",
9
9
  "muted": "okhsl(229 6% 64%)",
10
- "dim": "okhsl(229 7% 48%)",
11
- "faint": "okhsl(230 6% 34%)",
10
+ "dim": "okhsl(229 7% 56%)",
11
+ "faint": "okhsl(230 6% 36%)",
12
12
  "violet": "okhsl(295 50% 67%)",
13
13
  "violetSoft": "okhsl(295 45% 74%)",
14
14
  "blue": "okhsl(232 54% 67%)",
@@ -29,7 +29,7 @@
29
29
  "muted": "muted",
30
30
  "dim": "dim",
31
31
  "text": "text",
32
- "thinkingText": "okhsl(229 6% 52%)",
32
+ "thinkingText": "okhsl(229 6% 58%)",
33
33
  "selectedBg": "userBg",
34
34
  "scrollbarTrack": "faint",
35
35
  "scrollbarThumb": "dim",
@@ -40,8 +40,8 @@
40
40
  "customMessageBg": "okhsl(295 30% 20%)",
41
41
  "customMessageText": "muted",
42
42
  "customMessageLabel": "violet",
43
- "toolPendingBg": "panel",
44
- "toolSuccessBg": "panel",
43
+ "toolPendingBg": "bg",
44
+ "toolSuccessBg": "bg",
45
45
  "toolErrorBg": "errorBg",
46
46
  "toolTitle": "text",
47
47
  "toolOutput": "dim",
@@ -60,10 +60,10 @@
60
60
  "toolDiffContext": "dim",
61
61
  "syntaxComment": "dim",
62
62
  "syntaxKeyword": "blue",
63
- "syntaxFunction": "yellow",
63
+ "syntaxFunction": "okhsl(265 45% 76%)",
64
64
  "syntaxVariable": "okhsl(202 58% 70%)",
65
65
  "syntaxString": "okhsl(52 67% 70%)",
66
- "syntaxNumber": "green",
66
+ "syntaxNumber": "okhsl(180 40% 68%)",
67
67
  "syntaxType": "violet",
68
68
  "syntaxOperator": "muted",
69
69
  "syntaxPunctuation": "muted",