pi-minimal 0.2.2 → 0.3.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.
@@ -0,0 +1,38 @@
1
+ import type { FooterContributions, RegistrationResult } from "./footer-contributions.js"
2
+
3
+ export const FOOTER_EVENTS = {
4
+ register: "pi-minimal:footer:v1:register",
5
+ ready: "pi-minimal:footer:v1:ready",
6
+ unavailable: "pi-minimal:footer:v1:unavailable",
7
+ } as const
8
+ export type RegisterRequest = {
9
+ version: 1; sessionId: string; owner: string; slot: "activity" | "summary"
10
+ result?: RegistrationResult | { ok: false; code: "not-tui" }
11
+ }
12
+ type Events = {
13
+ on(name: string, handler: (value: unknown) => void): () => void
14
+ emit(name: string, value: unknown): void
15
+ }
16
+
17
+ /** Bind once per session, not once per footer mount. Trusted in-process protocol. */
18
+ export function bindFooterContributionEvents(events: Events, registry: FooterContributions, sessionId: string, mode: string): () => void {
19
+ registry.reset(mode === "tui" ? sessionId : null)
20
+ const unsubscribe = events.on(FOOTER_EVENTS.register, (value) => {
21
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return
22
+ const request = value as Partial<RegisterRequest>
23
+ // A different host may have answered first. Never overwrite its result.
24
+ if (request.result !== undefined) return
25
+ if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary")) request.result = { ok: false, code: "invalid" }
26
+ else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
27
+ else request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request)
28
+ })
29
+ if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] })
30
+ let disposed = false
31
+ return () => {
32
+ if (disposed) return
33
+ disposed = true
34
+ unsubscribe()
35
+ registry.reset(null)
36
+ if (mode === "tui") events.emit(FOOTER_EVENTS.unavailable, { version: 1, sessionId })
37
+ }
38
+ }
@@ -0,0 +1,132 @@
1
+ import { stripVTControlCharacters } from "node:util"
2
+ import { normalizePlanSnapshot } from "./footer-plan.js"
3
+
4
+ export const ACTIVITY_STATES = ["queued", "running", "waiting", "paused", "stopping", "completed", "failed", "stopped", "unknown"] as const
5
+ export type ActivityState = typeof ACTIVITY_STATES[number]
6
+ export type ActivityMetrics = Readonly<{ elapsedMs?: number; tokens?: number }>
7
+ export const TASK_STATUSES = ["todo", "in_progress", "blocked", "in_review", "done", "cancelled"] as const
8
+ export type ActivityTask = Readonly<{ planId: string; id: string; status: typeof TASK_STATUSES[number] }>
9
+ export type ActivityRow = Readonly<{
10
+ key: string; agent: string; state: ActivityState; text: string; parentKey?: string
11
+ metrics?: ActivityMetrics; task?: ActivityTask
12
+ }>
13
+ export type ActivitySnapshot = Readonly<{
14
+ revision: number; rows: readonly ActivityRow[]; omitted: number
15
+ motion: "spinner" | "static"; format: "icons" | "plain"
16
+ }>
17
+ type Failure = { ok: false; code: "invalid" | "wrong-session" | "occupied" | "stale" | "disposed" }
18
+ export type ActivityRegistration = {
19
+ publish(snapshot: unknown): { ok: true } | Failure
20
+ dispose(): void
21
+ }
22
+ export type RegistrationResult = { ok: true; registration: ActivityRegistration } | Failure
23
+
24
+ function record(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value)
26
+ }
27
+ function identity(value: unknown): value is string {
28
+ return typeof value === "string" && /^[a-zA-Z0-9._:@+-]{1,128}$/.test(value)
29
+ }
30
+ export function cleanActivityText(value: string): string {
31
+ return stripVTControlCharacters(value)
32
+ // Also remove bare C0/C1 and bidi controls; no terminal commands from labels.
33
+ // eslint-disable-next-line no-control-regex
34
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ")
35
+ .replace(/\s+/gu, " ").trim()
36
+ }
37
+ function validMetrics(value: unknown): value is ActivityMetrics {
38
+ return record(value) && [value.elapsedMs, value.tokens].every(n => n === undefined || (Number.isSafeInteger(n) && (n as number) >= 0))
39
+ }
40
+ function validTask(value: unknown): value is ActivityTask {
41
+ return record(value) && identity(value.planId) && identity(value.id) && TASK_STATUSES.includes(value.status as ActivityTask["status"])
42
+ }
43
+ function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
44
+ if (!record(input) || !Number.isSafeInteger(input.revision) || (input.revision as number) < 0 ||
45
+ !Number.isSafeInteger(input.omitted) || (input.omitted as number) < 0 ||
46
+ !["spinner", "static"].includes(input.motion as string) || !["icons", "plain"].includes(input.format as string) ||
47
+ !Array.isArray(input.rows) || input.rows.length > 64) return
48
+ const rows: ActivityRow[] = []
49
+ const parents = new Map<string, string | undefined>()
50
+ for (const item of input.rows) {
51
+ if (!record(item) || !identity(item.key) || parents.has(item.key) ||
52
+ typeof item.agent !== "string" || item.agent.length > 96 || !cleanActivityText(item.agent) ||
53
+ typeof item.text !== "string" || item.text.length > 256 ||
54
+ !ACTIVITY_STATES.includes(item.state as ActivityState) ||
55
+ (item.parentKey !== undefined && !identity(item.parentKey)) ||
56
+ (item.metrics !== undefined && !validMetrics(item.metrics)) ||
57
+ (item.task !== undefined && !validTask(item.task))) return
58
+ parents.set(item.key, item.parentKey as string | undefined)
59
+ rows.push(Object.freeze({ key: item.key, agent: cleanActivityText(item.agent), state: item.state as ActivityState,
60
+ text: cleanActivityText(item.text), ...(item.parentKey === undefined ? {} : { parentKey: item.parentKey as string }),
61
+ ...(item.metrics === undefined ? {} : { metrics: Object.freeze({ elapsedMs: (item.metrics as ActivityMetrics).elapsedMs, tokens: (item.metrics as ActivityMetrics).tokens }) }),
62
+ ...(item.task === undefined ? {} : { task: Object.freeze({ planId: (item.task as ActivityTask).planId, id: (item.task as ActivityTask).id, status: (item.task as ActivityTask).status }) }),
63
+ }))
64
+ }
65
+ for (const row of rows) {
66
+ const seen = new Set([row.key])
67
+ let parent = row.parentKey
68
+ while (parent !== undefined) {
69
+ if (!parents.has(parent) || seen.has(parent)) return
70
+ seen.add(parent)
71
+ parent = parents.get(parent)
72
+ }
73
+ }
74
+ return Object.freeze({ revision: input.revision as number, rows: Object.freeze(rows), omitted: input.omitted as number,
75
+ motion: input.motion as ActivitySnapshot["motion"], format: input.format as ActivitySnapshot["format"] })
76
+ }
77
+
78
+ function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
79
+ let sessionId = initialSessionId
80
+ let owner: symbol | undefined
81
+ let snapshot: T | undefined
82
+ const observers = new Set<() => void>()
83
+ const notify = () => {
84
+ for (const observer of [...observers]) {
85
+ try { observer() } catch { /* A disposed UI must not invalidate a successful publish. */ }
86
+ }
87
+ }
88
+ return {
89
+ read: () => snapshot,
90
+ subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
91
+ reset(nextSessionId: string | null) {
92
+ sessionId = nextSessionId; owner = undefined; snapshot = undefined; notify()
93
+ },
94
+ register(input: unknown): RegistrationResult {
95
+ if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
96
+ if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
97
+ if (owner) return { ok: false, code: "occupied" }
98
+ const token = Symbol(input.owner)
99
+ owner = token
100
+ return { ok: true, registration: {
101
+ publish(input: unknown) {
102
+ if (owner !== token) return { ok: false, code: "disposed" }
103
+ const next = normalize(input)
104
+ if (!next) return { ok: false, code: "invalid" }
105
+ if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
106
+ snapshot = next; notify()
107
+ return { ok: true }
108
+ },
109
+ dispose() {
110
+ if (owner !== token) return
111
+ owner = undefined; snapshot = undefined; notify()
112
+ },
113
+ } }
114
+ },
115
+ }
116
+ }
117
+ export function createFooterContributions(initialSessionId: string | null = null) {
118
+ const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
119
+ const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId)
120
+ return {
121
+ read: activity.read,
122
+ readPlan: summary.read,
123
+ register: activity.register,
124
+ registerPlan: summary.register,
125
+ subscribe(observer: () => void) {
126
+ const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer)
127
+ return () => { offActivity(); offSummary() }
128
+ },
129
+ reset(sessionId: string | null) { activity.reset(sessionId); summary.reset(sessionId) },
130
+ }
131
+ }
132
+ export type FooterContributions = ReturnType<typeof createFooterContributions>
@@ -0,0 +1,44 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
2
+ import type { ActivityTheme } from "./footer-activity-details.js"
3
+
4
+ export type PlanSnapshot = Readonly<{
5
+ revision: number
6
+ format: "icons" | "plain"
7
+ plan?: Readonly<{ id: string; accepted: number; total: number; inReview: number; blocked: number }>
8
+ }>
9
+ function record(value: unknown): value is Record<string, unknown> {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value)
11
+ }
12
+ const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0
13
+
14
+ export function normalizePlanSnapshot(value: unknown): PlanSnapshot | undefined {
15
+ if (!record(value) || !count(value.revision) || (value.format !== "icons" && value.format !== "plain")) return
16
+ if (value.plan === undefined) return Object.freeze({ revision: value.revision, format: value.format })
17
+ const plan = value.plan
18
+ if (!record(plan) || typeof plan.id !== "string" || !/^[a-zA-Z0-9._:@+-]{1,128}$/.test(plan.id) ||
19
+ !count(plan.accepted) || !count(plan.total) || !count(plan.inReview) || !count(plan.blocked) ||
20
+ plan.accepted > plan.total || plan.inReview > plan.total - plan.accepted || plan.blocked > plan.total - plan.accepted) return
21
+ return Object.freeze({ revision: value.revision, format: value.format,
22
+ plan: Object.freeze({ id: plan.id, accepted: plan.accepted, total: plan.total, inReview: plan.inReview, blocked: plan.blocked }) })
23
+ }
24
+
25
+ /** Display-only accepted-task counts, never inferred from agent execution. */
26
+ export function renderFooterPlan(snapshot: PlanSnapshot | undefined, width: number, theme: ActivityTheme): string[] {
27
+ if (!snapshot?.plan || width <= 0) return []
28
+ const { id, accepted, total, inReview, blocked } = snapshot.plan
29
+ const plain = snapshot.format === "plain", separator = plain ? " / " : " · "
30
+ const label = theme.fg("accent", `Plan ${id}`)
31
+ if (!total) return [truncateToWidth(`${label}${separator}No tasks yet`, width)]
32
+ const slots = total <= 12 ? total : 10
33
+ const filled = total <= 12 ? accepted : Math.floor(accepted / total * slots)
34
+ const bar = theme.fg("accent", (plain ? "#" : "▰").repeat(filled)) + theme.fg("muted", (plain ? "." : "▱").repeat(slots - filled))
35
+ const counts = `${accepted}/${total}`
36
+ const attention = [inReview ? `${plain ? "" : "◇ "}${inReview} review` : "", blocked ? `! ${blocked} blocked` : ""].filter(Boolean)
37
+ const suffix = attention.length ? separator + attention.join(separator) : ""
38
+ for (const text of [`${label}${separator}${bar} ${counts}${suffix}`, `${label}${separator}${counts}${suffix}`, `${label}${separator}${counts}`]) {
39
+ if (visibleWidth(text) <= width) return [text]
40
+ }
41
+ // Exact counts outrank a long plan label on extremely narrow terminals.
42
+ const room = width - visibleWidth(counts) - 1
43
+ return [room > 0 ? `${truncateToWidth(label, room)} ${counts}` : truncateToWidth(counts, width)]
44
+ }
package/src/footer.ts CHANGED
@@ -3,6 +3,10 @@ import { keyHint } from "@earendil-works/pi-coding-agent"
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui"
4
4
  import * as chrome from "./chrome.js"
