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,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
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { initTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { installFooter } from "../src/footer.js"
|
|
5
|
+
import { createFooterContributions, type ActivitySnapshot } from "../src/footer-contributions.js"
|
|
6
|
+
|
|
7
|
+
initTheme("dark", false)
|
|
8
|
+
|
|
9
|
+
type Factory = NonNullable<Parameters<ExtensionContext["ui"]["setFooter"]>[0]>
|
|
10
|
+
function fixture() {
|
|
11
|
+
let factory: Factory | undefined
|
|
12
|
+
let renders = 0, branchSubscriptions = 0
|
|
13
|
+
const ctx = { ui: { setFooter: (f: Factory) => { factory = f } }, sessionManager: { getBranch: () => [] } } as unknown as ExtensionContext
|
|
14
|
+
const args = [
|
|
15
|
+
{ requestRender: () => { renders++ } },
|
|
16
|
+
{ fg: (_color: string, text: string) => text },
|
|
17
|
+
{ onBranchChange: () => { branchSubscriptions++; return () => { branchSubscriptions-- } } },
|
|
18
|
+
] as unknown as Parameters<Factory>
|
|
19
|
+
return { ctx, mount: () => { assert.ok(factory); return factory(...args) },
|
|
20
|
+
get renders() { return renders }, get branchSubscriptions() { return branchSubscriptions } }
|
|
21
|
+
}
|
|
22
|
+
const data = (revision: number, motion: "spinner" | "static" = "spinner"): ActivitySnapshot => ({
|
|
23
|
+
revision, rows: [{ key: "1", agent: "worker", state: "running", text: "Last: tests" }], omitted: 0, motion, format: "icons",
|
|
24
|
+
})
|
|
25
|
+
const sources = { busy: () => false, editorText: () => "", contextPct: () => 10 }
|
|
26
|
+
|
|
27
|
+
test("no contributor preserves existing footer; activity is last even while typing/busy", () => {
|
|
28
|
+
const f = fixture(), registry = createFooterContributions("S1")
|
|
29
|
+
installFooter(f.ctx, sources)
|
|
30
|
+
const baseline = f.mount(); const expected = baseline.render(80); baseline.dispose?.()
|
|
31
|
+
const dispose = installFooter(f.ctx, sources, registry)
|
|
32
|
+
const component = f.mount()
|
|
33
|
+
assert.deepEqual(component.render(80), expected)
|
|
34
|
+
const result = registry.register({ sessionId: "S1", owner: "activity" }); assert.ok(result.ok)
|
|
35
|
+
result.registration.publish(data(0, "static"))
|
|
36
|
+
const lines = component.render(80)
|
|
37
|
+
assert.deepEqual(lines.slice(0, -1), ["", ...expected, ""])
|
|
38
|
+
assert.ok(lines.at(-1)?.includes("◐ worker"))
|
|
39
|
+
dispose(); component.dispose?.()
|
|
40
|
+
const busyDispose = installFooter(f.ctx, { ...sources, busy: () => true, editorText: () => "draft" }, registry)
|
|
41
|
+
const busy = f.mount()
|
|
42
|
+
assert.equal(busy.render(80).length, 2)
|
|
43
|
+
assert.equal(busy.render(80)[0], "")
|
|
44
|
+
busyDispose()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("updates invalidate; replacement disposes old mount but preserves session rows", (t) => {
|
|
48
|
+
t.mock.timers.enable({ apis: ["setInterval"] })
|
|
49
|
+
const f = fixture(), registry = createFooterContributions("S1")
|
|
50
|
+
const result = registry.register({ sessionId: "S1", owner: "activity" }); assert.ok(result.ok)
|
|
51
|
+
const stop = installFooter(f.ctx, sources, registry)
|
|
52
|
+
const old = f.mount()
|
|
53
|
+
result.registration.publish(data(0))
|
|
54
|
+
const initial = f.renders
|
|
55
|
+
t.mock.timers.tick(400)
|
|
56
|
+
assert.equal(f.renders, initial + 1)
|
|
57
|
+
assert.ok(old.render(80).at(-1)?.includes("◓"))
|
|
58
|
+
stop(); old.dispose?.()
|
|
59
|
+
assert.equal(f.branchSubscriptions, 0)
|
|
60
|
+
const stopped = f.renders
|
|
61
|
+
t.mock.timers.tick(800)
|
|
62
|
+
assert.equal(f.renders, stopped)
|
|
63
|
+
const stopNext = installFooter(f.ctx, sources, registry)
|
|
64
|
+
const next = f.mount()
|
|
65
|
+
assert.ok(next.render(80).at(-1)?.includes("worker"))
|
|
66
|
+
result.registration.publish(data(1, "static"))
|
|
67
|
+
const staticCount = f.renders
|
|
68
|
+
t.mock.timers.tick(800)
|
|
69
|
+
assert.equal(f.renders, staticCount)
|
|
70
|
+
registry.reset(null)
|
|
71
|
+
assert.deepEqual(next.render(80), next.render(80).filter(line => !line.includes("worker")))
|
|
72
|
+
stopNext()
|
|
73
|
+
assert.equal(f.branchSubscriptions, 0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("no spinner timer when terminal has no activity space", (t) => {
|
|
77
|
+
t.mock.timers.enable({ apis: ["setInterval"] })
|
|
78
|
+
const f = fixture(), registry = createFooterContributions("S1")
|
|
79
|
+
const result = registry.register({ sessionId: "S1", owner: "activity" }); assert.ok(result.ok)
|
|
80
|
+
result.registration.publish(data(0))
|
|
81
|
+
const stop = installFooter(f.ctx, { ...sources, busy: () => true }, registry)
|
|
82
|
+
const component = f.mount()
|
|
83
|
+
component.render(0)
|
|
84
|
+
const before = f.renders
|
|
85
|
+
t.mock.timers.tick(800)
|
|
86
|
+
assert.equal(f.renders, before)
|
|
87
|
+
component.render(80)
|
|
88
|
+
t.mock.timers.tick(400)
|
|
89
|
+
assert.equal(f.renders, before + 1)
|
|
90
|
+
stop()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("plan appears between hints and activity with exactly one blank row between groups", () => {
|
|
94
|
+
const f = fixture(), registry = createFooterContributions("S1")
|
|
95
|
+
const activity = registry.register({ sessionId: "S1", owner: "activity" })
|
|
96
|
+
const plan = registry.registerPlan({ sessionId: "S1", owner: "tracker" })
|
|
97
|
+
assert.ok(activity.ok && plan.ok)
|
|
98
|
+
activity.registration.publish(data(0, "static"))
|
|
99
|
+
plan.registration.publish({ revision: 0, format: "icons", plan: { id: "P12", accepted: 3, total: 7, inReview: 1, blocked: 0 } })
|
|
100
|
+
const stop = installFooter(f.ctx, sources, registry)
|
|
101
|
+
const component = f.mount()
|
|
102
|
+
const lines = component.render(80)
|
|
103
|
+
assert.equal(lines.length, 6)
|
|
104
|
+
assert.equal(lines[0], "")
|
|
105
|
+
assert.ok(lines[1].includes("send"))
|
|
106
|
+
assert.equal(lines[2], "")
|
|
107
|
+
assert.ok(lines[3].includes("▰▰▰▱▱▱▱ 3/7"))
|
|
108
|
+
assert.equal(lines[4], "")
|
|
109
|
+
assert.ok(lines[5].includes("worker"))
|
|
110
|
+
activity.registration.dispose()
|
|
111
|
+
assert.equal(component.render(80).length, 4, "no trailing spacer for absent activity")
|
|
112
|
+
plan.registration.dispose()
|
|
113
|
+
assert.equal(component.render(80).length, 1, "legacy footer restored without empty groups")
|
|
114
|
+
stop()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("legacy task summary precedes activity", () => {
|
|
118
|
+
const f = fixture(), registry = createFooterContributions("S1")
|
|
119
|
+
f.ctx.sessionManager.getBranch = () => [{ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: { tasks: [{ name: "Legacy task", status: "in_progress" }] } } }] as unknown as ReturnType<ExtensionContext["sessionManager"]["getBranch"]>
|
|
120
|
+
const result = registry.register({ sessionId: "S1", owner: "activity" }); assert.ok(result.ok)
|
|
121
|
+
result.registration.publish(data(0, "static"))
|
|
122
|
+
const dispose = installFooter(f.ctx, sources, registry)
|
|
123
|
+
const component = f.mount()
|
|
124
|
+
const lines = component.render(80)
|
|
125
|
+
assert.ok(lines.at(-3)?.includes("Legacy task"))
|
|
126
|
+
assert.equal(lines.at(-2), "")
|
|
127
|
+
assert.ok(lines.at(-1)?.includes("worker"))
|
|
128
|
+
dispose()
|
|
129
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { EventEmitter } from "node:events"
|
|
4
|
+
import { createFooterContributions } from "../src/footer-contributions.js"
|
|
5
|
+
import { bindFooterContributionEvents, FOOTER_EVENTS, type RegisterRequest } from "../src/footer-contribution-events.js"
|
|
6
|
+
|
|
7
|
+
function bus() {
|
|
8
|
+
const emitter = new EventEmitter()
|
|
9
|
+
return { emit: (name: string, value: unknown) => { emitter.emit(name, value) },
|
|
10
|
+
on: (name: string, handler: (value: unknown) => void) => { emitter.on(name, handler); return () => { emitter.off(name, handler) } } }
|
|
11
|
+
}
|
|
12
|
+
const request = (): RegisterRequest => ({ version: 1, sessionId: "S1", owner: "activity", slot: "activity" })
|
|
13
|
+
|
|
14
|
+
test("consumer-before-host registers on ready; host-before-consumer registers directly", () => {
|
|
15
|
+
for (const consumerFirst of [true, false]) {
|
|
16
|
+
const events = bus(), registry = createFooterContributions(), req = request()
|
|
17
|
+
const register = () => events.emit(FOOTER_EVENTS.register, req)
|
|
18
|
+
if (consumerFirst) events.on(FOOTER_EVENTS.ready, register)
|
|
19
|
+
const dispose = bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
20
|
+
if (!consumerFirst) register()
|
|
21
|
+
assert.equal(req.result?.ok, true)
|
|
22
|
+
dispose()
|
|
23
|
+
assert.equal(registry.read(), undefined)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("rejects foreign session, invalid version/slot and non-TUI requests", () => {
|
|
28
|
+
const events = bus(), registry = createFooterContributions()
|
|
29
|
+
let stop = bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
30
|
+
for (const changes of [{ sessionId: "S2" }, { version: 2 }, { slot: "sidebar" }]) {
|
|
31
|
+
const req = { ...request(), ...changes }
|
|
32
|
+
events.emit(FOOTER_EVENTS.register, req)
|
|
33
|
+
assert.equal(req.result?.ok, false)
|
|
34
|
+
}
|
|
35
|
+
stop()
|
|
36
|
+
stop = bindFooterContributionEvents(events, registry, "S1", "print")
|
|
37
|
+
const req = request(); events.emit(FOOTER_EVENTS.register, req)
|
|
38
|
+
assert.deepEqual(req.result, { ok: false, code: "not-tui" })
|
|
39
|
+
stop()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("summary and activity register independently through the public event", () => {
|
|
43
|
+
const events = bus(), registry = createFooterContributions()
|
|
44
|
+
const stop = bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
45
|
+
const activity = request(), plan: RegisterRequest = { ...request(), slot: "summary", owner: "tracker" }
|
|
46
|
+
events.emit(FOOTER_EVENTS.register, activity)
|
|
47
|
+
events.emit(FOOTER_EVENTS.register, plan)
|
|
48
|
+
assert.ok(activity.result?.ok && plan.result?.ok)
|
|
49
|
+
assert.ok(plan.result.registration.publish({ revision: 0, format: "icons", plan: { id: "P12", accepted: 3, total: 7, inReview: 1, blocked: 0 } }).ok)
|
|
50
|
+
assert.equal(registry.readPlan()?.plan?.accepted, 3)
|
|
51
|
+
stop()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("shutdown removes listeners, invalidates handles and emits unavailable once", () => {
|
|
55
|
+
const events = bus(), registry = createFooterContributions()
|
|
56
|
+
let unavailable = 0
|
|
57
|
+
events.on(FOOTER_EVENTS.unavailable, () => { unavailable++ })
|
|
58
|
+
const stop = bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
59
|
+
const req = request(); events.emit(FOOTER_EVENTS.register, req)
|
|
60
|
+
assert.ok(req.result?.ok)
|
|
61
|
+
const handle = req.result.registration
|
|
62
|
+
stop(); stop()
|
|
63
|
+
assert.equal(unavailable, 1)
|
|
64
|
+
const fresh = request(); events.emit(FOOTER_EVENTS.register, fresh)
|
|
65
|
+
assert.equal(fresh.result, undefined)
|
|
66
|
+
assert.deepEqual(handle.publish({}), { ok: false, code: "disposed" })
|
|
67
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { createFooterContributions, type ActivitySnapshot } from "../src/footer-contributions.js"
|
|
4
|
+
|
|
5
|
+
const snapshot = (revision = 0): ActivitySnapshot => ({
|
|
6
|
+
revision, rows: [{ key: "child", agent: "worker", state: "running", text: "Last: tests" }],
|
|
7
|
+
omitted: 0, motion: "spinner", format: "icons",
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function owner() {
|
|
11
|
+
const registry = createFooterContributions("S1")
|
|
12
|
+
const result = registry.register({ sessionId: "S1", owner: "activity" })
|
|
13
|
+
assert.equal(result.ok, true)
|
|
14
|
+
return { registry, handle: result.registration }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("empty registry, exclusive ownership and immutable snapshots", () => {
|
|
18
|
+
const { registry, handle } = owner()
|
|
19
|
+
assert.equal(registry.read(), undefined)
|
|
20
|
+
assert.deepEqual(registry.register({ sessionId: "S2", owner: "other" }), { ok: false, code: "wrong-session" })
|
|
21
|
+
assert.deepEqual(registry.register({ sessionId: "S1", owner: "other" }), { ok: false, code: "occupied" })
|
|
22
|
+
const input = snapshot()
|
|
23
|
+
assert.deepEqual(handle.publish(input), { ok: true })
|
|
24
|
+
assert.notEqual(registry.read(), input)
|
|
25
|
+
assert.ok(Object.isFrozen(registry.read()))
|
|
26
|
+
assert.ok(Object.isFrozen(registry.read()?.rows[0]))
|
|
27
|
+
assert.ok(Object.isFrozen(registry.read()?.rows))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("rejects stale revisions and invalid payloads without replacing last good state", () => {
|
|
31
|
+
const { registry, handle } = owner()
|
|
32
|
+
handle.publish(snapshot(2))
|
|
33
|
+
const good = registry.read()
|
|
34
|
+
for (const invalid of [null, {}, snapshot(1), snapshot(2), { ...snapshot(3), revision: Infinity },
|
|
35
|
+
{ ...snapshot(3), omitted: -1 }, { ...snapshot(3), motion: "fast" },
|
|
36
|
+
{ ...snapshot(3), rows: [{ ...snapshot().rows[0], state: "invented" }] },
|
|
37
|
+
{ ...snapshot(3), rows: Array(65).fill(snapshot().rows[0]) },
|
|
38
|
+
{ ...snapshot(3), rows: [snapshot().rows[0], snapshot().rows[0]] },
|
|
39
|
+
{ ...snapshot(3), rows: [{ ...snapshot().rows[0], agent: "a".repeat(97) }] },
|
|
40
|
+
{ ...snapshot(3), rows: [{ ...snapshot().rows[0], parentKey: "missing" }] },
|
|
41
|
+
{ ...snapshot(3), rows: [{ ...snapshot().rows[0], parentKey: "child" }] },
|
|
42
|
+
{ ...snapshot(3), rows: [{ key: "a", parentKey: "b", agent: "x", text: "", state: "running" }, { key: "b", parentKey: "a", agent: "y", text: "", state: "running" }] },
|
|
43
|
+
]) {
|
|
44
|
+
assert.equal(handle.publish(invalid).ok, false)
|
|
45
|
+
assert.equal(registry.read(), good)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("sanitizes display controls, retains Unicode and clones caller data", () => {
|
|
50
|
+
const { registry, handle } = owner()
|
|
51
|
+
const input = { ...snapshot(), rows: [{ key: "c", agent: "工人", state: "running", text: "\u001b[31mtests\u001b[0m\nnext\u001b]0;evil\u0007\u202e" }] }
|
|
52
|
+
assert.equal(handle.publish(input).ok, true)
|
|
53
|
+
input.rows[0].text = "changed"
|
|
54
|
+
assert.equal(registry.read()?.rows[0].text, "tests next")
|
|
55
|
+
assert.equal(registry.read()?.rows[0].agent, "工人")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("reset and stale disposer cannot change a newer owner, including the same session ID", () => {
|
|
59
|
+
const { registry, handle } = owner()
|
|
60
|
+
handle.publish(snapshot())
|
|
61
|
+
registry.reset("S1")
|
|
62
|
+
const next = registry.register({ sessionId: "S1", owner: "activity" })
|
|
63
|
+
assert.ok(next.ok)
|
|
64
|
+
if (!next.ok) return
|
|
65
|
+
next.registration.publish(snapshot(0))
|
|
66
|
+
handle.dispose()
|
|
67
|
+
assert.equal(handle.publish(snapshot(99)).ok, false)
|
|
68
|
+
assert.ok(registry.read())
|
|
69
|
+
next.registration.dispose()
|
|
70
|
+
next.registration.dispose()
|
|
71
|
+
assert.equal(registry.read(), undefined)
|
|
72
|
+
registry.reset(null)
|
|
73
|
+
assert.equal(registry.register({ sessionId: "S1", owner: "activity" }).ok, false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("optional metrics and task links are validated, cloned and frozen", () => {
|
|
77
|
+
const { registry, handle } = owner()
|
|
78
|
+
const input = { ...snapshot(), rows: [{ ...snapshot().rows[0], metrics: { elapsedMs: 134000, tokens: 8200 }, task: { planId: "P12", id: "T3", status: "in_review" } }] }
|
|
79
|
+
assert.ok(handle.publish(input).ok)
|
|
80
|
+
assert.deepEqual(registry.read()?.rows[0].metrics, input.rows[0].metrics)
|
|
81
|
+
assert.deepEqual(registry.read()?.rows[0].task, input.rows[0].task)
|
|
82
|
+
assert.ok(Object.isFrozen(registry.read()?.rows[0].metrics))
|
|
83
|
+
assert.ok(Object.isFrozen(registry.read()?.rows[0].task))
|
|
84
|
+
input.rows[0].metrics.tokens = 9000
|
|
85
|
+
assert.equal(registry.read()?.rows[0].metrics?.tokens, 8200)
|
|
86
|
+
for (const fields of [
|
|
87
|
+
{ metrics: { tokens: -1 } }, { metrics: { tokens: 1.5 } }, { metrics: { elapsedMs: Infinity } },
|
|
88
|
+
{ metrics: null }, { task: { id: "T3", status: "done" } },
|
|
89
|
+
{ task: { planId: "P12", id: "T3", status: "invented" } },
|
|
90
|
+
]) assert.equal(handle.publish({ ...snapshot(1), rows: [{ ...snapshot().rows[0], ...fields }] }).ok, false)
|
|
91
|
+
assert.ok(handle.publish({ ...snapshot(1), rows: [{ ...snapshot().rows[0], metrics: {} }] }).ok)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("subscriptions observe valid changes only; one failing observer cannot break publication", () => {
|
|
95
|
+
const { registry, handle } = owner()
|
|
96
|
+
let changes = 0
|
|
97
|
+
const stop = registry.subscribe(() => { changes++ })
|
|
98
|
+
const stopBad = registry.subscribe(() => { throw new Error("disposed UI") })
|
|
99
|
+
assert.equal(handle.publish(snapshot()).ok, true)
|
|
100
|
+
handle.publish(snapshot())
|
|
101
|
+
assert.equal(changes, 1)
|
|
102
|
+
stop(); stopBad()
|
|
103
|
+
handle.dispose()
|
|
104
|
+
assert.equal(changes, 1)
|
|
105
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
4
|
+
import { createFooterContributions } from "../src/footer-contributions.js"
|
|
5
|
+
import { renderFooterPlan, type PlanSnapshot } from "../src/footer-plan.js"
|
|
6
|
+
|
|
7
|
+
const sample: PlanSnapshot = { revision: 0, format: "icons", plan: { id: "P12", accepted: 3, total: 7, inReview: 1, blocked: 1 } }
|
|
8
|
+
const theme = { fg: (_color: string, text: string) => text }
|
|
9
|
+
|
|
10
|
+
test("plan slot is independent of activity and rejects invalid/stale snapshots", () => {
|
|
11
|
+
const registry = createFooterContributions("S1")
|
|
12
|
+
const activity = registry.register({ sessionId: "S1", owner: "activity" })
|
|
13
|
+
const result = registry.registerPlan({ sessionId: "S1", owner: "tracker" })
|
|
14
|
+
assert.ok(activity.ok && result.ok)
|
|
15
|
+
assert.ok(result.registration.publish(sample).ok)
|
|
16
|
+
assert.deepEqual(registry.readPlan(), sample)
|
|
17
|
+
assert.ok(Object.isFrozen(registry.readPlan()?.plan))
|
|
18
|
+
for (const payload of [sample, { ...sample, revision: 1, plan: { ...sample.plan, accepted: 8 } },
|
|
19
|
+
{ ...sample, revision: 1, plan: { ...sample.plan, total: -1 } },
|
|
20
|
+
{ ...sample, revision: 1, plan: { ...sample.plan, id: "bad\nlabel" } },
|
|
21
|
+
{ ...sample, revision: 1, plan: { ...sample.plan, inReview: 9 } },
|
|
22
|
+
{ ...sample, revision: 1, format: "html" }]) assert.equal(result.registration.publish(payload).ok, false)
|
|
23
|
+
assert.equal(registry.read(), undefined)
|
|
24
|
+
assert.ok(result.registration.publish({ revision: 1, format: "plain" }).ok)
|
|
25
|
+
assert.equal(registry.readPlan()?.plan, undefined)
|
|
26
|
+
registry.reset("S2")
|
|
27
|
+
assert.equal(result.registration.publish({ ...sample, revision: 3 }).ok, false)
|
|
28
|
+
assert.equal(registry.readPlan(), undefined)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("violet slanted segments and exact count, with bounded large-plan progress", () => {
|
|
32
|
+
const calls: string[] = []
|
|
33
|
+
const colored = { fg: (color: string, text: string) => { calls.push(`${color}:${text}`); return text } }
|
|
34
|
+
const [line] = renderFooterPlan(sample, 80, colored)
|
|
35
|
+
assert.ok(line.includes("▰▰▰▱▱▱▱ 3/7"))
|
|
36
|
+
assert.ok(calls.includes("accent:▰▰▰"))
|
|
37
|
+
assert.ok(calls.includes("accent:Plan P12"))
|
|
38
|
+
assert.ok(calls.includes("muted:▱▱▱▱"))
|
|
39
|
+
const large = renderFooterPlan({ ...sample, plan: { id: "P12", accepted: 27, total: 100, inReview: 0, blocked: 0 } }, 80, theme)[0]
|
|
40
|
+
assert.ok(large.includes("▰▰▱▱▱▱▱▱▱▱ 27/100"))
|
|
41
|
+
assert.ok(renderFooterPlan({ ...sample, format: "plain" }, 80, theme)[0].includes("###.... 3/7"))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("narrow layout drops segments before counts; empty plans and missing contributions are distinct", () => {
|
|
45
|
+
const narrow = renderFooterPlan(sample, 45, theme)[0]
|
|
46
|
+
assert.ok(!narrow.includes("▰") && narrow.includes("3/7"))
|
|
47
|
+
for (const width of [0, 1, 20, 40, 60, 80, 120]) {
|
|
48
|
+
for (const line of renderFooterPlan(sample, width, theme)) assert.ok(visibleWidth(line) <= width)
|
|
49
|
+
}
|
|
50
|
+
assert.deepEqual(renderFooterPlan(undefined, 80, theme), [])
|
|
51
|
+
assert.deepEqual(renderFooterPlan({ revision: 0, format: "icons" }, 80, theme), [])
|
|
52
|
+
assert.ok(renderFooterPlan({ ...sample, plan: { id: "P12", accepted: 0, total: 0, inReview: 0, blocked: 0 } }, 80, theme)[0].includes("No tasks yet"))
|
|
53
|
+
})
|
package/test/index.test.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
type ExtensionContext,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent"
|
|
11
11
|
import install from "../src/index.js"
|
|
12
|
+
import { EventEmitter } from "node:events"
|
|
13
|
+
import { FOOTER_EVENTS, type RegisterRequest } from "../src/footer-contribution-events.js"
|
|
12
14
|
|
|
13
15
|
type Call = { method: string; args: unknown[] }
|
|
14
16
|
type SessionMode = "tui" | "rpc" | "json" | "print"
|
|
@@ -44,6 +46,11 @@ function createFake(options: {
|
|
|
44
46
|
const execCalls: Call[] = []
|
|
45
47
|
const registerCalls: Call[] = []
|
|
46
48
|
const allCalls: Call[] = []
|
|
49
|
+
const emitter = new EventEmitter()
|
|
50
|
+
const events = {
|
|
51
|
+
emit: (name: string, value: unknown) => { emitter.emit(name, value) },
|
|
52
|
+
on: (name: string, handler: (value: unknown) => void) => { emitter.on(name, handler); return () => { emitter.off(name, handler) } },
|
|
53
|
+
}
|
|
47
54
|
|
|
48
55
|
const record = (target: Call[], method: string, args: unknown[]) => {
|
|
49
56
|
const call = { method, args }
|
|
@@ -66,11 +73,12 @@ function createFake(options: {
|
|
|
66
73
|
mode: options.mode ?? "tui",
|
|
67
74
|
cwd: options.cwd,
|
|
68
75
|
isProjectTrusted: () => options.projectTrusted ?? false,
|
|
69
|
-
sessionManager: { getBranch: () => [] },
|
|
76
|
+
sessionManager: { getBranch: () => [], getSessionId: () => "S1" },
|
|
70
77
|
ui,
|
|
71
78
|
} as unknown as ExtensionContext
|
|
72
79
|
|
|
73
80
|
const pi = {
|
|
81
|
+
events,
|
|
74
82
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
|
|
75
83
|
const list = handlers.get(event) ?? []
|
|
76
84
|
list.push(handler)
|
|
@@ -110,7 +118,7 @@ function createFake(options: {
|
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
return { pi, ctx, uiCalls, execCalls, registerCalls, allCalls, emit }
|
|
121
|
+
return { pi, ctx, uiCalls, execCalls, registerCalls, allCalls, emit, events }
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
function widgetCalls(uiCalls: Call[], key: string): Call[] {
|
|
@@ -275,6 +283,31 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
275
283
|
})
|
|
276
284
|
})
|
|
277
285
|
|
|
286
|
+
test("footer registration survives chrome reinstalls and becomes inert on shutdown", async (t) => {
|
|
287
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
288
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
289
|
+
delete process.env.HERDR_ENV
|
|
290
|
+
t.after(restoreEnv)
|
|
291
|
+
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
292
|
+
install(pi)
|
|
293
|
+
t.after(() => emit("session_shutdown"))
|
|
294
|
+
await emit("session_start")
|
|
295
|
+
const req: RegisterRequest = { version: 1, owner: "activity", sessionId: "S1", slot: "activity" }
|
|
296
|
+
events.emit(FOOTER_EVENTS.register, req)
|
|
297
|
+
assert.ok(req.result?.ok)
|
|
298
|
+
const handle = req.result.registration
|
|
299
|
+
const data = { revision: 0, rows: [], omitted: 0, motion: "static", format: "icons" }
|
|
300
|
+
assert.equal(handle.publish(data).ok, true)
|
|
301
|
+
await emit("turn_end")
|
|
302
|
+
await emit("tool_execution_end")
|
|
303
|
+
assert.equal(handle.publish({ ...data, revision: 1 }).ok, true)
|
|
304
|
+
await emit("session_shutdown")
|
|
305
|
+
assert.equal(handle.publish({ ...data, revision: 2 }).ok, false)
|
|
306
|
+
const count = uiCalls.length
|
|
307
|
+
await new Promise(resolve => setTimeout(resolve, 70))
|
|
308
|
+
assert.equal(uiCalls.length, count, "settle callbacks must not reinstall after shutdown")
|
|
309
|
+
})
|
|
310
|
+
|
|
278
311
|
test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
279
312
|
const { agentDir, cwd } = setupDirs(t)
|
|
280
313
|
process.env[AGENT_DIR_ENV] = agentDir
|
package/test/package.test.ts
CHANGED
|
@@ -5,7 +5,11 @@ import { readFileSync } from "node:fs"
|
|
|
5
5
|
test("package manifest exposes the committed extension", () => {
|
|
6
6
|
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
|
|
7
7
|
assert.equal(pkg.name, "pi-minimal")
|
|
8
|
-
assert.equal(pkg.version, "0.
|
|
8
|
+
assert.equal(pkg.version, "0.3.0")
|
|
9
|
+
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"))
|
|
10
|
+
assert.equal(lock.version, pkg.version, "lockfile root version matches package")
|
|
11
|
+
assert.equal(lock.packages[""].version, pkg.version, "lockfile package version matches package")
|
|
12
|
+
assert.ok(readFileSync("CHANGELOG.md", "utf8").includes(`## [${pkg.version}] - `), "version has a dated release entry")
|
|
9
13
|
assert.equal(pkg.license, "MIT")
|
|
10
14
|
assert.equal(pkg.type, "module")
|
|
11
15
|
assert.deepEqual(pkg.engines, { node: ">=22.19.0" })
|
package/test/theme.test.ts
CHANGED
|
@@ -43,3 +43,31 @@ test("minimal-dark frames code quietly and neutralises tool backgrounds", () =>
|
|
|
43
43
|
assert.equal(theme.colors.toolSuccessBg, theme.colors.toolPendingBg)
|
|
44
44
|
assert.notEqual(theme.colors.mdHeading, theme.colors.warning)
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
const lightness = (value: string) => {
|
|
48
|
+
const resolved = value in theme.vars ? theme.vars[value] : value
|
|
49
|
+
const m = /okhsl\(\s*[\d.]+\s+[\d.]+%\s+([\d.]+)%\s*\)/.exec(resolved)
|
|
50
|
+
assert.ok(m, `okhsl literal for ${value}`)
|
|
51
|
+
return Number(m![1]) / 100
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("minimal-dark keeps a readable grey ladder: text > muted > thinking > dim > faint", () => {
|
|
55
|
+
const ladder = [theme.vars.text, theme.vars.muted, theme.colors.thinkingText, theme.vars.dim, theme.vars.faint].map(lightness)
|
|
56
|
+
for (let i = 1; i < ladder.length; i++) assert.ok(ladder[i] < ladder[i - 1], `step ${i}: ${ladder.join(" > ")}`)
|
|
57
|
+
// Readable tiers stay at or above L .56 (~4.4:1 on bg L .11, estimating Y ≈ L³).
|
|
58
|
+
assert.ok(lightness(theme.vars.dim) >= 0.56)
|
|
59
|
+
assert.ok(lightness(theme.colors.thinkingText) >= 0.56)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("minimal-dark keeps status hues out of syntax colours", () => {
|
|
63
|
+
const status = new Set([theme.colors.success, theme.colors.warning, theme.colors.error])
|
|
64
|
+
for (const role of ["syntaxFunction", "syntaxNumber", "syntaxString", "syntaxVariable"]) {
|
|
65
|
+
assert.ok(!status.has(theme.colors[role]), `${role} reuses a status colour`)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("minimal-dark draws no slab behind tool rows; only failures get a background", () => {
|
|
70
|
+
assert.equal(theme.colors.toolSuccessBg, "bg")
|
|
71
|
+
assert.equal(theme.colors.toolPendingBg, "bg")
|
|
72
|
+
assert.notEqual(theme.colors.toolErrorBg, "bg")
|
|
73
|
+
})
|