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/CHANGELOG.md +24 -1
- package/README.md +22 -0
- package/dist/footer-activity-details.js +45 -0
- package/dist/footer-activity.js +36 -0
- package/dist/footer-contribution-events.js +35 -0
- package/dist/footer-contributions.js +130 -0
- package/dist/footer-plan.js +41 -0
- package/dist/footer.js +49 -7
- package/dist/index.js +24 -4
- package/doc/footer-contributions.md +132 -0
- package/package.json +1 -1
- package/release-notes.md +20 -21
- package/src/footer-activity-details.ts +43 -0
- package/src/footer-activity.ts +31 -0
- package/src/footer-contribution-events.ts +38 -0
- package/src/footer-contributions.ts +132 -0
- package/src/footer-plan.ts +44 -0
- package/src/footer.ts +43 -7
- package/src/index.ts +23 -4
- package/test/footer-activity.test.ts +93 -0
- package/test/footer-composition.test.ts +129 -0
- package/test/footer-contribution-events.test.ts +67 -0
- package/test/footer-contributions.test.ts +105 -0
- package/test/footer-plan.test.ts +53 -0
- package/test/index.test.ts +35 -2
- package/test/package.test.ts +5 -1
package/release-notes.md
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
### Added
|
|
2
|
-
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
###
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
+
})
|