pi-minimal 0.3.1 → 0.5.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 +60 -0
- package/CONTEXT.md +83 -0
- package/README.md +6 -3
- package/dist/config.js +21 -9
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/git-state.js +31 -11
- package/dist/index.js +23 -206
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +224 -281
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +4 -1
- package/release-notes.md +45 -19
- package/src/config.ts +20 -9
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/git-state.ts +29 -10
- package/src/index.ts +25 -199
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +279 -312
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +41 -20
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +45 -3
- package/test/tools.test.ts +98 -0
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/src/tracker.ts
CHANGED
|
@@ -3,18 +3,24 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
|
3
3
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const
|
|
4
4
|
type PhaseName = (typeof PHASES)[number]
|
|
5
5
|
type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped"
|
|
6
|
-
|
|
6
|
+
/** pi-gauntlet's task statuses; distinct from the footer contribution API's `ActivityTask.status`. */
|
|
7
|
+
type GauntletTaskStatus = "pending" | "in_progress" | "complete" | "failed"
|
|
7
8
|
|
|
8
9
|
type PhaseMap = Record<PhaseName, { status: PhaseStatus; substep?: string | null }>
|
|
9
10
|
|
|
10
|
-
type Task = { name: string; status:
|
|
11
|
+
type Task = { name: string; status: GauntletTaskStatus }
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* What pi-gauntlet last reported on this session branch, read from its `phase_tracker` and
|
|
15
|
+
* `plan_tracker` tool results. A projection of another extension's history, not a contribution:
|
|
16
|
+
* nothing is registered or versioned, so it is only as stable as those tools' `details` shapes.
|
|
17
|
+
*/
|
|
18
|
+
export type GauntletProjection = {
|
|
13
19
|
phases?: PhaseMap
|
|
14
20
|
tasks?: Task[]
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
function
|
|
23
|
+
export function readGauntletProjection(ctx: ExtensionContext): GauntletProjection {
|
|
18
24
|
let phases: PhaseMap | undefined
|
|
19
25
|
let tasks: Task[] | undefined
|
|
20
26
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -47,7 +53,7 @@ export function formatTrackerLines(
|
|
|
47
53
|
ctx: ExtensionContext,
|
|
48
54
|
theme: { fg: (name: string, s: string) => string },
|
|
49
55
|
): string[] {
|
|
50
|
-
const { phases, tasks } =
|
|
56
|
+
const { phases, tasks } = readGauntletProjection(ctx)
|
|
51
57
|
const lines: string[] = []
|
|
52
58
|
if (phases && phaseActive(phases)) {
|
|
53
59
|
const parts = PHASES.map((p) => {
|
package/src/working-status.ts
CHANGED
|
@@ -206,8 +206,9 @@ export function installWorkingStatus(pi: ExtensionAPI): () => void {
|
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
pi.on("session_shutdown", () => {
|
|
209
|
-
ctx
|
|
209
|
+
// Clear the message while ctx is still known; dropping ctx first left it on screen.
|
|
210
210
|
stop()
|
|
211
|
+
ctx = undefined
|
|
211
212
|
})
|
|
212
213
|
|
|
213
214
|
return stop
|
package/test/config.test.ts
CHANGED
|
@@ -26,89 +26,110 @@ function writeProject(cwd: string, contents: string) {
|
|
|
26
26
|
writeFileSync(join(dir, "pi-minimal.json"), contents)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
test("defaults hideGauntletWidgets to
|
|
29
|
+
test("defaults hideGauntletWidgets to false", (t) => {
|
|
30
30
|
const { agentDir, cwd } = setup(t)
|
|
31
31
|
const error = t.mock.method(console, "error")
|
|
32
32
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
33
|
-
assert.deepEqual(config, { hideGauntletWidgets:
|
|
33
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
34
34
|
assert.equal(error.mock.callCount(), 0)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
test("global false overrides default", (t) => {
|
|
38
38
|
const { agentDir, cwd } = setup(t)
|
|
39
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
39
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
40
40
|
const error = t.mock.method(console, "error")
|
|
41
41
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
|
|
42
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
42
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
43
43
|
assert.equal(error.mock.callCount(), 0)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
test("trusted project true overrides global false", (t) => {
|
|
47
47
|
const { agentDir, cwd } = setup(t)
|
|
48
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
49
|
-
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: true }))
|
|
48
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
49
|
+
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: true, editDiff: "expanded" }))
|
|
50
50
|
const error = t.mock.method(console, "error")
|
|
51
51
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
52
|
-
assert.deepEqual(config, { hideGauntletWidgets: true })
|
|
52
|
+
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
|
|
53
53
|
assert.equal(error.mock.callCount(), 0)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
test("untrusted project false does not override global true", (t) => {
|
|
57
57
|
const { agentDir, cwd } = setup(t)
|
|
58
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: true }))
|
|
59
|
-
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: false }))
|
|
58
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: true, editDiff: "expanded" }))
|
|
59
|
+
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
60
60
|
const error = t.mock.method(console, "error")
|
|
61
61
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
|
|
62
|
-
assert.deepEqual(config, { hideGauntletWidgets: true })
|
|
62
|
+
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
|
|
63
63
|
assert.equal(error.mock.callCount(), 0)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
test("omitted keys do not override", (t) => {
|
|
67
67
|
const { agentDir, cwd } = setup(t)
|
|
68
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
68
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
69
69
|
writeProject(cwd, JSON.stringify({}))
|
|
70
70
|
const error = t.mock.method(console, "error")
|
|
71
71
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
72
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
72
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
73
73
|
assert.equal(error.mock.callCount(), 0)
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
test("unknown keys do not override", (t) => {
|
|
77
77
|
const { agentDir, cwd } = setup(t)
|
|
78
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
78
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
79
79
|
writeProject(cwd, JSON.stringify({ extra: true }))
|
|
80
80
|
const error = t.mock.method(console, "error")
|
|
81
81
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
82
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
82
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
83
83
|
assert.equal(error.mock.callCount(), 0)
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
test("invalid JSON calls console.error and leaves the lower-precedence value unchanged", (t) => {
|
|
87
87
|
const { agentDir, cwd } = setup(t)
|
|
88
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
88
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
89
89
|
writeProject(cwd, "{")
|
|
90
90
|
const error = t.mock.method(console, "error")
|
|
91
91
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
92
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
92
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
93
93
|
assert.ok(error.mock.callCount() >= 1)
|
|
94
94
|
})
|
|
95
95
|
|
|
96
96
|
test("non-object root calls console.error and leaves the lower-precedence value unchanged", (t) => {
|
|
97
97
|
const { agentDir, cwd } = setup(t)
|
|
98
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
98
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
99
99
|
writeProject(cwd, JSON.stringify(["hideGauntletWidgets", false]))
|
|
100
100
|
const error = t.mock.method(console, "error")
|
|
101
101
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
102
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
102
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
103
103
|
assert.ok(error.mock.callCount() >= 1)
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
test("non-boolean hideGauntletWidgets calls console.error and leaves the lower-precedence value unchanged", (t) => {
|
|
107
107
|
const { agentDir, cwd } = setup(t)
|
|
108
|
-
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
|
|
108
|
+
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
109
109
|
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: "yes" }))
|
|
110
110
|
const error = t.mock.method(console, "error")
|
|
111
111
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
112
|
-
assert.deepEqual(config, { hideGauntletWidgets: false })
|
|
112
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
113
113
|
assert.ok(error.mock.callCount() >= 1)
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
test("editDiff accepts always, never and expanded, layered like other keys", (t) => {
|
|
117
|
+
const { agentDir, cwd } = setup(t)
|
|
118
|
+
writeGlobal(agentDir, JSON.stringify({ editDiff: "always" }))
|
|
119
|
+
writeProject(cwd, JSON.stringify({ editDiff: "never" }))
|
|
120
|
+
const error = t.mock.method(console, "error")
|
|
121
|
+
assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).editDiff, "always")
|
|
122
|
+
assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true }).editDiff, "never")
|
|
123
|
+
assert.equal(error.mock.callCount(), 0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("an invalid editDiff value is reported and does not override", (t) => {
|
|
127
|
+
const { agentDir, cwd } = setup(t)
|
|
128
|
+
writeGlobal(agentDir, JSON.stringify({ editDiff: "always", hideGauntletWidgets: false }))
|
|
129
|
+
writeProject(cwd, JSON.stringify({ editDiff: "sometimes" }))
|
|
130
|
+
const error = t.mock.method(console, "error")
|
|
131
|
+
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
132
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "always" })
|
|
133
|
+
assert.equal(error.mock.callCount(), 1)
|
|
134
|
+
assert.match(String(error.mock.calls[0]!.arguments[0]), /editDiff must be one of "always", "never", "expanded"/)
|
|
135
|
+
})
|
|
@@ -65,3 +65,18 @@ test("shutdown removes listeners, invalidates handles and emits unavailable once
|
|
|
65
65
|
assert.equal(fresh.result, undefined)
|
|
66
66
|
assert.deepEqual(handle.publish({}), { ok: false, code: "disposed" })
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
test("the plan slot accepts both its name and the legacy alias, and advertises all three", () => {
|
|
70
|
+
const events = bus(), registry = createFooterContributions()
|
|
71
|
+
let ready: { slots: string[] } | undefined
|
|
72
|
+
events.on(FOOTER_EVENTS.ready, (value) => { ready = value as { slots: string[] } })
|
|
73
|
+
bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
74
|
+
assert.ok(ready)
|
|
75
|
+
assert.deepEqual(ready!.slots, ["activity", "plan", "summary"])
|
|
76
|
+
const viaPlan: RegisterRequest = { ...request(), slot: "plan", owner: "a" }
|
|
77
|
+
events.emit(FOOTER_EVENTS.register, viaPlan)
|
|
78
|
+
assert.ok(viaPlan.result?.ok)
|
|
79
|
+
const viaSummary: RegisterRequest = { ...request(), slot: "summary", owner: "b" }
|
|
80
|
+
events.emit(FOOTER_EVENTS.register, viaSummary)
|
|
81
|
+
assert.deepEqual(viaSummary.result, { ok: false, code: "occupied" }, "both names address the same slot")
|
|
82
|
+
})
|
package/test/git-state.test.ts
CHANGED
|
@@ -50,3 +50,38 @@ test("createGitState clears state outside a repository", async () => {
|
|
|
50
50
|
await git.refresh(true)
|
|
51
51
|
assert.equal(git.get(), undefined)
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
test("createGitState reads cwd once per refresh, drops results after dispose, and isolates listener errors", async () => {
|
|
55
|
+
const cwds: string[] = []
|
|
56
|
+
let resolveStatus!: (value: unknown) => void
|
|
57
|
+
const pi = {
|
|
58
|
+
async exec(_cmd: string, args: string[], opts: { cwd: string }) {
|
|
59
|
+
cwds.push(opts.cwd)
|
|
60
|
+
if (args[0] === "rev-parse") return { stdout: "main\n", stderr: "", code: 0, killed: false }
|
|
61
|
+
return new Promise(resolve => { resolveStatus = resolve })
|
|
62
|
+
},
|
|
63
|
+
} as unknown as ExtensionAPI
|
|
64
|
+
let dir = "/a"
|
|
65
|
+
const git = createGitState(pi, () => dir)
|
|
66
|
+
let notified = 0
|
|
67
|
+
git.onChange(() => { throw new Error("boom") })
|
|
68
|
+
git.onChange(() => { notified++ })
|
|
69
|
+
const pending = git.refresh(true)
|
|
70
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
71
|
+
dir = "/b"
|
|
72
|
+
resolveStatus({ stdout: " M x\n", stderr: "", code: 0, killed: false })
|
|
73
|
+
await pending
|
|
74
|
+
assert.deepEqual(cwds, ["/a", "/a"], "both commands use the directory read at refresh start")
|
|
75
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 })
|
|
76
|
+
assert.equal(notified, 1, "a throwing listener does not block the next one")
|
|
77
|
+
|
|
78
|
+
const late = git.refresh(true)
|
|
79
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
80
|
+
git.dispose()
|
|
81
|
+
resolveStatus({ stdout: "", stderr: "", code: 0, killed: false })
|
|
82
|
+
await late
|
|
83
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 }, "a result landing after dispose is ignored")
|
|
84
|
+
assert.equal(notified, 1)
|
|
85
|
+
await git.refresh(true)
|
|
86
|
+
assert.deepEqual(cwds.length, 4, "no new commands after dispose")
|
|
87
|
+
})
|
package/test/index.test.ts
CHANGED
|
@@ -132,7 +132,7 @@ function herdrProbes(execCalls: Call[]): Call[] {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
test.describe("session chrome", { concurrency: 1 }, () => {
|
|
135
|
-
test("TUI session_start installs chrome and
|
|
135
|
+
test("TUI session_start installs chrome and leaves Gauntlet widgets alone by default", async (t) => {
|
|
136
136
|
const { agentDir, cwd } = setupDirs(t)
|
|
137
137
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
138
138
|
delete process.env.HERDR_ENV
|
|
@@ -155,8 +155,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
155
155
|
(call) => typeof call.args[1] === "function" || (call.args[1] !== undefined && call.args[1] !== null),
|
|
156
156
|
),
|
|
157
157
|
)
|
|
158
|
-
assert.
|
|
159
|
-
assert.
|
|
158
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, 0)
|
|
159
|
+
assert.equal(widgetCalls(uiCalls, "plan_tracker").length, 0)
|
|
160
160
|
assert.equal(
|
|
161
161
|
allCalls.some((call) => call.method === "setHideThinkingBlock"),
|
|
162
162
|
false,
|
|
@@ -200,6 +200,26 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
200
200
|
assert.equal(allCalls.some((call) => call.method === "registerTool"), false, "built-in tools are not re-registered")
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
+
test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
|
|
204
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
205
|
+
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
|
|
206
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
207
|
+
delete process.env.HERDR_ENV
|
|
208
|
+
t.after(restoreEnv)
|
|
209
|
+
|
|
210
|
+
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
211
|
+
install(pi)
|
|
212
|
+
t.after(() => emit("session_shutdown"))
|
|
213
|
+
await emit("session_start")
|
|
214
|
+
await new Promise(resolve => setTimeout(resolve, 60))
|
|
215
|
+
const afterSettle = widgetCalls(uiCalls, "phase_tracker").length
|
|
216
|
+
assert.ok(afterSettle >= 1, "hidden at session_start")
|
|
217
|
+
await new Promise(resolve => setTimeout(resolve, 450))
|
|
218
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle, "no steal interval")
|
|
219
|
+
await emit("turn_end")
|
|
220
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle + 1, "hidden again on turn_end")
|
|
221
|
+
})
|
|
222
|
+
|
|
203
223
|
test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
|
|
204
224
|
const { agentDir, cwd } = setupDirs(t)
|
|
205
225
|
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
|
|
@@ -329,3 +349,25 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
|
329
349
|
assert.equal(data.outcome, "completed")
|
|
330
350
|
assert.ok(registerCalls.some((c) => c.method === "registerEntryRenderer" && c.args[0] === "pi-minimal.turn"))
|
|
331
351
|
})
|
|
352
|
+
|
|
353
|
+
test("a second session_start disposes the first session's chrome and the stale one cannot draw", async (t) => {
|
|
354
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
355
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
356
|
+
delete process.env.HERDR_ENV
|
|
357
|
+
t.after(restoreEnv)
|
|
358
|
+
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
359
|
+
install(pi)
|
|
360
|
+
t.after(() => emit("session_shutdown"))
|
|
361
|
+
await emit("session_start")
|
|
362
|
+
const first: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
363
|
+
events.emit(FOOTER_EVENTS.register, first)
|
|
364
|
+
assert.ok(first.result?.ok)
|
|
365
|
+
await emit("session_start")
|
|
366
|
+
assert.equal(first.result.registration.publish({ revision: 1, rows: [], omitted: 0, motion: "static", format: "icons" }).ok, false, "old registration is revoked")
|
|
367
|
+
const second: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
368
|
+
events.emit(FOOTER_EVENTS.register, second)
|
|
369
|
+
assert.ok(second.result?.ok, "the new session accepts a fresh registration")
|
|
370
|
+
const count = uiCalls.length
|
|
371
|
+
await new Promise(resolve => setTimeout(resolve, 70))
|
|
372
|
+
assert.ok(uiCalls.length >= count, "settle callbacks of the live session still run")
|
|
373
|
+
})
|
package/test/tools.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
genericVerb,
|
|
11
11
|
installToolRenderers,
|
|
12
12
|
isToolKind,
|
|
13
|
+
setEditDiffMode,
|
|
13
14
|
stopLiveWatch,
|
|
14
15
|
} from "../src/tools.js"
|
|
15
16
|
|
|
@@ -176,3 +177,100 @@ test("generic renderer draws one row, an error line on failure, and output on ex
|
|
|
176
177
|
const open = r.renderResult!({ content: [{ type: "text", text: "a\nb" }] } as never, { expanded: true, isPartial: false } as never, plainTheme, context as never)
|
|
177
178
|
assert.deepEqual(open.render(90).map((l) => l.trim()), ["│ a", "│ b"])
|
|
178
179
|
})
|
|
180
|
+
|
|
181
|
+
// pi's edit tool emits its own line-numbered form, not a unified diff: "+123 content", "-123 content", " 123 context", "..." between hunks.
|
|
182
|
+
const EDIT_DIFF = " 1 context\n-2 const a = old(x)\n+2 const a = fresh(x)\n 3 context"
|
|
183
|
+
const editResult = { content: [{ type: "text", text: "Successfully replaced 1 block(s)" }], details: { diff: EDIT_DIFF } }
|
|
184
|
+
function editRows(expanded: boolean, result: unknown = editResult, isError = false): string[] {
|
|
185
|
+
const renderers = compactRenderers("edit")
|
|
186
|
+
const context = { args: { path: "a.ts" }, toolCallId: "e1", invalidate() {}, state: {}, isPartial: false, isError, durationMs: 100 } as never
|
|
187
|
+
const theme = {
|
|
188
|
+
fg: (c: string, s: string) => (c.startsWith("toolDiff") ? `<${c.slice(8).toLowerCase()}>${s}` : s),
|
|
189
|
+
inverse: (s: string) => `[${s}]`,
|
|
190
|
+
} as never
|
|
191
|
+
const block = renderers.renderResult!(result as never, { expanded, isPartial: false } as never, theme, context)
|
|
192
|
+
return block.render(80).map((line) => line.trimEnd())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const longDiff = (n: number) => ({ content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: n }, (_, i) => `+${i + 1} line ${i}`).join("\n") } })
|
|
196
|
+
|
|
197
|
+
test("editDiff=expanded (default) shows small diffs inline and larger ones only behind ctrl+o", (t) => {
|
|
198
|
+
t.after(() => setEditDiffMode("expanded"))
|
|
199
|
+
setEditDiffMode("expanded")
|
|
200
|
+
assert.equal(editRows(false).length, 4, "a 4-line diff is shown without ctrl+o")
|
|
201
|
+
assert.equal(editRows(false, longDiff(8)).length, 8, "8 lines is still small")
|
|
202
|
+
assert.deepEqual(editRows(false, longDiff(9)), [], "9 lines waits for ctrl+o")
|
|
203
|
+
assert.equal(editRows(true, longDiff(9)).length, 9)
|
|
204
|
+
const rows = editRows(true)
|
|
205
|
+
assert.equal(rows.length, 4)
|
|
206
|
+
assert.match(rows[0]!, /│ <context> 1 context$/)
|
|
207
|
+
assert.match(rows[1]!, /│ <removed>-2 const a = /)
|
|
208
|
+
assert.match(rows[2]!, /│ <added>\+2 const a = /)
|
|
209
|
+
assert.ok(rows.every((row) => !row.includes("Successfully replaced")), "the diff replaces the result text")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("a one-line replacement highlights only the changed tokens with inverse", (t) => {
|
|
213
|
+
t.after(() => setEditDiffMode("expanded"))
|
|
214
|
+
setEditDiffMode("expanded")
|
|
215
|
+
const [, removed, added] = editRows(true)
|
|
216
|
+
assert.match(removed!, /-2 const a = \[old\]\(x\)$/)
|
|
217
|
+
assert.match(added!, /\+2 const a = \[fresh\]\(x\)$/)
|
|
218
|
+
const multi = editRows(true, { content: [], details: { diff: "-1 a\n-2 b\n+1 c" } })
|
|
219
|
+
assert.ok(multi.every((row) => !row.includes("[")), "blocks that are not 1:1 are not token-highlighted")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("editDiff=always shows the diff inline and editDiff=never only on request falls back to the result text", (t) => {
|
|
223
|
+
t.after(() => setEditDiffMode("expanded"))
|
|
224
|
+
setEditDiffMode("always")
|
|
225
|
+
assert.equal(editRows(false).length, 4)
|
|
226
|
+
assert.equal(editRows(true).length, 4)
|
|
227
|
+
setEditDiffMode("never")
|
|
228
|
+
assert.deepEqual(editRows(false), [])
|
|
229
|
+
assert.match(editRows(true)[0]!, /│ Successfully replaced 1 block\(s\)$/)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("editDiff=always truncates a long inline diff and leaves the full diff to ctrl+o", (t) => {
|
|
233
|
+
t.after(() => setEditDiffMode("expanded"))
|
|
234
|
+
setEditDiffMode("always")
|
|
235
|
+
const long = longDiff(60)
|
|
236
|
+
const inline = editRows(false, long)
|
|
237
|
+
assert.equal(inline.length, 41)
|
|
238
|
+
assert.match(inline[40]!, /… 20 more lines \(ctrl\+o\)$/)
|
|
239
|
+
assert.equal(editRows(true, long).length, 60)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("edit failures and results without a diff never render a diff block", (t) => {
|
|
243
|
+
t.after(() => setEditDiffMode("expanded"))
|
|
244
|
+
setEditDiffMode("always")
|
|
245
|
+
assert.deepEqual(editRows(false, { content: [{ type: "text", text: "edited" }] }), [])
|
|
246
|
+
const failed = editRows(false, { content: [{ type: "text", text: "oldText not found" }] }, true)
|
|
247
|
+
assert.ok(failed.every((row) => !row.includes("<removed>")))
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test("write rows show the written content on ctrl+o and nothing when collapsed", () => {
|
|
251
|
+
const renderers = compactRenderers("write")
|
|
252
|
+
const content = Array.from({ length: 450 }, (_, i) => `line ${i}`).join("\n")
|
|
253
|
+
const context = { args: { path: "a.ts", content }, toolCallId: "w1", invalidate() {}, state: {}, isPartial: false, isError: false, durationMs: 100 } as never
|
|
254
|
+
const result = { content: [{ type: "text", text: "Successfully wrote to a.ts" }] } as never
|
|
255
|
+
const collapsed = renderers.renderResult!(result, { expanded: false } as never, plainTheme, context).render(80)
|
|
256
|
+
assert.deepEqual(collapsed, [])
|
|
257
|
+
const expanded = renderers.renderResult!(result, { expanded: true } as never, plainTheme, context).render(80).map((l) => l.trimEnd())
|
|
258
|
+
assert.equal(expanded.length, 401)
|
|
259
|
+
assert.match(expanded[0]!, /│ line 0$/)
|
|
260
|
+
assert.match(expanded[400]!, /… 50 more lines$/)
|
|
261
|
+
const failed = { args: { path: "a.ts", content }, toolCallId: "w2", invalidate() {}, state: {}, isPartial: false, isError: true, durationMs: 100 } as never
|
|
262
|
+
const err = renderers.renderResult!({ content: [{ type: "text", text: "EACCES" }] } as never, { expanded: true } as never, plainTheme, failed).render(80)
|
|
263
|
+
assert.ok(err.every((l) => !l.includes("line 0")), "a failed write shows its error, not the content")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("createRowRenderers keeps one live key per row across call and result", () => {
|
|
267
|
+
const renderers = compactRenderers("bash")
|
|
268
|
+
const state: Record<string, unknown> = {}
|
|
269
|
+
const call = { args: { command: "sleep 1" }, invalidate() {}, state, isPartial: true }
|
|
270
|
+
renderers.renderCall!({ command: "sleep 1" } as never, plainTheme, call as never)
|
|
271
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
272
|
+
renderers.renderResult!({ content: [{ type: "text", text: "" }] } as never, { expanded: false } as never, plainTheme, { ...call, isPartial: false } as never)
|
|
273
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
274
|
+
assert.equal((state as { running?: boolean }).running, false)
|
|
275
|
+
stopLiveWatch()
|
|
276
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { installWorkingStatus } from "../src/working-status.js"
|
|
5
|
+
|
|
6
|
+
function fixture() {
|
|
7
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>()
|
|
8
|
+
const messages: Array<string | undefined> = []
|
|
9
|
+
const ctx = { mode: "tui", ui: { setWorkingMessage: (text?: string) => { messages.push(text) } } } as unknown as ExtensionContext
|
|
10
|
+
const pi = { on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => { handlers.set(name, handler) } } as unknown as ExtensionAPI
|
|
11
|
+
const stop = installWorkingStatus(pi)
|
|
12
|
+
const emit = (name: string, event: unknown = {}) => handlers.get(name)!(event, ctx)
|
|
13
|
+
return { emit, messages, stop }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("working status walks thinking → tool → thinking → writing and clears when the agent settles", (t) => {
|
|
17
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
18
|
+
const f = fixture()
|
|
19
|
+
f.emit("session_start")
|
|
20
|
+
f.emit("agent_start")
|
|
21
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
22
|
+
f.emit("tool_execution_start", { toolCallId: "t1", toolName: "bash", args: { command: "ls -la" } })
|
|
23
|
+
assert.match(f.messages.at(-1)!, /^Running ls -la/)
|
|
24
|
+
f.emit("tool_execution_end", { toolCallId: "t1" })
|
|
25
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
26
|
+
f.emit("message_update", { message: { role: "assistant", content: [] }, assistantMessageEvent: { type: "text_delta" } })
|
|
27
|
+
assert.match(f.messages.at(-1)!, /^Writing/)
|
|
28
|
+
f.emit("agent_settled")
|
|
29
|
+
assert.equal(f.messages.at(-1), undefined, "settling clears the message")
|
|
30
|
+
f.stop()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("events before session_start or after settle are ignored", () => {
|
|
34
|
+
const f = fixture()
|
|
35
|
+
f.emit("agent_start")
|
|
36
|
+
assert.deepEqual(f.messages, [])
|
|
37
|
+
f.emit("session_start")
|
|
38
|
+
f.emit("tool_execution_start", { toolCallId: "x", toolName: "read" })
|
|
39
|
+
assert.deepEqual(f.messages, [], "a tool without an active run does not paint")
|
|
40
|
+
f.stop()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("session_shutdown clears the working message while the context is still known", (t) => {
|
|
44
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
45
|
+
const f = fixture()
|
|
46
|
+
f.emit("session_start")
|
|
47
|
+
f.emit("agent_start")
|
|
48
|
+
assert.ok(f.messages.at(-1))
|
|
49
|
+
f.emit("session_shutdown")
|
|
50
|
+
assert.equal(f.messages.at(-1), undefined, "shutdown must call setWorkingMessage() to clear")
|
|
51
|
+
f.emit("agent_start")
|
|
52
|
+
assert.equal(f.messages.at(-1), undefined, "no painting after shutdown dropped the context")
|
|
53
|
+
})
|
|
File without changes
|
|
File without changes
|