5
5
  import { formatTrackerLines } from "./tracker.js"
6
+ import type { FooterContributions } from "./footer-contributions.js"
7
+ import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js"
8
+ import { createLiveWatch } from "./live-watch.js"
9
+ import { renderFooterPlan } from "./footer-plan.js"
6
10
 
7
11
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
8
12
  const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
@@ -30,11 +34,34 @@ export function footerHints(sources: FooterSources, hints: string[]): string[] {
30
34
  return out
31
35
  }
32
36
 
33
- export function installFooter(ctx: ExtensionContext, sources: FooterSources): void {
37
+ export function installFooter(ctx: ExtensionContext, sources: FooterSources, contributions?: FooterContributions): () => void {
38
+ let stopMount = () => {}
39
+ let stopped = false
34
40
  ctx.ui.setFooter((tui, theme, footerData) => {
35
- const unsub = footerData.onBranchChange(() => tui.requestRender())
41
+ stopMount()
42
+ if (stopped) return { render: () => [], invalidate() {} }
43
+ const live = createLiveWatch(400)
44
+ let disposed = false
45
+ let activityHasSpace = true
46
+ const redraw = () => { if (!disposed) tui.requestRender() }
47
+ const syncAnimation = () => {
48
+ if (disposed) return
49
+ if (activityHasSpace && activityNeedsAnimation(contributions?.read())) live.watch("footer", redraw)
50
+ else live.unwatch("footer")
51
+ }
52
+ const update = () => { syncAnimation(); redraw() }
53
+ const unsub = footerData.onBranchChange(redraw)
54
+ const unsubscribeActivity = contributions?.subscribe(update)
55
+ stopMount = () => {
56
+ if (disposed) return
57
+ disposed = true
58
+ unsub()
59
+ unsubscribeActivity?.()
60
+ live.stop()
61
+ }
62
+ update()
36
63
  return {
37
- dispose: unsub,
64
+ dispose: stopMount,
38
65
  invalidate() {},
39
66
  render(width: number): string[] {
40
67
  const inner = Math.max(8, width - margin() * 2)
@@ -48,11 +75,20 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources): vo
48
75
  const line = theme.fg("dim", hints.join(" · "))
49
76
  out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
50
77
  }
51
- for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
52
- out.push(`${" ".repeat(margin())}${truncateToWidth(extra, inner, "")}`)
53
- }
54
- return out
78
+ const activityWidth = Math.max(0, width - margin() * 2)
79
+ const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme)
80
+ const summary = (plan.length ? plan : formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string }))
81
+ .map(line => `${" ".repeat(margin())}${truncateToWidth(line, plan.length ? activityWidth : inner, "")}`)
82
+ activityHasSpace = activityWidth > 0
83
+ syncAnimation()
84
+ const activity = renderFooterActivity(contributions?.read(), activityWidth, theme, live.frame)
85
+ .map(line => `${" ".repeat(margin())}${line}`)
86
+ // Preserve legacy-only layout; contributions opt into the spaced layout.
87
+ if (!plan.length && !activity.length) return [...out, ...summary]
88
+ const groups = [out, summary, activity].filter(group => group.length > 0)
89
+ return ["", ...groups.flatMap((group, index) => index === 0 ? group : ["", ...group])]
55
90
  },
