pi-minimal 0.2.2 → 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.
package/release-notes.md CHANGED
@@ -1,32 +1,31 @@
1
1
  ### Added
2
- - Fenced code renders as a closed box (`╭─ lang ─╮` / `│ code │` / `╰───╯`)
3
- instead of literal ``` lines, keeping syntax highlighting; long lines wrap
4
- inside the box. pi-tui exposes no hook for this, so pi-minimal post-processes
5
- the code token's rendered lines.
2
+ - Versioned, session-scoped footer activity contribution API. Companion extensions
3
+ can publish bounded activity rows after existing hints and tracker summaries
4
+ without replacing the footer. No visible change without a contributor.
5
+ - Icon-first activity rows with pi-minimal's spinner/check/failure colors,
6
+ plain/static modes, overflow handling, optional elapsed time/token metrics,
7
+ and optional task links with independent task-state badges.
8
+ - Snapshot validation, immutable metadata, exclusive slot ownership, stale-update
9
+ protection, and integration documentation.
10
+ - Independent plan-summary contribution slot with violet slanted progress segments
11
+ and exact accepted/total counts, rendered below the input. One blank row
12
+ separates nonempty footer groups; activity rows stay compact. The summary is
13
+ display-only and does not add task storage or infer acceptance from agent runs.
14
+ - Release checks for matching manifest/lockfile versions and a dated changelog entry.
6
15
 
7
- ### Changed
8
- - Chrome margin (status line, footer, turn rule, tool rows) follows pi's
9
- `outputPad` setting so it lines up with transcript text. Set
10
- `editorPaddingX` to the same value for a flush left edge.
11
- - Chrome modules tolerate a stale `chrome` module after `/reload`, which
12
- previously could crash the renderer when the package was updated in place.
13
- - `mdCodeBlockBorder` in `minimal-dark` is a faint grey again (it frames the
14
- box now) instead of background-coloured.
16
+ ### Fixed
17
+ - Dispose activity subscriptions and animation timers across footer replacement
18
+ and shutdown; prevent delayed settle callbacks from reinstalling stale chrome.
19
+ - Synchronize the previously stale lockfile package version with the manifest.
15
20
 
16
21
  ## What's Changed
17
- * Fix trusted publishing: upgrade npm, make Publish re-runnable by @raviy06 in https://github.com/raviy06/pi-minimal/pull/7
18
- * Add Dependabot configuration file by @raviy06 in https://github.com/raviy06/pi-minimal/pull/9
19
- * Create SECURITY.md for security policy by @raviy06 in https://github.com/raviy06/pi-minimal/pull/10
20
- * Pin npm 11 for trusted publishing by @raviy06 in https://github.com/raviy06/pi-minimal/pull/8
21
- * Drop registry-url so npm uses OIDC instead of a placeholder token by @raviy06 in https://github.com/raviy06/pi-minimal/pull/11
22
- * Add repository metadata required for npm provenance by @raviy06 in https://github.com/raviy06/pi-minimal/pull/12
23
- * Frame fenced code, align chrome with outputPad (0.2.2) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/13
22
+ * feat: add cooperative footer activity contributions by @raviy06 in https://github.com/raviy06/pi-minimal/pull/14
24
23
 
25
24
 
26
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
25
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
27
26
 
28
27
  ---
29
28
  **Install / upgrade**
30
29
  ```bash
