pi-minimal 0.2.1 → 0.2.3

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,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
@@ -1,8 +1,16 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent"
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui"
4
- import { CHROME_MARGIN } from "./chrome.js"
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"
10
+
11
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
12
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
13
+
6
14
 
7
15
  export type FooterSources = {
8
16
  /** True while an agent run is in progress. */
@@ -26,14 +34,37 @@ export function footerHints(sources: FooterSources, hints: string[]): string[] {
26
34
  return out
27
35
  }
28
36
 
29
- 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
30
40
  ctx.ui.setFooter((tui, theme, footerData) => {
31
- 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()
32
63
  return {
33
- dispose: unsub,
64
+ dispose: stopMount,
34
65
  invalidate() {},
35
66
  render(width: number): string[] {
36
- const inner = Math.max(8, width - CHROME_MARGIN * 2)
67
+ const inner = Math.max(8, width - margin() * 2)
37
68
  const out: string[] = []
38
69
  const hints = footerHints(sources, [
39
70
  keyHint("tui.input.submit", "send"),
@@ -42,13 +73,22 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources): vo
42
73
  ].filter(Boolean))
43
74
  if (hints.length > 0) {
44
75
  const line = theme.fg("dim", hints.join(" · "))
45
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(line, inner, "")}`)
46
- }
47
- for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
48
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`)
76
+ out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
49
77
  }
50
- 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])]
51
90
  },
52
91
  }
53
92
  })
93
+ return () => { stopped = true; stopMount() }
54
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"
@@ -11,12 +13,20 @@ import { installWorkingStatus } from "./working-status.js"
11
13
  import { installTurnRule } from "./turn-rule.js"
12
14
  import { installTranscript } from "./transcript.js"
13
15
  import { createGitState } from "./git-state.js"
16
+ import { installCodeBox } from "./code-box.js"
17
+ import { setChromeMargin } from "./chrome.js"
18
+ import { SettingsManager } from "@earendil-works/pi-coding-agent"
14
19
 
15
20
  export default function piMinimalExtension(pi: ExtensionAPI): void {
16
21
  const stopWorkingStatus = installWorkingStatus(pi)
17
22
  const stopTurnRule = installTurnRule(pi)
18
23
  installToolRenderers(pi)
19
24
  installTranscript(pi)
25
+ installCodeBox()
26
+ const contributions = createFooterContributions()
27
+ let stopFooter = () => {}
28
+ let stopContributionEvents = () => {}
29
+ let sessionActive = false
20
30
  let fastTimer: ReturnType<typeof setInterval> | undefined
21
31
  let slowTimer: ReturnType<typeof setInterval> | undefined
22
32
  let stealTimer: ReturnType<typeof setInterval> | undefined
@@ -120,11 +130,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
120
130
  }
121
131
 
122
132
  const installChromeFooter = (ctx: ExtensionContext) => {
123
- installFooter(ctx, {
133
+ stopFooter()
134
+ stopFooter = installFooter(ctx, {
124
135
  busy: () => busy,
125
136
  editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
126
137
  contextPct: () => contextPct(ctx),
127
- })
138
+ }, contributions)
128
139
  }
129
140
 
130
141
  const installStatusLine = (ctx: ExtensionContext) => {
@@ -143,6 +154,11 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
143
154
  }
144
155
 
145
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)
146
162
  if (ctx.mode !== "tui") return
147
163
  const config = loadPiMinimalConfig({
148
164
  agentDir: getAgentDir(),
@@ -151,6 +167,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
151
167
  })
152
168
  hideWidgets = config.hideGauntletWidgets
153
169
  currentCwd = ctx.cwd
170
+ try {
171
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
172
+ setChromeMargin(settings.getOutputPad())
173
+ } catch {
174
+ // Keep the default margin when settings are unavailable.
175
+ }
154
176
  installHeader(ctx)
155
177
  installChromeFooter(ctx)
156
178
  installStatusLine(ctx)
@@ -158,8 +180,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
158
180
  pinChrome(ctx, "session_start")
159
181
  ctx.ui.setHiddenThinkingLabel("◆ Thought")
160
182
  ctx.ui.setWorkingIndicator()
161
- setTimeout(() => pinChrome(ctx, "session_settle"), 0)
162
- 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)
163
189
  if (!stealTimer) {
164
190
  stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200)
165
191
  }
@@ -185,6 +211,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
185
211
  })
186
212
 