56
91
  }
57
92
  })
93
+ return () => { stopped = true; stopMount() }
58
94
  }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
2
2
  import { loadPiMinimalConfig } from "./config.js"
3
3
  import { installHeader } from "./header.js"
4
4
  import { installFooter } from "./footer.js"
5
+ import { createFooterContributions } from "./footer-contributions.js"
6
+ import { bindFooterContributionEvents } from "./footer-contribution-events.js"
5
7
  import { installCwd } from "./cwd.js"
6
8
  import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
7
9
  import { hideGauntletWidgets } from "./tracker.js"
@@ -21,6 +23,10 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
21
23
  installToolRenderers(pi)
22
24
  installTranscript(pi)
23
25
  installCodeBox()
26
+ const contributions = createFooterContributions()
27
+ let stopFooter = () => {}
28
+ let stopContributionEvents = () => {}
29
+ let sessionActive = false
24
30
  let fastTimer: ReturnType<typeof setInterval> | undefined
25
31
  let slowTimer: ReturnType<typeof setInterval> | undefined
26
32
  let stealTimer: ReturnType<typeof setInterval> | undefined
@@ -124,11 +130,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
124
130
  }
125
131
 
126
132
  const installChromeFooter = (ctx: ExtensionContext) => {
127
- installFooter(ctx, {
133
+ stopFooter()
134
+ stopFooter = installFooter(ctx, {
128
135
  busy: () => busy,
129
136
  editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
130
137
  contextPct: () => contextPct(ctx),
131
- })
138
+ }, contributions)
132
139
  }