31
- pi install npm:pi-minimal@0.2.2 # or: pi update --extensions
30
+ pi install npm:pi-minimal@0.2.3 # or: pi update --extensions
32
31
  ```
@@ -0,0 +1,43 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
2
+ import type { ActivityMetrics, ActivityTask } from "./footer-contributions.js"
3
+
4
+ export type ActivityTone = "warning" | "success" | "error" | "muted" | "accent"
5
+ export type ActivityTheme = { fg: (color: ActivityTone, text: string) => string }
6
+
7
+ export function taskPrefix(task: ActivityTask | undefined, plain: boolean, theme: ActivityTheme): string {
8
+ if (!task) return ""
9
+ if (plain) return `[${task.status}] ${task.id} / `
10
+ const badges: Partial<Record<ActivityTask["status"], [string, ActivityTone]>> = {
11
+ blocked: ["!", "warning"], in_review: ["◇", "accent"], done: ["✓", "success"], cancelled: ["⊘", "muted"],
12
+ }
13
+ const badge = badges[task.status]
14
+ return `${badge ? theme.fg(badge[1], badge[0]) + " " : ""}${task.id} · `
15
+ }
16
+
17
+ function elapsed(ms: number | undefined, missing: string): string {
18
+ if (ms === undefined) return missing
19
+ const seconds = Math.floor(ms / 1000)
20
+ if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}m`
21
+ if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
22
+ return `${seconds}s`
23
+ }
24
+ function tokens(value: number | undefined, missing: string): string {
25
+ if (value === undefined) return missing
26
+ if (value >= 999950) return `${(value / 1000000).toFixed(1)}m`
27
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`
28
+ return String(value)
29
+ }
30
+
31
+ /** Metrics never displace activity text: first drop tokens, then elapsed time. */
32
+ export function alignActivityMetrics(left: string, metrics: ActivityMetrics | undefined, width: number, plain: boolean, theme: ActivityTheme): string {
33
+ if (metrics) {
34
+ const missing = plain ? "-" : "—"
35
+ const time = elapsed(metrics.elapsedMs, missing)
36
+ const full = `${time}${plain ? " / " : " · "}${tokens(metrics.tokens, missing)} tok`
37
+ for (const right of [full, time]) {
38
+ const gap = width - visibleWidth(left) - visibleWidth(right)
39
+ if (gap >= 2) return left + " ".repeat(gap) + theme.fg("muted", right)
40
+ }
41
+ }
42
+ return truncateToWidth(left, width)
43
+ }
@@ -0,0 +1,31 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui"
2
+ import { cleanActivityText, type ActivitySnapshot, type ActivityState } from "./footer-contributions.js"
3
+ import { rowGlyph } from "./tool-rows.js"
4
+ import { alignActivityMetrics, taskPrefix, type ActivityTone, type ActivityTheme } from "./footer-activity-details.js"
5
+ const MAX_ROWS = 4
6
+ const indicators: Record<ActivityState, [string, ActivityTone]> = {
7
+ queued: ["○", "muted"], running: ["◐", "warning"], waiting: ["!", "warning"], paused: ["Ⅱ", "muted"],
8
+ stopping: ["!", "warning"], completed: ["✓", "success"], failed: ["✗", "error"], stopped: ["⊘", "muted"], unknown: ["?", "muted"],
9
+ }
10
+ export function activityNeedsAnimation(snapshot: ActivitySnapshot | undefined): boolean {
11
+ return snapshot?.motion === "spinner" && snapshot.format === "icons" && snapshot.rows.slice(0, MAX_ROWS).some(row => row.state === "running")
12
+ }
13
+ export function renderFooterActivity(snapshot: ActivitySnapshot | undefined, width: number, theme: ActivityTheme, frame: 0 | 1 = 0): string[] {
14
+ if (!snapshot || width <= 0) return []
15
+ const parents = new Map(snapshot.rows.map(row => [row.key, row.parentKey]))
16
+ const lines = snapshot.rows.slice(0, MAX_ROWS).map(row => {
17
+ const [fallback, tone] = indicators[row.state]
18
+ const glyph = row.state === "running" ? rowGlyph("running", snapshot.motion === "spinner" ? frame : 0)
19
+ : row.state === "completed" ? rowGlyph("ok", 0) : row.state === "failed" ? rowGlyph("error", 0) : fallback
20
+ const marker = snapshot.format === "plain" ? `[${row.state}]` : glyph
21
+ let depth = 0, parent = row.parentKey
22
+ while (parent && depth < 2) { depth++; parent = parents.get(parent) }
23
+ const reason = row.state === "stopping" && snapshot.format === "icons" ? "Stopping: " : ""
24
+ const task = taskPrefix(row.task, snapshot.format === "plain", theme)
25
+ const text = `${" ".repeat(depth)}${theme.fg(tone, marker)} ${cleanActivityText(row.agent)} ${task}${reason}${cleanActivityText(row.text)}`.trimEnd()
26
+ return alignActivityMetrics(text, row.metrics, width, snapshot.format === "plain", theme)
27
+ })
28
+ const omitted = Math.min(Number.MAX_SAFE_INTEGER, snapshot.omitted + Math.max(0, snapshot.rows.length - MAX_ROWS))
29
+ if (omitted > 0) lines.push(truncateToWidth(theme.fg("muted", `+ ${omitted} more`), width))
30
+ return lines
31
+ }
@@ -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()
@@ -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
+ })