pi-minimal 0.4.0 → 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/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
- type TaskStatus = "pending" | "in_progress" | "complete" | "failed"
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: TaskStatus }
11
+ type Task = { name: string; status: GauntletTaskStatus }
11
12
 
12
- export type TrackerSnapshot = {
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 readSnapshot(ctx: ExtensionContext): TrackerSnapshot {
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 } = readSnapshot(ctx)
56
+ const { phases, tasks } = readGauntletProjection(ctx)
51
57
  const lines: string[] = []
52
58
  if (phases && phaseActive(phases)) {
53
59
  const parts = PHASES.map((p) => {
@@ -206,8 +206,9 @@ export function installWorkingStatus(pi: ExtensionAPI): () => void {
206
206
  })
207
207
 
208
208
  pi.on("session_shutdown", () => {
209
- ctx = undefined
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
@@ -26,11 +26,11 @@ function writeProject(cwd: string, contents: string) {
26
26
  writeFileSync(join(dir, "pi-minimal.json"), contents)
27
27
  }
28
28
 
29
- test("defaults hideGauntletWidgets to true", (t) => {
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: true, editDiff: "expanded" })
33
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
34
34
  assert.equal(error.mock.callCount(), 0)
35
35
  })
36
36
 
@@ -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
+ })
@@ -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
+ })
@@ -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 hides Gauntlet widgets by default", async (t) => {
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.ok(widgetCalls(uiCalls, "phase_tracker").some((call) => call.args[1] === undefined))
159
- assert.ok(widgetCalls(uiCalls, "plan_tracker").some((call) => call.args[1] === undefined))
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
+ })
@@ -192,10 +192,15 @@ function editRows(expanded: boolean, result: unknown = editResult, isError = fal
192
192
  return block.render(80).map((line) => line.trimEnd())
193
193
  }
194
194
 
195
- test("editDiff=expanded (default) shows the diff only behind ctrl+o, in the theme's diff colours", (t) => {
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) => {
196
198
  t.after(() => setEditDiffMode("expanded"))
197
199
  setEditDiffMode("expanded")
198
- assert.deepEqual(editRows(false), [])
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)
199
204
  const rows = editRows(true)
200
205
  assert.equal(rows.length, 4)
201
206
  assert.match(rows[0]!, /│ <context> 1 context$/)
@@ -227,7 +232,7 @@ test("editDiff=always shows the diff inline and editDiff=never only on request f
227
232
  test("editDiff=always truncates a long inline diff and leaves the full diff to ctrl+o", (t) => {
228
233
  t.after(() => setEditDiffMode("expanded"))
229
234
  setEditDiffMode("always")
230
- const long = { content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: 60 }, (_, i) => `+${i + 1} line ${i}`).join("\n") } }
235
+ const long = longDiff(60)
231
236
  const inline = editRows(false, long)
232
237
  assert.equal(inline.length, 41)
233
238
  assert.match(inline[40]!, /… 20 more lines \(ctrl\+o\)$/)
@@ -241,3 +246,31 @@ test("edit failures and results without a diff never render a diff block", (t) =
241
246
  const failed = editRows(false, { content: [{ type: "text", text: "oldText not found" }] }, true)
242
247
  assert.ok(failed.every((row) => !row.includes("<removed>")))
243
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
+ })