133
140
 
134
141
  const installStatusLine = (ctx: ExtensionContext) => {
@@ -147,6 +154,11 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
147
154
  }
148
155
 
149
156
  pi.on("session_start", async (_event, ctx) => {
157
+ stopFooter()
158
+ stopContributionEvents()
159
+ sessionActive = true
160
+ generation += 1
161
+ stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, ctx.sessionManager.getSessionId(), ctx.mode)
150
162
  if (ctx.mode !== "tui") return
151
163
  const config = loadPiMinimalConfig({
152
164
  agentDir: getAgentDir(),
@@ -168,8 +180,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
168
180
  pinChrome(ctx, "session_start")
169
181
  ctx.ui.setHiddenThinkingLabel("◆ Thought")
170
182
  ctx.ui.setWorkingIndicator()
171
- setTimeout(() => pinChrome(ctx, "session_settle"), 0)
172
- setTimeout(() => pinChrome(ctx, "session_settle"), 50)
183
+ const sessionGeneration = generation
184
+ const settle = () => {
185
+ if (sessionActive && generation === sessionGeneration) pinChrome(ctx, "session_settle")
186
+ }
187
+ setTimeout(settle, 0)
188
+ setTimeout(settle, 50)
173
189
  if (!stealTimer) {
174
190
  stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200)
175
191
  }
@@ -195,6 +211,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
195
211
  })
