pi-minimal 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -1
- package/README.md +25 -1
- package/dist/chrome.js +15 -0
- package/dist/code-box.js +69 -0
- package/dist/cwd.js +5 -3
- 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 +54 -10
- package/dist/index.js +35 -4
- package/dist/tools.js +9 -6
- package/dist/turn-rule.js +5 -3
- package/doc/footer-contributions.md +132 -0
- package/package.json +1 -1
- package/release-notes.md +20 -19
- package/src/chrome.ts +18 -0
- package/src/code-box.ts +86 -0
- package/src/cwd.ts +7 -3
- 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 +50 -10
- package/src/index.ts +33 -4
- package/src/tools.ts +11 -6
- package/src/turn-rule.ts +7 -3
- package/test/code-box.test.ts +55 -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 +2 -2
- package/themes/minimal-dark.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util"
|
|
2
|
+
import { normalizePlanSnapshot } from "./footer-plan.js"
|
|
3
|
+
|
|
4
|
+
export const ACTIVITY_STATES = ["queued", "running", "waiting", "paused", "stopping", "completed", "failed", "stopped", "unknown"] as const
|
|
5
|
+
export type ActivityState = typeof ACTIVITY_STATES[number]
|
|
6
|
+
export type ActivityMetrics = Readonly<{ elapsedMs?: number; tokens?: number }>
|
|
7
|
+
export const TASK_STATUSES = ["todo", "in_progress", "blocked", "in_review", "done", "cancelled"] as const
|
|
8
|
+
export type ActivityTask = Readonly<{ planId: string; id: string; status: typeof TASK_STATUSES[number] }>
|
|
9
|
+
export type ActivityRow = Readonly<{
|
|
10
|
+
key: string; agent: string; state: ActivityState; text: string; parentKey?: string
|
|
11
|
+
metrics?: ActivityMetrics; task?: ActivityTask
|
|
12
|
+
}>
|
|
13
|
+
export type ActivitySnapshot = Readonly<{
|
|
14
|
+
revision: number; rows: readonly ActivityRow[]; omitted: number
|
|
15
|
+
motion: "spinner" | "static"; format: "icons" | "plain"
|
|
16
|
+
}>
|
|
17
|
+
type Failure = { ok: false; code: "invalid" | "wrong-session" | "occupied" | "stale" | "disposed" }
|
|
18
|
+
export type ActivityRegistration = {
|
|
19
|
+
publish(snapshot: unknown): { ok: true } | Failure
|
|
20
|
+
dispose(): void
|
|
21
|
+
}
|
|
22
|
+
export type RegistrationResult = { ok: true; registration: ActivityRegistration } | Failure
|
|
23
|
+
|
|
24
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
function identity(value: unknown): value is string {
|
|
28
|
+
return typeof value === "string" && /^[a-zA-Z0-9._:@+-]{1,128}$/.test(value)
|
|
29
|
+
}
|
|
30
|
+
export function cleanActivityText(value: string): string {
|
|
31
|
+
return stripVTControlCharacters(value)
|
|
32
|
+
// Also remove bare C0/C1 and bidi controls; no terminal commands from labels.
|
|
33
|
+
// eslint-disable-next-line no-control-regex
|
|
34
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, " ")
|
|
35
|
+
.replace(/\s+/gu, " ").trim()
|
|
36
|
+
}
|
|
37
|
+
function validMetrics(value: unknown): value is ActivityMetrics {
|
|
38
|
+
return record(value) && [value.elapsedMs, value.tokens].every(n => n === undefined || (Number.isSafeInteger(n) && (n as number) >= 0))
|
|
39
|
+
}
|
|
40
|
+
function validTask(value: unknown): value is ActivityTask {
|
|
41
|
+
return record(value) && identity(value.planId) && identity(value.id) && TASK_STATUSES.includes(value.status as ActivityTask["status"])
|
|
42
|
+
}
|
|
43
|
+
function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
|
|
44
|
+
if (!record(input) || !Number.isSafeInteger(input.revision) || (input.revision as number) < 0 ||
|
|
45
|
+
!Number.isSafeInteger(input.omitted) || (input.omitted as number) < 0 ||
|
|
46
|
+
!["spinner", "static"].includes(input.motion as string) || !["icons", "plain"].includes(input.format as string) ||
|
|
47
|
+
!Array.isArray(input.rows) || input.rows.length > 64) return
|
|
48
|
+
const rows: ActivityRow[] = []
|
|
49
|
+
const parents = new Map<string, string | undefined>()
|
|
50
|
+
for (const item of input.rows) {
|
|
51
|
+
if (!record(item) || !identity(item.key) || parents.has(item.key) ||
|
|
52
|
+
typeof item.agent !== "string" || item.agent.length > 96 || !cleanActivityText(item.agent) ||
|
|
53
|
+
typeof item.text !== "string" || item.text.length > 256 ||
|
|
54
|
+
!ACTIVITY_STATES.includes(item.state as ActivityState) ||
|
|
55
|
+
(item.parentKey !== undefined && !identity(item.parentKey)) ||
|
|
56
|
+
(item.metrics !== undefined && !validMetrics(item.metrics)) ||
|
|
57
|
+
(item.task !== undefined && !validTask(item.task))) return
|
|
58
|
+
parents.set(item.key, item.parentKey as string | undefined)
|
|
59
|
+
rows.push(Object.freeze({ key: item.key, agent: cleanActivityText(item.agent), state: item.state as ActivityState,
|
|
60
|
+
text: cleanActivityText(item.text), ...(item.parentKey === undefined ? {} : { parentKey: item.parentKey as string }),
|
|
61
|
+
...(item.metrics === undefined ? {} : { metrics: Object.freeze({ elapsedMs: (item.metrics as ActivityMetrics).elapsedMs, tokens: (item.metrics as ActivityMetrics).tokens }) }),
|
|
62
|
+
...(item.task === undefined ? {} : { task: Object.freeze({ planId: (item.task as ActivityTask).planId, id: (item.task as ActivityTask).id, status: (item.task as ActivityTask).status }) }),
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const seen = new Set([row.key])
|
|
67
|
+
let parent = row.parentKey
|
|
68
|
+
while (parent !== undefined) {
|
|
69
|
+
if (!parents.has(parent) || seen.has(parent)) return
|
|
70
|
+
seen.add(parent)
|
|
71
|
+
parent = parents.get(parent)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({ revision: input.revision as number, rows: Object.freeze(rows), omitted: input.omitted as number,
|
|
75
|
+
motion: input.motion as ActivitySnapshot["motion"], format: input.format as ActivitySnapshot["format"] })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
|
|
79
|
+
let sessionId = initialSessionId
|
|
80
|
+
let owner: symbol | undefined
|
|
81
|
+
let snapshot: T | undefined
|
|
82
|
+
const observers = new Set<() => void>()
|
|
83
|
+
const notify = () => {
|
|
84
|
+
for (const observer of [...observers]) {
|
|
85
|
+
try { observer() } catch { /* A disposed UI must not invalidate a successful publish. */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
read: () => snapshot,
|
|
90
|
+
subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
|
|
91
|
+
reset(nextSessionId: string | null) {
|
|
92
|
+
sessionId = nextSessionId; owner = undefined; snapshot = undefined; notify()
|
|
93
|
+
},
|
|
94
|
+
register(input: unknown): RegistrationResult {
|
|
95
|
+
if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
|
|
96
|
+
if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
|
|
97
|
+
if (owner) return { ok: false, code: "occupied" }
|
|
98
|
+
const token = Symbol(input.owner)
|
|
99
|
+
owner = token
|
|
100
|
+
return { ok: true, registration: {
|
|
101
|
+
publish(input: unknown) {
|
|
102
|
+
if (owner !== token) return { ok: false, code: "disposed" }
|
|
103
|
+
const next = normalize(input)
|
|
104
|
+
if (!next) return { ok: false, code: "invalid" }
|
|
105
|
+
if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
|
|
106
|
+
snapshot = next; notify()
|
|
107
|
+
return { ok: true }
|
|
108
|
+
},
|
|
109
|
+
dispose() {
|
|
110
|
+
if (owner !== token) return
|
|
111
|
+
owner = undefined; snapshot = undefined; notify()
|
|
112
|
+
},
|
|
113
|
+
} }
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function createFooterContributions(initialSessionId: string | null = null) {
|
|
118
|
+
const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
|
|
119
|
+
const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId)
|
|
120
|
+
return {
|
|
121
|
+
read: activity.read,
|
|
122
|
+
readPlan: summary.read,
|
|
123
|
+
register: activity.register,
|
|
124
|
+
registerPlan: summary.register,
|
|
125
|
+
subscribe(observer: () => void) {
|
|
126
|
+
const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer)
|
|
127
|
+
return () => { offActivity(); offSummary() }
|
|
128
|
+
},
|
|
129
|
+
reset(sessionId: string | null) { activity.reset(sessionId); summary.reset(sessionId) },
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export type FooterContributions = ReturnType<typeof createFooterContributions>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
import type { ActivityTheme } from "./footer-activity-details.js"
|
|
3
|
+
|
|
4
|
+
export type PlanSnapshot = Readonly<{
|
|
5
|
+
revision: number
|
|
6
|
+
format: "icons" | "plain"
|
|
7
|
+
plan?: Readonly<{ id: string; accepted: number; total: number; inReview: number; blocked: number }>
|
|
8
|
+
}>
|
|
9
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
11
|
+
}
|
|
12
|
+
const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0
|
|
13
|
+
|
|
14
|
+
export function normalizePlanSnapshot(value: unknown): PlanSnapshot | undefined {
|
|
15
|
+
if (!record(value) || !count(value.revision) || (value.format !== "icons" && value.format !== "plain")) return
|
|
16
|
+
if (value.plan === undefined) return Object.freeze({ revision: value.revision, format: value.format })
|
|
17
|
+
const plan = value.plan
|
|
18
|
+
if (!record(plan) || typeof plan.id !== "string" || !/^[a-zA-Z0-9._:@+-]{1,128}$/.test(plan.id) ||
|
|
19
|
+
!count(plan.accepted) || !count(plan.total) || !count(plan.inReview) || !count(plan.blocked) ||
|
|
20
|
+
plan.accepted > plan.total || plan.inReview > plan.total - plan.accepted || plan.blocked > plan.total - plan.accepted) return
|
|
21
|
+
return Object.freeze({ revision: value.revision, format: value.format,
|
|
22
|
+
plan: Object.freeze({ id: plan.id, accepted: plan.accepted, total: plan.total, inReview: plan.inReview, blocked: plan.blocked }) })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Display-only accepted-task counts, never inferred from agent execution. */
|
|
26
|
+
export function renderFooterPlan(snapshot: PlanSnapshot | undefined, width: number, theme: ActivityTheme): string[] {
|
|
27
|
+
if (!snapshot?.plan || width <= 0) return []
|
|
28
|
+
const { id, accepted, total, inReview, blocked } = snapshot.plan
|
|
29
|
+
const plain = snapshot.format === "plain", separator = plain ? " / " : " · "
|
|
30
|
+
const label = theme.fg("accent", `Plan ${id}`)
|
|
31
|
+
if (!total) return [truncateToWidth(`${label}${separator}No tasks yet`, width)]
|
|
32
|
+
const slots = total <= 12 ? total : 10
|
|
33
|
+
const filled = total <= 12 ? accepted : Math.floor(accepted / total * slots)
|
|
34
|
+
const bar = theme.fg("accent", (plain ? "#" : "▰").repeat(filled)) + theme.fg("muted", (plain ? "." : "▱").repeat(slots - filled))
|
|
35
|
+
const counts = `${accepted}/${total}`
|
|
36
|
+
const attention = [inReview ? `${plain ? "" : "◇ "}${inReview} review` : "", blocked ? `! ${blocked} blocked` : ""].filter(Boolean)
|
|
37
|
+
const suffix = attention.length ? separator + attention.join(separator) : ""
|
|
38
|
+
for (const text of [`${label}${separator}${bar} ${counts}${suffix}`, `${label}${separator}${counts}${suffix}`, `${label}${separator}${counts}`]) {
|
|
39
|
+
if (visibleWidth(text) <= width) return [text]
|
|
40
|
+
}
|
|
41
|
+
// Exact counts outrank a long plan label on extremely narrow terminals.
|
|
42
|
+
const room = width - visibleWidth(counts) - 1
|
|
43
|
+
return [room > 0 ? `${truncateToWidth(label, room)} ${counts}` : truncateToWidth(counts, width)]
|
|
44
|
+
}
|
package/src/footer.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent"
|
|
3
3
|
import { truncateToWidth } from "@earendil-works/pi-tui"
|
|
4
|
-
import
|
|
4
|
+
import * as chrome from "./chrome.js"
|
|
5
5
|
import { formatTrackerLines } from "./tracker.js"
|
|
6
|
+
import type { FooterContributions } from "./footer-contributions.js"
|
|
7
|
+
import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js"
|
|
8
|
+
import { createLiveWatch } from "./live-watch.js"
|
|
9
|
+
import { renderFooterPlan } from "./footer-plan.js"
|
|
10
|
+
|
|
11
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
12
|
+
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
13
|
+
|
|
6
14
|
|
|
7
15
|
export type FooterSources = {
|
|
8
16
|
/** True while an agent run is in progress. */
|
|
@@ -26,14 +34,37 @@ export function footerHints(sources: FooterSources, hints: string[]): string[] {
|
|
|
26
34
|
return out
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
export function installFooter(ctx: ExtensionContext, sources: FooterSources): void {
|
|
37
|
+
export function installFooter(ctx: ExtensionContext, sources: FooterSources, contributions?: FooterContributions): () => void {
|
|
38
|
+
let stopMount = () => {}
|
|
39
|
+
let stopped = false
|
|
30
40
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
31
|
-
|
|
41
|
+
stopMount()
|
|
42
|
+
if (stopped) return { render: () => [], invalidate() {} }
|
|
43
|
+
const live = createLiveWatch(400)
|
|
44
|
+
let disposed = false
|
|
45
|
+
let activityHasSpace = true
|
|
46
|
+
const redraw = () => { if (!disposed) tui.requestRender() }
|
|
47
|
+
const syncAnimation = () => {
|
|
48
|
+
if (disposed) return
|
|
49
|
+
if (activityHasSpace && activityNeedsAnimation(contributions?.read())) live.watch("footer", redraw)
|
|
50
|
+
else live.unwatch("footer")
|
|
51
|
+
}
|
|
52
|
+
const update = () => { syncAnimation(); redraw() }
|
|
53
|
+
const unsub = footerData.onBranchChange(redraw)
|
|
54
|
+
const unsubscribeActivity = contributions?.subscribe(update)
|
|
55
|
+
stopMount = () => {
|
|
56
|
+
if (disposed) return
|
|
57
|
+
disposed = true
|
|
58
|
+
unsub()
|
|
59
|
+
unsubscribeActivity?.()
|
|
60
|
+
live.stop()
|
|
61
|
+
}
|
|
62
|
+
update()
|
|
32
63
|
return {
|
|
33
|
-
dispose:
|
|
64
|
+
dispose: stopMount,
|
|
34
65
|
invalidate() {},
|
|
35
66
|
render(width: number): string[] {
|
|
36
|
-
const inner = Math.max(8, width -
|
|
67
|
+
const inner = Math.max(8, width - margin() * 2)
|
|
37
68
|
const out: string[] = []
|
|
38
69
|
const hints = footerHints(sources, [
|
|
39
70
|
keyHint("tui.input.submit", "send"),
|
|
@@ -42,13 +73,22 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources): vo
|
|
|
42
73
|
].filter(Boolean))
|
|
43
74
|
if (hints.length > 0) {
|
|
44
75
|
const line = theme.fg("dim", hints.join(" · "))
|
|
45
|
-
out.push(`${" ".repeat(
|
|
46
|
-
}
|
|
47
|
-
for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
|
|
48
|
-
out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`)
|
|
76
|
+
out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
|
|
49
77
|
}
|
|
50
|
-
|
|
78
|
+
const activityWidth = Math.max(0, width - margin() * 2)
|
|
79
|
+
const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme)
|
|
80
|
+
const summary = (plan.length ? plan : formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string }))
|
|
81
|
+
.map(line => `${" ".repeat(margin())}${truncateToWidth(line, plan.length ? activityWidth : inner, "")}`)
|
|
82
|
+
activityHasSpace = activityWidth > 0
|
|
83
|
+
syncAnimation()
|
|
84
|
+
const activity = renderFooterActivity(contributions?.read(), activityWidth, theme, live.frame)
|
|
85
|
+
.map(line => `${" ".repeat(margin())}${line}`)
|
|
86
|
+
// Preserve legacy-only layout; contributions opt into the spaced layout.
|
|
87
|
+
if (!plan.length && !activity.length) return [...out, ...summary]
|
|
88
|
+
const groups = [out, summary, activity].filter(group => group.length > 0)
|
|
89
|
+
return ["", ...groups.flatMap((group, index) => index === 0 ? group : ["", ...group])]
|
|
51
90
|
},
|
|
52
91
|
}
|
|
53
92
|
})
|
|
93
|
+
return () => { stopped = true; stopMount() }
|
|
54
94
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
|
|
|
2
2
|
import { loadPiMinimalConfig } from "./config.js"
|
|
3
3
|
import { installHeader } from "./header.js"
|
|
4
4
|
import { installFooter } from "./footer.js"
|
|
5
|
+
import { createFooterContributions } from "./footer-contributions.js"
|
|
6
|
+
import { bindFooterContributionEvents } from "./footer-contribution-events.js"
|
|
5
7
|
import { installCwd } from "./cwd.js"
|
|
6
8
|
import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
|
|
7
9
|
import { hideGauntletWidgets } from "./tracker.js"
|
|
@@ -11,12 +13,20 @@ import { installWorkingStatus } from "./working-status.js"
|
|
|
11
13
|
import { installTurnRule } from "./turn-rule.js"
|
|
12
14
|
import { installTranscript } from "./transcript.js"
|
|
13
15
|
import { createGitState } from "./git-state.js"
|
|
16
|
+
import { installCodeBox } from "./code-box.js"
|
|
17
|
+
import { setChromeMargin } from "./chrome.js"
|
|
18
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent"
|
|
14
19
|
|
|
15
20
|
export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
16
21
|
const stopWorkingStatus = installWorkingStatus(pi)
|
|
17
22
|
const stopTurnRule = installTurnRule(pi)
|
|
18
23
|
installToolRenderers(pi)
|
|
19
24
|
installTranscript(pi)
|
|
25
|
+
installCodeBox()
|
|
26
|
+
const contributions = createFooterContributions()
|
|
27
|
+
let stopFooter = () => {}
|
|
28
|
+
let stopContributionEvents = () => {}
|
|
29
|
+
let sessionActive = false
|
|
20
30
|
let fastTimer: ReturnType<typeof setInterval> | undefined
|
|
21
31
|
let slowTimer: ReturnType<typeof setInterval> | undefined
|
|
22
32
|
let stealTimer: ReturnType<typeof setInterval> | undefined
|
|
@@ -120,11 +130,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
const installChromeFooter = (ctx: ExtensionContext) => {
|
|
123
|
-
|
|
133
|
+
stopFooter()
|
|
134
|
+
stopFooter = installFooter(ctx, {
|
|
124
135
|
busy: () => busy,
|
|
125
136
|
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
126
137
|
contextPct: () => contextPct(ctx),
|
|
127
|
-
})
|
|
138
|
+
}, contributions)
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
const installStatusLine = (ctx: ExtensionContext) => {
|
|
@@ -143,6 +154,11 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
pi.on("session_start", async (_event, ctx) => {
|
|
157
|
+
stopFooter()
|
|
158
|
+
stopContributionEvents()
|
|
159
|
+
sessionActive = true
|
|
160
|
+
generation += 1
|
|
161
|
+
stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, ctx.sessionManager.getSessionId(), ctx.mode)
|
|
146
162
|
if (ctx.mode !== "tui") return
|
|
147
163
|
const config = loadPiMinimalConfig({
|
|
148
164
|
agentDir: getAgentDir(),
|
|
@@ -151,6 +167,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
151
167
|
})
|
|
152
168
|
hideWidgets = config.hideGauntletWidgets
|
|
153
169
|
currentCwd = ctx.cwd
|
|
170
|
+
try {
|
|
171
|
+
const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
|
|
172
|
+
setChromeMargin(settings.getOutputPad())
|
|
173
|
+
} catch {
|
|
174
|
+
// Keep the default margin when settings are unavailable.
|
|
175
|
+
}
|
|
154
176
|
installHeader(ctx)
|
|
155
177
|
installChromeFooter(ctx)
|
|
156
178
|
installStatusLine(ctx)
|
|
@@ -158,8 +180,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
158
180
|
pinChrome(ctx, "session_start")
|
|
159
181
|
ctx.ui.setHiddenThinkingLabel("◆ Thought")
|
|
160
182
|
ctx.ui.setWorkingIndicator()
|
|
161
|
-
|
|
162
|
-
|
|
183
|
+
const sessionGeneration = generation
|
|
184
|
+
const settle = () => {
|
|
185
|
+
if (sessionActive && generation === sessionGeneration) pinChrome(ctx, "session_settle")
|
|
186
|
+
}
|
|
187
|
+
setTimeout(settle, 0)
|
|
188
|
+
setTimeout(settle, 50)
|
|
163
189
|
if (!stealTimer) {
|
|
164
190
|
stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200)
|
|
165
191
|
}
|
|
@@ -185,6 +211,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
185
211
|
})
|
|
186
212
|
|
|
187
213
|
pi.on("session_shutdown", async () => {
|
|
214
|
+
sessionActive = false
|
|
215
|
+
stopFooter()
|
|
216
|
+
stopContributionEvents()
|
|
188
217
|
busy = false
|
|
189
218
|
stopTimers()
|
|
190
219
|
stopWorkingStatus()
|
package/src/tools.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { Container, truncateToWidth } from "@earendil-works/pi-tui"
|
|
3
3
|
import { rowIsRunning } from "./activity.js"
|
|
4
|
+
import * as chrome from "./chrome.js"
|
|
4
5
|
import { createLiveWatch } from "./live-watch.js"
|
|
5
6
|
import {
|
|
7
|
+
|
|
6
8
|
bashBody,
|
|
7
9
|
formatDuration,
|
|
8
10
|
formatOutcome,
|
|
@@ -17,6 +19,9 @@ import {
|
|
|
17
19
|
type ToolResultLike,
|
|
18
20
|
} from "./tool-rows.js"
|
|
19
21
|
|
|
22
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
23
|
+
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
24
|
+
|
|
20
25
|
type RenderOptions = {
|
|
21
26
|
expanded?: boolean
|
|
22
27
|
}
|
|
@@ -42,8 +47,8 @@ type RenderContext = {
|
|
|
42
47
|
|
|
43
48
|
const live = createLiveWatch(400)
|
|
44
49
|
|
|
45
|
-
/** Left indent of every tool row
|
|
46
|
-
const
|
|
50
|
+
/** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
|
|
51
|
+
const rowIndent = () => margin() + 1
|
|
47
52
|
/** Max lines of output shown when a row is expanded. */
|
|
48
53
|
const EXPANDED_MAX_LINES = 400
|
|
49
54
|
|
|
@@ -82,7 +87,7 @@ function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): stri
|
|
|
82
87
|
function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
|
|
83
88
|
const lines = expandedLines(kind, result)
|
|
84
89
|
if (lines.length === 0) return new Container()
|
|
85
|
-
const indent = " ".repeat(
|
|
90
|
+
const indent = " ".repeat(rowIndent() + 2)
|
|
86
91
|
const rule = theme.fg("borderMuted", "│")
|
|
87
92
|
return {
|
|
88
93
|
render(width: number): string[] {
|
|
@@ -95,7 +100,7 @@ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme:
|
|
|
95
100
|
|
|
96
101
|
function errorLineBlock(line: string | undefined, theme: Theme) {
|
|
97
102
|
if (!line) return undefined
|
|
98
|
-
const indent = " ".repeat(
|
|
103
|
+
const indent = " ".repeat(rowIndent() + 2)
|
|
99
104
|
const rule = theme.fg("error", "│")
|
|
100
105
|
return {
|
|
101
106
|
render(width: number): string[] {
|
|
@@ -135,7 +140,7 @@ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, c
|
|
|
135
140
|
right = right ? `${stats} · ${right}` : stats
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
|
-
const inner = Math.max(8, width -
|
|
143
|
+
const inner = Math.max(8, width - rowIndent())
|
|
139
144
|
const row = layoutRow(inner, kind, item, right)
|
|
140
145
|
|
|
141
146
|
let coloredRight: string
|
|
@@ -148,7 +153,7 @@ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, c
|
|
|
148
153
|
} else coloredRight = theme.fg("dim", row.outcome)
|
|
149
154
|
|
|
150
155
|
const line =
|
|
151
|
-
" ".repeat(
|
|
156
|
+
" ".repeat(rowIndent()) +
|
|
152
157
|
theme.fg(statusColor(status), glyph) +
|
|
153
158
|
" " +
|
|
154
159
|
theme.fg("dim", row.verb) +
|
package/src/turn-rule.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
3
|
-
import
|
|
3
|
+
import * as chrome from "./chrome.js"
|
|
4
4
|
import { formatElapsed } from "./working-status.js"
|
|
5
5
|
|
|
6
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
7
|
+
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
8
|
+
|
|
9
|
+
|
|
6
10
|
export const TURN_ENTRY_TYPE = "pi-minimal.turn"
|
|
7
11
|
|
|
8
12
|
export type TurnSummary = {
|
|
@@ -83,10 +87,10 @@ export function installTurnRule(pi: ExtensionAPI): () => void {
|
|
|
83
87
|
const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error"
|
|
84
88
|
return {
|
|
85
89
|
render(width: number): string[] {
|
|
86
|
-
const inner = Math.max(0, width -
|
|
90
|
+
const inner = Math.max(0, width - margin() * 2)
|
|
87
91
|
const rule = ruleLine(inner, label)
|
|
88
92
|
const colored = rule.replace(label, theme.fg("dim", label))
|
|
89
|
-
return ["", `${" ".repeat(
|
|
93
|
+
return ["", `${" ".repeat(margin())}${theme.fg(color, colored)}`]
|
|
90
94
|
},
|
|
91
95
|
invalidate() {},
|
|
92
96
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { Markdown } from "@earendil-works/pi-tui"
|
|
4
|
+
import { frameCodeLines, installCodeBox } from "../src/code-box.js"
|
|
5
|
+
|
|
6
|
+
const theme = { codeBlockBorder: (s: string) => s }
|
|
7
|
+
const ESC = String.fromCharCode(27)
|
|
8
|
+
const stripAnsi = (s: string) => s.split(ESC).map((p, i) => (i === 0 ? p : p.replace(/^\[[0-9;]*m/, ""))).join("")
|
|
9
|
+
|
|
10
|
+
test("frameCodeLines replaces fences with a closed box and keeps the trailer", () => {
|
|
11
|
+
const out = frameCodeLines(["```json", ' {"a":1}', " ", "```", ""], "json", 20, theme)
|
|
12
|
+
assert.equal(out.length, 5)
|
|
13
|
+
assert.ok(out[0].startsWith("╭─ json ─") && out[0].endsWith("╮"))
|
|
14
|
+
assert.equal(out[1], '│ {"a":1} │')
|
|
15
|
+
assert.equal(out[2], "│ │")
|
|
16
|
+
assert.ok(out[3].startsWith("╰─") && out[3].endsWith("╯"))
|
|
17
|
+
for (const l of out.slice(0, 4)) assert.equal(l.length, 20, l)
|
|
18
|
+
assert.equal(out[4], "")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("frameCodeLines wraps long lines inside the box", () => {
|
|
22
|
+
const out = frameCodeLines(["```", " " + "x".repeat(30), "```"], undefined, 20, theme)
|
|
23
|
+
assert.equal(out.length, 4, out.join("\n"))
|
|
24
|
+
assert.equal(out[1], `│ ${"x".repeat(16)} │`)
|
|
25
|
+
assert.equal(out[2], `│ ${"x".repeat(14)} │`)
|
|
26
|
+
for (const l of out) assert.equal(l.length, 20, l)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("frameCodeLines handles empty blocks and missing language", () => {
|
|
30
|
+
const out = frameCodeLines(["```", "```"], undefined, 10, theme)
|
|
31
|
+
assert.deepEqual(out.map((l) => l[0]), ["╭", "│", "╰"])
|
|
32
|
+
assert.deepEqual(out.map((l) => l[l.length - 1]), ["╮", "│", "╯"])
|
|
33
|
+
assert.ok(!out[0].includes(" "), "no language label")
|
|
34
|
+
assert.deepEqual(frameCodeLines(["x"], undefined, 10, theme), ["x"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("installCodeBox patches pi-tui's Markdown renderer idempotently", () => {
|
|
38
|
+
assert.equal(installCodeBox(), true)
|
|
39
|
+
assert.equal(installCodeBox(), true)
|
|
40
|
+
// A reload re-points the installed wrapper at the current framer.
|
|
41
|
+
const slot = Symbol.for("pi-minimal.code-box.frame")
|
|
42
|
+
const slots = globalThis as unknown as Record<symbol, unknown>
|
|
43
|
+
assert.equal(slots[slot], frameCodeLines)
|
|
44
|
+
const id = (s: string) => s
|
|
45
|
+
const mdTheme = {
|
|
46
|
+
heading: id, link: id, linkUrl: id, code: id, codeBlock: id, codeBlockBorder: id, quote: id, quoteBorder: id,
|
|
47
|
+
hr: id, listBullet: id, bold: id, italic: id, underline: id, strikethrough: id,
|
|
48
|
+
}
|
|
49
|
+
const md = new Markdown("before\n\n```ts\nconst x = 1\n```\n\nafter", 0, 0, mdTheme as never)
|
|
50
|
+
const lines = md.render(40).map(stripAnsi)
|
|
51
|
+
assert.ok(lines.some((l) => l.startsWith("╭─ ts ") && l.endsWith("╮")), lines.join("\n"))
|
|
52
|
+
assert.ok(lines.some((l) => l.startsWith("│ const x = 1") && l.endsWith("│")), lines.join("\n"))
|
|
53
|
+
assert.ok(lines.some((l) => l.startsWith("╰─") && l.endsWith("╯")), lines.join("\n"))
|
|
54
|
+
assert.ok(!lines.some((l) => l.includes("```")), "no literal fences remain")
|
|
55
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
4
|
+
import { renderFooterActivity, activityNeedsAnimation } from "../src/footer-activity.js"
|
|
5
|
+
import { ACTIVITY_STATES, type ActivitySnapshot } from "../src/footer-contributions.js"
|
|
6
|
+
|
|
7
|
+
const theme = { fg: (_color: string, text: string) => text }
|
|
8
|
+
const snapshot = (): ActivitySnapshot => ({ revision: 0, rows: [{ key: "1", agent: "worker", state: "running", text: "Last: tests" }], omitted: 0, motion: "spinner", format: "icons" })
|
|
9
|
+
|
|
10
|
+
test("matches pi-minimal spinner/check/failure and explicit other state glyphs", () => {
|
|
11
|
+
const glyphs = ["○", "◐", "!", "Ⅱ", "!", "✓", "✗", "⊘", "?"]
|
|
12
|
+
for (const [i, state] of ACTIVITY_STATES.entries()) {
|
|
13
|
+
const input = { ...snapshot(), rows: [{ ...snapshot().rows[0], state }] }
|
|
14
|
+
const [line] = renderFooterActivity(input, 80, theme, 0)
|
|
15
|
+
assert.ok(line.startsWith(glyphs[i]+" "), line)
|
|
16
|
+
const plain = renderFooterActivity({ ...input, format: "plain" }, 80, theme, 1)[0]
|
|
17
|
+
assert.ok(plain.includes(`[${state}]`))
|
|
18
|
+
}
|
|
19
|
+
assert.ok(renderFooterActivity(snapshot(), 80, theme, 1)[0].startsWith("◓"))
|
|
20
|
+
assert.ok(renderFooterActivity({ ...snapshot(), motion: "static" }, 80, theme, 1)[0].startsWith("◐"))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("caps rows, reports overflow, and only animates visible running rows", () => {
|
|
24
|
+
const rows = Array.from({ length: 6 }, (_, i) => ({ key: `${i}`, agent: "agent", state: "completed" as const, text: "Done" }))
|
|
25
|
+
const input: ActivitySnapshot = { ...snapshot(), rows, omitted: 2 }
|
|
26
|
+
const lines = renderFooterActivity(input, 80, theme)
|
|
27
|
+
assert.equal(lines.length, 5)
|
|
28
|
+
assert.ok(lines[4].includes("4 more"))
|
|
29
|
+
assert.equal(activityNeedsAnimation(input), false)
|
|
30
|
+
assert.equal(activityNeedsAnimation({ ...input, rows: [...rows, snapshot().rows[0]] }), false)
|
|
31
|
+
assert.equal(activityNeedsAnimation(snapshot()), true)
|
|
32
|
+
assert.equal(activityNeedsAnimation({ ...snapshot(), format: "plain" }), false)
|
|
33
|
+
assert.equal(activityNeedsAnimation({ ...snapshot(), motion: "static" }), false)
|
|
34
|
+
assert.deepEqual(renderFooterActivity(undefined, 80, theme), [])
|
|
35
|
+
assert.deepEqual(renderFooterActivity({ ...snapshot(), rows: [] }, 80, theme), [])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("bounds Unicode visible widths, strips controls, and limits nested indentation", () => {
|
|
39
|
+
const input: ActivitySnapshot = { ...snapshot(), rows: [{ key: "p", agent: "工人👩💻é", state: "running", text: "\u001b[31mhello\nworld\u001b[0m" },
|
|
40
|
+
{ key: "c", parentKey: "p", agent: "child", state: "running", text: "test" }] }
|
|
41
|
+
for (const width of [0, 1, 2, 20, 60, 80, 120]) {
|
|
42
|
+
for (const format of ["icons", "plain"] as const) {
|
|
43
|
+
for (const line of renderFooterActivity({ ...input, format }, width, theme)) {
|
|
44
|
+
assert.ok(visibleWidth(line) <= width, `${width}: ${line}`)
|
|
45
|
+
assert.ok(!line.includes("\n") && !line.includes("\u001b[31m"))
|
|
46
|
+
// pi-tui adds its own safe SGR reset when truncating plain text.
|
|
47
|
+
assert.ok(!line.replaceAll("\u001b[0m", "").includes("\u001b"))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
assert.ok(renderFooterActivity(input, 80, theme)[1].startsWith(" ◐"))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("right-aligns metrics and drops tokens then time before truncating activity", () => {
|
|
55
|
+
const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], metrics: { elapsedMs: 134000, tokens: 8200 }, task: { planId: "P12", id: "T3", status: "in_progress" } }] }
|
|
56
|
+
const full = renderFooterActivity(input, 80, theme)[0]
|
|
57
|
+
assert.ok(full.includes("T3 · Last: tests"))
|
|
58
|
+
assert.ok(full.endsWith("2m 14s · 8.2k tok"))
|
|
59
|
+
assert.equal(visibleWidth(full), 80)
|
|
60
|
+
const timeOnly = renderFooterActivity(input, 40, theme)[0]
|
|
61
|
+
assert.ok(timeOnly.endsWith("2m 14s"), timeOnly)
|
|
62
|
+
assert.ok(!timeOnly.includes("tok"))
|
|
63
|
+
const narrow = renderFooterActivity(input, 30, theme)[0]
|
|
64
|
+
assert.ok(narrow.includes("T3 · Last: tests"))
|
|
65
|
+
assert.ok(!narrow.includes("2m"))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("missing metrics are not zero and completed elapsed values do not advance", () => {
|
|
69
|
+
const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], state: "completed", metrics: {} }] }
|
|
70
|
+
assert.ok(renderFooterActivity(input, 80, theme)[0].endsWith("— · — tok"))
|
|
71
|
+
const ended: ActivitySnapshot = { ...input, rows: [{ ...input.rows[0], metrics: { elapsedMs: 66000, tokens: 0 } }] }
|
|
72
|
+
assert.ok(renderFooterActivity(ended, 80, theme, 0)[0].endsWith("1m 06s · 0 tok"))
|
|
73
|
+
assert.equal(renderFooterActivity(ended, 80, theme, 0)[0], renderFooterActivity(ended, 80, theme, 1)[0])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("task status is independent of agent status and plain mode keeps task labels", () => {
|
|
77
|
+
for (const [status, marker] of [["blocked", "!"], ["in_review", "◇"], ["done", "✓"]] as const) {
|
|
78
|
+
const input: ActivitySnapshot = { ...snapshot(), rows: [{ ...snapshot().rows[0], state: "completed", task: { planId: "P12", id: "T3", status } }] }
|
|
79
|
+
assert.ok(renderFooterActivity(input, 80, theme)[0].includes(`${marker} T3`))
|
|
80
|
+
const plain = renderFooterActivity({ ...input, format: "plain" }, 100, theme)[0]
|
|
81
|
+
assert.ok(plain.includes(`[${status}] T3`))
|
|
82
|
+
assert.ok(plain.includes("[completed]"))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("uses current semantic theme and never caches colored text", () => {
|
|
87
|
+
const colors: string[] = []
|
|
88
|
+
const themed = { fg: (color: string, text: string) => { colors.push(color); return text } }
|
|
89
|
+
renderFooterActivity(snapshot(), 80, themed)
|
|
90
|
+
assert.ok(colors.includes("warning"))
|
|
91
|
+
const changed = renderFooterActivity(snapshot(), 80, { fg: (_c, text) => `<${text}>` })
|
|
92
|
+
assert.ok(changed[0].includes("<◐>"))
|
|
93
|
+
})
|