pi-minimal 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/README.md +24 -2
- package/dist/codemode-rows.js +109 -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/dist/tools.js +139 -1
- package/doc/footer-contributions.md +132 -0
- package/package.json +1 -1
- package/release-notes.md +20 -20
- package/src/codemode-rows.ts +121 -0
- 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/src/tools.ts +138 -1
- package/test/codemode-rows.test.ts +61 -0
- 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/test/theme.test.ts +28 -0
- package/test/tools.test.ts +72 -2
- package/themes/minimal-dark.json +7 -7
|
@@ -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()
|
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
|
|
2
|
-
import { Container, truncateToWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
3
3
|
import { rowIsRunning } from "./activity.js"
|
|
4
4
|
import * as chrome from "./chrome.js"
|
|
5
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
|
|
5
6
|
import { createLiveWatch } from "./live-watch.js"
|
|
6
7
|
import {
|
|
7
8
|
|
|
@@ -203,6 +204,141 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/** Max nested calls shown under a collapsed codemode row; the rest are counted. */
|
|
208
|
+
const NESTED_PREVIEW = 8
|
|
209
|
+
/** Width of the nested tool-name column. */
|
|
210
|
+
const NESTED_NAME_MAX = 14
|
|
211
|
+
|
|
212
|
+
type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
|
|
213
|
+
|
|
214
|
+
function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
|
|
215
|
+
if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
|
|
216
|
+
if (call.status === "error") return { glyph: "\u2717", color: "error" }
|
|
217
|
+
if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
|
|
218
|
+
return { glyph: "\u2713", color: "success" }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
|
|
222
|
+
function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
|
|
223
|
+
const inner = Math.max(0, width - indent.length - 2)
|
|
224
|
+
const rule = theme.fg("borderMuted", "\u2502")
|
|
225
|
+
let lines = text.split("\n")
|
|
226
|
+
if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
|
|
227
|
+
const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
|
|
228
|
+
return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
|
|
233
|
+
* child row per nested call. The script and its output appear only when expanded, undecorated,
|
|
234
|
+
* so syntax colours never compete with status colours in the transcript.
|
|
235
|
+
*/
|
|
236
|
+
export function codemodeRenderers(): ToolRenderers {
|
|
237
|
+
return {
|
|
238
|
+
renderShell: "self",
|
|
239
|
+
renderCall(args, theme, rawContext) {
|
|
240
|
+
const context = rawContext as RenderContext & { state: CodemodeState }
|
|
241
|
+
const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
|
|
242
|
+
context.state.code = code
|
|
243
|
+
const id = context.toolCallId || "codemode"
|
|
244
|
+
const running = rowIsRunning(context)
|
|
245
|
+
context.state.running = running
|
|
246
|
+
if (running) {
|
|
247
|
+
context.state.startedAt ??= Date.now()
|
|
248
|
+
live.watch(id, context.invalidate)
|
|
249
|
+
} else live.unwatch(id)
|
|
250
|
+
return {
|
|
251
|
+
render(width: number): string[] {
|
|
252
|
+
const state = context.state
|
|
253
|
+
const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
|
|
254
|
+
const frame = status === "running" && live.has(id) ? live.frame : 0
|
|
255
|
+
const calls = state.calls ?? []
|
|
256
|
+
const right =
|
|
257
|
+
status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
|
|
258
|
+
const inner = Math.max(8, width - rowIndent())
|
|
259
|
+
const row = layoutRow(inner, "code", callSummary(calls), right)
|
|
260
|
+
const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
|
|
261
|
+
const lines = [
|
|
262
|
+
" ".repeat(rowIndent()) +
|
|
263
|
+
theme.fg(statusColor(status), rowGlyph(status, frame)) +
|
|
264
|
+
" " +
|
|
265
|
+
theme.fg("dim", row.verb) +
|
|
266
|
+
" " +
|
|
267
|
+
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
268
|
+
" ".repeat(row.gap) +
|
|
269
|
+
theme.fg(rightColor, row.outcome),
|
|
270
|
+
]
|
|
271
|
+
const expanded = Boolean(context.expanded)
|
|
272
|
+
const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
|
|
273
|
+
const childIndent = " ".repeat(rowIndent() + 4)
|
|
274
|
+
if (shown.length < calls.length) {
|
|
275
|
+
lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
|
|
276
|
+
}
|
|
277
|
+
const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
|
|
278
|
+
for (const call of shown) {
|
|
279
|
+
const { glyph, color } = nestedGlyph(call, frame)
|
|
280
|
+
const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
|
|
281
|
+
const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
|
|
282
|
+
const room = Math.max(8, width - childIndent.length)
|
|
283
|
+
// Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
|
|
284
|
+
const prefixWidth = 2 + nameWidth + 1
|
|
285
|
+
const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
|
|
286
|
+
const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
|
|
287
|
+
const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
|
|
288
|
+
lines.push(
|
|
289
|
+
childIndent +
|
|
290
|
+
theme.fg(color, glyph) +
|
|
291
|
+
" " +
|
|
292
|
+
theme.fg("dim", name) +
|
|
293
|
+
" " +
|
|
294
|
+
theme.fg(call.status === "error" ? "text" : "muted", item) +
|
|
295
|
+
" ".repeat(gap) +
|
|
296
|
+
theme.fg(call.status === "error" ? "error" : "dim", dur),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
return lines
|
|
300
|
+
},
|
|
301
|
+
invalidate() {},
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
renderResult(result, options, theme, rawContext) {
|
|
305
|
+
const context = rawContext as RenderContext & { state: CodemodeState }
|
|
306
|
+
const typed = result as ToolResultLike
|
|
307
|
+
context.state.calls = nestedCalls(typed)
|
|
308
|
+
if (!context.isPartial) {
|
|
309
|
+
context.state.done = true
|
|
310
|
+
context.state.running = false
|
|
311
|
+
const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
|
|
312
|
+
context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
|
|
313
|
+
context.state.output = scriptOutput(typed)
|
|
314
|
+
live.unwatch(context.toolCallId || "codemode")
|
|
315
|
+
}
|
|
316
|
+
// No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
|
|
317
|
+
// paints afterwards, so the call row's lazy render() already sees these calls. Calling
|
|
318
|
+
// invalidate() would re-enter updateDisplay() and recurse.
|
|
319
|
+
const container = new Container()
|
|
320
|
+
const expanded = Boolean((options as RenderOptions).expanded)
|
|
321
|
+
const errorLine = context.state.outcome?.errorLine
|
|
322
|
+
if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
|
|
323
|
+
if (expanded) {
|
|
324
|
+
const indent = " ".repeat(rowIndent() + 4)
|
|
325
|
+
const code = context.state.code ?? ""
|
|
326
|
+
const output = context.state.output ?? ""
|
|
327
|
+
container.addChild({
|
|
328
|
+
render(width: number): string[] {
|
|
329
|
+
const lines: string[] = []
|
|
330
|
+
if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
|
|
331
|
+
if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
|
|
332
|
+
return lines
|
|
333
|
+
},
|
|
334
|
+
invalidate() {},
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
return container
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
206
342
|
/**
|
|
207
343
|
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
208
344
|
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
@@ -210,6 +346,7 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
|
210
346
|
export function installToolRenderers(pi: ExtensionAPI): void {
|
|
211
347
|
if (typeof pi.registerToolRenderer !== "function") return
|
|
212
348
|
pi.registerToolRenderer((toolName, next) => {
|
|
349
|
+
if (toolName === "codemode") return codemodeRenderers()
|
|
213
350
|
if (!isToolKind(toolName)) return next()
|
|
214
351
|
return compactRenderers(toolName)
|
|
215
352
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { callSummary, codemodeOutcome, nestedCalls, nestedItem, parseArgs, scriptOutput } from "../src/codemode-rows.js"
|
|
4
|
+
|
|
5
|
+
const result = (text: string, calls: unknown[] = []) => ({
|
|
6
|
+
content: [
|
|
7
|
+
{ type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
|
|
8
|
+
{ type: "text", text },
|
|
9
|
+
],
|
|
10
|
+
details: { calls },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("parseArgs reads complete JSON and recovers keys from a preview cut at 200 chars", () => {
|
|
14
|
+
assert.deepEqual(parseArgs('{"action":"update","index":6}'), { action: "update", index: 6 })
|
|
15
|
+
const cut = '{"command":"cd ~/repo && sed -n 168,180p docs/a.md && echo \\"===\\" && rg -n x docs/b.md | sed -n 1,30p && ech...'
|
|
16
|
+
assert.equal(parseArgs(cut).command, 'cd ~/repo && sed -n 168,180p docs/a.md && echo "===" && rg -n x docs/b.md | sed -n 1,30p && ech...')
|
|
17
|
+
assert.deepEqual(parseArgs(""), {})
|
|
18
|
+
assert.deepEqual(parseArgs("[1,2]"), {})
|
|
19
|
+
// A cut inside an escape must not throw.
|
|
20
|
+
assert.equal(typeof parseArgs('{"command":"echo \\').command, "string")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("nestedItem reads built-ins like their top-level rows and others as key=value", () => {
|
|
24
|
+
assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status")
|
|
25
|
+
assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
|
|
26
|
+
assert.equal(
|
|
27
|
+
nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
|
|
28
|
+
"action=update index=6 status=complete",
|
|
29
|
+
)
|
|
30
|
+
assert.equal(nestedItem({ name: "mcp__x__q", args: '{"query":"a","nested":{"b":1},"limit":5,"extra":1,"more":2}' }), "query=a limit=5 extra=1")
|
|
31
|
+
assert.equal(nestedItem({ name: "subagents_enable", args: "{}" }), "")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("callSummary names tools in first-call order with repeat counts", () => {
|
|
35
|
+
assert.equal(callSummary([]), "script")
|
|
36
|
+
assert.equal(callSummary([{ name: "bash" }]), "1 call · bash")
|
|
37
|
+
assert.equal(
|
|
38
|
+
callSummary([{ name: "plan_tracker" }, { name: "plan_tracker" }, { name: "bash" }]),
|
|
39
|
+
"3 calls · plan_tracker ×2, bash",
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("scriptOutput drops pi-core's script header; nestedCalls tolerates missing details", () => {
|
|
44
|
+
assert.equal(scriptOutput(result("hello\n")), "hello")
|
|
45
|
+
assert.deepEqual(nestedCalls({ content: [] }), [])
|
|
46
|
+
assert.deepEqual(nestedCalls({ content: [], details: { calls: [null, { name: "bash" }] } }), [{ name: "bash" }])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("codemodeOutcome counts output lines and surfaces the first failure", () => {
|
|
50
|
+
assert.deepEqual(codemodeOutcome(result("a\nb\n\nc"), false, 1200), { text: "3 lines · 1.2s", failed: false })
|
|
51
|
+
assert.deepEqual(codemodeOutcome(result(""), false, 300), { text: "ok · 0.3s", failed: false })
|
|
52
|
+
const failed = codemodeOutcome(
|
|
53
|
+
result("TEST_EXIT=1", [{ name: "bash", status: "ok" }, { name: "bash", status: "error", error: "\nboom: exit 2\nmore" }]),
|
|
54
|
+
false,
|
|
55
|
+
274_000,
|
|
56
|
+
)
|
|
57
|
+
assert.deepEqual(failed, { text: "1 failed · 4m 34s", failed: true, errorLine: "boom: exit 2" })
|
|
58
|
+
const scriptError = codemodeOutcome(result("partial\nScript error: ReferenceError: x is not defined"), true, 50)
|
|
59
|
+
assert.equal(scriptError.failed, true)
|
|
60
|
+
assert.equal(scriptError.errorLine, "ReferenceError: x is not defined")
|
|
61
|
+
})
|