196
212
 
197
213
  pi.on("session_shutdown", async () => {
214
+ sessionActive = false
215
+ stopFooter()
216
+ stopContributionEvents()
198
217
  busy = false
199
218
  stopTimers()
200
219
  stopWorkingStatus()
package/src/tools.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
2
- import { Container, truncateToWidth } from "@earendil-works/pi-tui"
2
+ 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
+ import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
5
6
  import { createLiveWatch } from "./live-watch.js"
6
7
  import {
7
8
 
@@ -203,6 +204,141 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
203
204
  }
204
205
  }
205
206
 
207
+ /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
208
+ const NESTED_PREVIEW = 8
209
+ /** Width of the nested tool-name column. */
210
+ const NESTED_NAME_MAX = 14
211
+
212
+ type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
213
+
214
+ function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
215
+ if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
216
+ if (call.status === "error") return { glyph: "\u2717", color: "error" }
217
+ if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
218
+ return { glyph: "\u2713", color: "success" }
219
+ }
220
+
221
+ /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
222
+ function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
223
+ const inner = Math.max(0, width - indent.length - 2)
224
+ const rule = theme.fg("borderMuted", "\u2502")
225
+ let lines = text.split("\n")
226
+ if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
227
+ const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
228
+ return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`))
229
+ }
230
+
231
+ /**
232
+ * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
233
+ * child row per nested call. The script and its output appear only when expanded, undecorated,
234
+ * so syntax colours never compete with status colours in the transcript.
235
+ */
236
+ export function codemodeRenderers(): ToolRenderers {
237
+ return {
238
+ renderShell: "self",
239
+ renderCall(args, theme, rawContext) {
240
+ const context = rawContext as RenderContext & { state: CodemodeState }
241
+ const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
242
+ context.state.code = code
243
+ const id = context.toolCallId || "codemode"
244
+ const running = rowIsRunning(context)
245
+ context.state.running = running
246
+ if (running) {
247
+ context.state.startedAt ??= Date.now()
248
+ live.watch(id, context.invalidate)
249
+ } else live.unwatch(id)
250
+ return {
251
+ render(width: number): string[] {
252
+ const state = context.state
253
+ const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
254
+ const frame = status === "running" && live.has(id) ? live.frame : 0
255
+ const calls = state.calls ?? []
256
+ const right =
257
+ 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
+ ]
271
+ const expanded = Boolean(context.expanded)
272
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
273
+ const childIndent = " ".repeat(rowIndent() + 4)
274
+ if (shown.length < calls.length) {
275
+ lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
276
+ }
277
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
278
+ for (const call of shown) {
279
+ const { glyph, color } = nestedGlyph(call, frame)
280
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
281
+ const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
282
+ const room = Math.max(8, width - childIndent.length)
283
+ // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
284
+ const prefixWidth = 2 + nameWidth + 1
285
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
286
+ const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
287
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
288
+ lines.push(
289
+ childIndent +
290
+ theme.fg(color, glyph) +
291
+ " " +
292
+ theme.fg("dim", name) +
293
+ " " +
294
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
295
+ " ".repeat(gap) +
296
+ theme.fg(call.status === "error" ? "error" : "dim", dur),
297
+ )
298
+ }
299
+ return lines
300
+ },
301
+ invalidate() {},
302
+ }
303
+ },
304
+ renderResult(result, options, theme, rawContext) {
305
+ const context = rawContext as RenderContext & { state: CodemodeState }
306
+ const typed = result as ToolResultLike
307
+ context.state.calls = nestedCalls(typed)
308
+ if (!context.isPartial) {
309
+ context.state.done = true
310
+ context.state.running = false
311
+ const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
312
+ context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
313
+ context.state.output = scriptOutput(typed)
314
+ live.unwatch(context.toolCallId || "codemode")
315
+ }
316
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
317
+ // paints afterwards, so the call row's lazy render() already sees these calls. Calling
318
+ // invalidate() would re-enter updateDisplay() and recurse.
319
+ const container = new Container()
320
+ const expanded = Boolean((options as RenderOptions).expanded)
321
+ const errorLine = context.state.outcome?.errorLine
322
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
323
+ if (expanded) {
324
+ const indent = " ".repeat(rowIndent() + 4)
325
+ const code = context.state.code ?? ""
326
+ const output = context.state.output ?? ""
327
+ container.addChild({
328
+ render(width: number): string[] {
329
+ const lines: string[] = []
330
+ if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
331
+ if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
332
+ return lines
333
+ },
334
+ invalidate() {},
335
+ })
336
+ }
337
+ return container
338
+ },
339
+ }
340
+ }
341
+
206
342
  /**
207
343
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
208
344
  * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
@@ -210,6 +346,7 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
210
346
  export function installToolRenderers(pi: ExtensionAPI): void {
211
347
  if (typeof pi.registerToolRenderer !== "function") return
212
348
  pi.registerToolRenderer((toolName, next) => {
349
+ if (toolName === "codemode") return codemodeRenderers()
213
350
  if (!isToolKind(toolName)) return next()
214
351
  return compactRenderers(toolName)
215
352
  })
@@ -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")
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
+ })