187
213
  pi.on("session_shutdown", async () => {
214
+ sessionActive = false
215
+ stopFooter()
216
+ stopContributionEvents()
188
217
  busy = false
189
218
  stopTimers()
190
219
  stopWorkingStatus()
package/src/tools.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
2
2
  import { Container, truncateToWidth } from "@earendil-works/pi-tui"
3
3
  import { rowIsRunning } from "./activity.js"
4
+ import * as chrome from "./chrome.js"
4
5
  import { createLiveWatch } from "./live-watch.js"
5
6
  import {
7
+
6
8
  bashBody,
7
9
  formatDuration,
8
10
  formatOutcome,
@@ -17,6 +19,9 @@ import {
17
19
  type ToolResultLike,
18
20
  } from "./tool-rows.js"
19
21
 
22
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
23
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
24
+
20
25
  type RenderOptions = {
21
26
  expanded?: boolean
22
27
  }
@@ -42,8 +47,8 @@ type RenderContext = {
42
47
 
43
48
  const live = createLiveWatch(400)
44
49
 
45
- /** Left indent of every tool row, in cells. */
46
- const ROW_INDENT = 2
50
+ /** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
51
+ const rowIndent = () => margin() + 1
47
52
  /** Max lines of output shown when a row is expanded. */
48
53
  const EXPANDED_MAX_LINES = 400
49
54
 
@@ -82,7 +87,7 @@ function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): stri
82
87
  function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
83
88
  const lines = expandedLines(kind, result)
84
89
  if (lines.length === 0) return new Container()
85
- const indent = " ".repeat(ROW_INDENT + 2)
90
+ const indent = " ".repeat(rowIndent() + 2)
86
91
  const rule = theme.fg("borderMuted", "│")
87
92
  return {
88
93
  render(width: number): string[] {
@@ -95,7 +100,7 @@ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme:
95
100
 
96
101
  function errorLineBlock(line: string | undefined, theme: Theme) {
97
102
  if (!line) return undefined
98
- const indent = " ".repeat(ROW_INDENT + 2)
103
+ const indent = " ".repeat(rowIndent() + 2)
99
104
  const rule = theme.fg("error", "│")
100
105
  return {
101
106
  render(width: number): string[] {
@@ -135,7 +140,7 @@ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, c
135
140
  right = right ? `${stats} · ${right}` : stats
136
141
  }
137
142
  }
138
- const inner = Math.max(8, width - ROW_INDENT)
143
+ const inner = Math.max(8, width - rowIndent())
139
144
  const row = layoutRow(inner, kind, item, right)
140
145
 
141
146
  let coloredRight: string
@@ -148,7 +153,7 @@ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, c
148
153
  } else coloredRight = theme.fg("dim", row.outcome)
149
154
 
150
155
  const line =
151
- " ".repeat(ROW_INDENT) +
156
+ " ".repeat(rowIndent()) +
152
157
  theme.fg(statusColor(status), glyph) +
153
158
  " " +
154
159
  theme.fg("dim", row.verb) +
package/src/turn-rule.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
3
- import { CHROME_MARGIN } from "./chrome.js"
3
+ import * as chrome from "./chrome.js"
4
4
  import { formatElapsed } from "./working-status.js"
5
5
 
6
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
7
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
8
+
9
+
6
10
  export const TURN_ENTRY_TYPE = "pi-minimal.turn"
7
11
 
8
12
  export type TurnSummary = {
@@ -83,10 +87,10 @@ export function installTurnRule(pi: ExtensionAPI): () => void {
83
87
  const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error"
84
88
  return {
85
89
  render(width: number): string[] {
86
- const inner = Math.max(0, width - CHROME_MARGIN * 2)
90
+ const inner = Math.max(0, width - margin() * 2)
87
91
  const rule = ruleLine(inner, label)
88
92
  const colored = rule.replace(label, theme.fg("dim", label))
89
- return ["", `${" ".repeat(CHROME_MARGIN)}${theme.fg(color, colored)}`]
93
+ return ["", `${" ".repeat(margin())}${theme.fg(color, colored)}`]
90
94
  },
91
95
  invalidate() {},
92
96
  }
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { Markdown } from "@earendil-works/pi-tui"
4
+ import { frameCodeLines, installCodeBox } from "../src/code-box.js"
5
+
6
+ const theme = { codeBlockBorder: (s: string) => s }
7
+ const ESC = String.fromCharCode(27)
8
+ const stripAnsi = (s: string) => s.split(ESC).map((p, i) => (i === 0 ? p : p.replace(/^\[[0-9;]*m/, ""))).join("")
9
+
10
+ test("frameCodeLines replaces fences with a closed box and keeps the trailer", () => {
11
+ const out = frameCodeLines(["```json", ' {"a":1}', " ", "```", ""], "json", 20, theme)
12
+ assert.equal(out.length, 5)
13
+ assert.ok(out[0].startsWith("╭─ json ─") && out[0].endsWith("╮"))
14
+ assert.equal(out[1], '│ {"a":1} │')
15
+ assert.equal(out[2], "│ │")
16
+ assert.ok(out[3].startsWith("╰─") && out[3].endsWith("╯"))
17
+ for (const l of out.slice(0, 4)) assert.equal(l.length, 20, l)
18
+ assert.equal(out[4], "")
19
+ })
20
+
21
+ test("frameCodeLines wraps long lines inside the box", () => {
22
+ const out = frameCodeLines(["```", " " + "x".repeat(30), "```"], undefined, 20, theme)
23
+ assert.equal(out.length, 4, out.join("\n"))
24
+ assert.equal(out[1], `│ ${"x".repeat(16)} │`)
25
+ assert.equal(out[2], `│ ${"x".repeat(14)} │`)
26
+ for (const l of out) assert.equal(l.length, 20, l)
27
+ })
28
+
29
+ test("frameCodeLines handles empty blocks and missing language", () => {
30
+ const out = frameCodeLines(["```", "```"], undefined, 10, theme)
31
+ assert.deepEqual(out.map((l) => l[0]), ["╭", "│", "╰"])
32
+ assert.deepEqual(out.map((l) => l[l.length - 1]), ["╮", "│", "╯"])
33
+ assert.ok(!out[0].includes(" "), "no language label")
34
+ assert.deepEqual(frameCodeLines(["x"], undefined, 10, theme), ["x"])
35
+ })
36
+
37
+ test("installCodeBox patches pi-tui's Markdown renderer idempotently", () => {
38
+ assert.equal(installCodeBox(), true)
39
+ assert.equal(installCodeBox(), true)
40
+ // A reload re-points the installed wrapper at the current framer.
41
+ const slot = Symbol.for("pi-minimal.code-box.frame")
42
+ const slots = globalThis as unknown as Record<symbol, unknown>
43
+ assert.equal(slots[slot], frameCodeLines)
44
+ const id = (s: string) => s
45
+ const mdTheme = {
46
+ heading: id, link: id, linkUrl: id, code: id, codeBlock: id, codeBlockBorder: id, quote: id, quoteBorder: id,
47
+ hr: id, listBullet: id, bold: id, italic: id, underline: id, strikethrough: id,
48
+ }
49
+ const md = new Markdown("before\n\n```ts\nconst x = 1\n```\n\nafter", 0, 0, mdTheme as never)
50
+ const lines = md.render(40).map(stripAnsi)
51
+ assert.ok(lines.some((l) => l.startsWith("╭─ ts ") && l.endsWith("╮")), lines.join("\n"))
52
+ assert.ok(lines.some((l) => l.startsWith("│ const x = 1") && l.endsWith("│")), lines.join("\n"))
53
+ assert.ok(lines.some((l) => l.startsWith("╰─") && l.endsWith("╯")), lines.join("\n"))
54
+ assert.ok(!lines.some((l) => l.includes("```")), "no literal fences remain")
55
+ })
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { visibleWidth } from "@earendil-works/pi-tui"
4
+ import { renderFooterActivity, activityNeedsAnimation } from "../src/footer-activity.js"
5
+ import { ACTIVITY_STATES, type ActivitySnapshot } from "../src/footer-contributions.js"
6
+
7
+ const theme = { fg: (_color: string, text: string) => text }
8
+ const snapshot = (): ActivitySnapshot => ({ revision: 0, rows: [{ key: "1", agent: "worker", state: "running", text: "Last: tests" }], omitted: 0, motion: "spinner", format: "icons" })
9
+
10
+ test("matches pi-minimal spinner/check/failure and explicit other state glyphs", () => {
11
+ const glyphs = ["○", "◐", "!", "Ⅱ", "!", "✓", "✗", "⊘", "?"]
12
+ for (const [i, state] of ACTIVITY_STATES.entries()) {
13
+ const input = { ...snapshot(), rows: [{ ...snapshot().rows[0], state }] }
14
+ const [line] = renderFooterActivity(input, 80, theme, 0)
15
+ assert.ok(line.startsWith(glyphs[i]+" "), line)
16
+ const plain = renderFooterActivity({ ...input, format: "plain" }, 80, theme, 1)[0]
17
+ assert.ok(plain.includes(`[${state}]`))
18
+ }
19
+ assert.ok(renderFooterActivity(snapshot(), 80, theme, 1)[0].startsWith("◓"))
20
+ assert.ok(renderFooterActivity({ ...snapshot(), motion: "static" }, 80, theme, 1)[0].startsWith("◐"))
21
+ })
22
+
23
+ test("caps rows, reports overflow, and only animates visible running rows", () => {
24
+ const rows = Array.from({ length: 6 }, (_, i) => ({ key: `${i}`, agent: "agent", state: "completed" as const, text: "Done" }))
25
+ const input: ActivitySnapshot = { ...snapshot(), rows, omitted: 2 }
26
+ const lines = renderFooterActivity(input, 80, theme)
27
+ assert.equal(lines.length, 5)
28
+ assert.ok(lines[4].includes("4 more"))
29
+ assert.equal(activityNeedsAnimation(input), false)
30
+ assert.equal(activityNeedsAnimation({ ...input, rows: [...rows, snapshot().rows[0]] }), false)
31
+ assert.equal(activityNeedsAnimation(snapshot()), true)
32
+ assert.equal(activityNeedsAnimation({ ...snapshot(), format: "plain" }), false)
33
+ assert.equal(activityNeedsAnimation({ ...snapshot(), motion: "static" }), false)
34
+ assert.deepEqual(renderFooterActivity(undefined, 80, theme), [])
35
+ assert.deepEqual(renderFooterActivity({ ...snapshot(), rows: [] }, 80, theme), [])
36
+ })
37
+
38
+ test("bounds Unicode visible widths, strips controls, and limits nested indentation", () => {
39
+ const input: ActivitySnapshot = { ...snapshot(), rows: [{ key: "p", agent: "工人👩‍💻é", state: "running", text: "\u001b[31mhello\nworld\u001b[0m" },
40
+ { key: "c", parentKey: "p", agent: "child", state: "running", text: "test" }] }
41
+ for (const width of [0, 1, 2, 20, 60, 80, 120]) {
42
+ for (const format of ["icons", "plain"] as const) {
43
+ for (const line of renderFooterActivity({ ...input, format }, width, theme)) {
44
+ assert.ok(visibleWidth(line) <= width, `${width}: ${line}`)
45
+ assert.ok(!line.includes("\n") && !line.includes("\u001b[31m"))
46
+ // pi-tui adds its own safe SGR reset when truncating plain text.
47
+ assert.ok(!line.replaceAll("\u001b[0m", "").includes("\u001b"))
48
+ }
49
+ }
50
+ }
51
+ assert.ok(renderFooterActivity(input, 80, theme)[1].startsWith(" ◐"))
52
+ })
53
+
54
+ test("right-aligns metrics and drops tokens then time before truncating activity", () => {
55
+ const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], metrics: { elapsedMs: 134000, tokens: 8200 }, task: { planId: "P12", id: "T3", status: "in_progress" } }] }
56
+ const full = renderFooterActivity(input, 80, theme)[0]
57
+ assert.ok(full.includes("T3 · Last: tests"))
58
+ assert.ok(full.endsWith("2m 14s · 8.2k tok"))
59
+ assert.equal(visibleWidth(full), 80)
60
+ const timeOnly = renderFooterActivity(input, 40, theme)[0]
61
+ assert.ok(timeOnly.endsWith("2m 14s"), timeOnly)
62
+ assert.ok(!timeOnly.includes("tok"))
63
+ const narrow = renderFooterActivity(input, 30, theme)[0]
64
+ assert.ok(narrow.includes("T3 · Last: tests"))
65
+ assert.ok(!narrow.includes("2m"))
66
+ })
67
+
68
+ test("missing metrics are not zero and completed elapsed values do not advance", () => {
69
+ const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], state: "completed", metrics: {} }] }
70
+ assert.ok(renderFooterActivity(input, 80, theme)[0].endsWith("— · — tok"))
71
+ const ended: ActivitySnapshot = { ...input, rows: [{ ...input.rows[0], metrics: { elapsedMs: 66000, tokens: 0 } }] }
72
+ assert.ok(renderFooterActivity(ended, 80, theme, 0)[0].endsWith("1m 06s · 0 tok"))
73
+ assert.equal(renderFooterActivity(ended, 80, theme, 0)[0], renderFooterActivity(ended, 80, theme, 1)[0])
74
+ })
75
+
76
+ test("task status is independent of agent status and plain mode keeps task labels", () => {
77
+ for (const [status, marker] of [["blocked", "!"], ["in_review", "◇"], ["done", "✓"]] as const) {
78
+ const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], state: "completed", task: { planId: "P12", id: "T3", status } }] }
79
+ assert.ok(renderFooterActivity(input, 80, theme)[0].includes(`${marker} T3`))
80
+ const plain = renderFooterActivity({ ...input, format: "plain" }, 100, theme)[0]
81
+ assert.ok(plain.includes(`[${status}] T3`))
82
+ assert.ok(plain.includes("[completed]"))
83
+ }
84
+ })
85
+
86
+ test("uses current semantic theme and never caches colored text", () => {
87
+ const colors: string[] = []
88
+ const themed = { fg: (color: string, text: string) => { colors.push(color); return text } }
89
+ renderFooterActivity(snapshot(), 80, themed)
90
+ assert.ok(colors.includes("warning"))
91
+ const changed = renderFooterActivity(snapshot(), 80, { fg: (_c, text) => `<${text}>` })
92
+ assert.ok(changed[0].includes("<◐>"))
93
+ })