pi-minimal 0.4.0 → 0.6.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/CONTEXT.md +107 -0
  3. package/README.md +9 -6
  4. package/dist/background-overlay.js +95 -0
  5. package/dist/background.js +206 -0
  6. package/dist/config.js +10 -1
  7. package/dist/cwd.js +2 -2
  8. package/dist/edit-diff.js +74 -0
  9. package/dist/footer-contribution-events.js +5 -3
  10. package/dist/footer-contributions.js +15 -13
  11. package/dist/footer.js +15 -3
  12. package/dist/git-state.js +31 -11
  13. package/dist/herdr.js +78 -0
  14. package/dist/index.js +79 -202
  15. package/dist/listeners.js +23 -0
  16. package/dist/prs-command.js +76 -0
  17. package/dist/prs.js +270 -0
  18. package/dist/reclaim.js +0 -3
  19. package/dist/rex.js +289 -0
  20. package/dist/session.js +168 -0
  21. package/dist/siblings.js +14 -0
  22. package/dist/tools.js +213 -374
  23. package/dist/tracker.js +2 -2
  24. package/dist/working-status.js +2 -1
  25. package/doc/archive/README.md +3 -0
  26. package/doc/footer-contributions.md +7 -7
  27. package/package.json +5 -3
  28. package/release-notes.md +33 -12
  29. package/src/background-overlay.ts +103 -0
  30. package/src/background.ts +250 -0
  31. package/src/config.ts +10 -1
  32. package/src/cwd.ts +5 -4
  33. package/src/edit-diff.ts +73 -0
  34. package/src/footer-contribution-events.ts +7 -4
  35. package/src/footer-contributions.ts +15 -13
  36. package/src/footer.ts +23 -3
  37. package/src/git-state.ts +29 -10
  38. package/src/herdr.ts +78 -5
  39. package/src/index.ts +81 -195
  40. package/src/listeners.ts +22 -0
  41. package/src/prs-command.ts +81 -0
  42. package/src/prs.ts +348 -0
  43. package/src/reclaim.ts +0 -4
  44. package/src/rex.ts +304 -0
  45. package/src/session.ts +185 -0
  46. package/src/siblings.ts +38 -0
  47. package/src/tools.ts +261 -389
  48. package/src/tracker.ts +11 -5
  49. package/src/working-status.ts +2 -1
  50. package/test/background.test.ts +198 -0
  51. package/test/config.test.ts +24 -11
  52. package/test/footer-composition.test.ts +61 -0
  53. package/test/footer-contribution-events.test.ts +15 -0
  54. package/test/git-state.test.ts +35 -0
  55. package/test/index.test.ts +202 -4
  56. package/test/prs.test.ts +272 -0
  57. package/test/rex.test.ts +249 -0
  58. package/test/tools.test.ts +36 -3
  59. package/test/working-status.test.ts +53 -0
  60. /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
  61. /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/CHANGELOG.md CHANGED
@@ -10,6 +10,85 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.6.0] - 2026-10-29
14
+
15
+ Rex support and a footer hints line that carries live state: sibling agents from Rex as well as
16
+ Herdr, background processes left by bash calls, and the repo's open PRs as clickable chips.
17
+
18
+ ### Added
19
+
20
+ - Open PRs: the footer shows `#1675 #1670 +3` chips, right-aligned on the hints line below the editor, for open PRs in the repo of cwd that you
21
+ authored or were asked to review, each an OSC 8 hyperlink to the PR, coloured accent (mine), dim
22
+ (my draft), amber (needs my review) or red (failing CI / changes requested). `/prs [mine|review|all]`
23
+ lists them with open / paste URL / checkout actions; `list_open_prs` tool for the model. Uses the
24
+ `gh` CLI; works on GitHub.com and GitHub Enterprise. PRs created by the session (`gh pr create`,
25
+ `gt submit`) are remembered as session entries and shown from any directory, as `repo#n` when
26
+ outside the cwd repo, until merged. New peer dependency `@earendil-works/pi-ai` (supplied by pi)
27
+ for the tool schema.
28
+ - Background processes: `⚙ N bg` after the key hints in the footer while processes left behind by `bash` tool
29
+ calls are running; `ctrl+b` / `/bg` opens an overlay listing each one with running/ended state and
30
+ duration, `k` kills the selection, `K` its process group. Attribution diffs `ps` around each bash
31
+ call using pi's detached process groups, so no command is rewritten. New `backgroundShortcut`
32
+ setting (`"ctrl+b"`; `null` disables the key).
33
+
34
+ - Rex siblings: inside a Rex session (`REX_SESSION` + `REX_BLOCK`) the status line shows other
35
+ panes' agent state, read from Rex's Program Status Protocol (OSC 7501) over one
36
+ `rex events --json` stream instead of polling. Needs a reporter in each pi (an extension that
37
+ writes OSC 7501 on `agent_start` / `agent_settled`); programs such as claude-code or terraform
38
+ that already speak the protocol appear as well. `error` renders like `blocked`.
39
+
40
+ ### Changed
41
+
42
+ - Sibling status now comes from a `SiblingSource` (`src/siblings.ts`) picked from the environment;
43
+ the Herdr poller moved from `session.ts` into `herdr.ts` unchanged. Herdr wins when both hosts
44
+ are present. The status line re-renders when the sibling list changes, not only on git changes.
45
+
46
+ ## [0.5.0] - 2026-10-27
47
+
48
+ Session lifecycle, one tool-row lifecycle, and a domain glossary. `hideGauntletWidgets`
49
+ is now opt-in and the 200 ms chrome steal interval is gone.
50
+
51
+ ### Added
52
+
53
+ - `write` rows show the written content (first 400 lines) when expanded with `ctrl+o`.
54
+ - With `editDiff: "expanded"` (the default), diffs of up to 8 lines are shown inline;
55
+ hiding them saved nothing and cost a keypress. Longer diffs still wait for `ctrl+o`.
56
+ - `CONTEXT.md`: a glossary of the domain (chrome, reclaim, slots, owner token, the snapshot
57
+ kinds, plan vs the legacy pi-gauntlet projection, activity rows, hints, turn rule, working
58
+ status). The 0.1 spec and plan moved to `doc/archive/`.
59
+ - The plan slot can be addressed as `slot: "plan"`; `summary` stays accepted as its legacy
60
+ name and the ready event now lists `["activity", "plan", "summary"]`.
61
+
62
+ ### Changed
63
+
64
+ - `hideGauntletWidgets` now defaults to `false`. Hiding another extension's widgets is
65
+ opt-in; set it to `true` to keep the previous behaviour.
66
+ - Chrome is re-asserted only on events (session start and its two settle ticks, turn
67
+ end, tool end). The 200 ms interval that cleared pi-gauntlet's widgets five times a
68
+ second is gone; it never reinstalled the header or footer anyway.
69
+ - All session-scoped state (config, cwd, the footer contribution registry, the git and
70
+ Herdr pollers, the chrome) now lives on one `Session` object created at session start
71
+ and disposed at shutdown or when the next session starts. Late async results from a
72
+ disposed session are dropped instead of drawing into the new one.
73
+ - The compact, generic and codemode tool rows are now three small strategies over one
74
+ row lifecycle (`createRowRenderers`): running state, live spinner, status colours,
75
+ the error line and expansion are implemented once. Edit-diff colouring moved to
76
+ `src/edit-diff.ts`. No visible change beyond the two items above.
77
+ - Internal renames for clarity: `TrackerSnapshot` → `GauntletProjection`,
78
+ `readSnapshot` → `readGauntletProjection`, the tracker's `TaskStatus` →
79
+ `GauntletTaskStatus`, and the slot's authority symbol is `ownerToken`. No wire change.
80
+ - `doc/footer-contributions.md` now states precisely when the legacy projection is replaced
81
+ (only while a plan line renders), how control characters are sanitised (VT stripped,
82
+ C0/C1/bidi replaced with spaces), and that `inReview`/`blocked` are bounded individually.
83
+
84
+ ### Fixed
85
+
86
+ - The working message is cleared on session shutdown; it used to drop the context
87
+ before clearing and leave the last status on screen.
88
+ - Git state reads the working directory once per refresh, so branch and dirty count
89
+ always describe the same tree, and one throwing change listener no longer blocks
90
+ the others.
91
+
13
92
  ## [0.4.0] - 2026-10-27
14
93
 
15
94
  ### Added
@@ -155,7 +234,8 @@ Releases are cut automatically: when the `version` in `package.json` changes on
155
234
  - Initial release: header/footer chrome, compact tool activity rows, working
156
235
  status line, Herdr sibling status, Gauntlet widget hiding.
157
236
 
158
- [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.2.3...HEAD
237
+ [Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.6.0...HEAD
238
+ [0.6.0]: https://github.com/raviy06/pi-minimal/compare/v0.5.0...v0.6.0
159
239
  [0.2.3]: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
160
240
  [0.2.2]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
161
241
  [0.2.1]: https://github.com/raviy06/pi-minimal/compare/v0.2.0...v0.2.1
package/CONTEXT.md ADDED
@@ -0,0 +1,107 @@
1
+ # pi-minimal domain glossary
2
+
3
+ The vocabulary used in code and docs, one entry per concept, with the file that defines it.
4
+ When a word below has a narrower meaning than in everyday use, the narrower meaning wins.
5
+
6
+ ## Surfaces pi-minimal owns
7
+
8
+ - **Chrome** — the three host slots pi-minimal draws into: the header (`src/header.ts`, intentionally
9
+ empty), the footer (`src/footer.ts`) and the status line (`src/cwd.ts`, a widget above the editor;
10
+ it is *not* pi's `setStatus`). pi has no owner concept for these, so chrome is re-asserted on
11
+ events; see *reclaim*.
12
+ - **Reclaim** — re-installing chrome after an event that may have let another extension draw over
13
+ it (`src/reclaim.ts`). A *reclaim reason* (`session_start`, `session_settle`, `turn_end`,
14
+ `tool_execution_end`) maps to a policy: whether to reset the editor and whether to reinstall chrome.
15
+ - **Status line** — `model · thinking · context meter · cwd · ⎇ branch ±dirty · siblings`
16
+ (`src/cwd.ts`). *Siblings* are other agents in the same host workspace with their state
17
+ (`working | blocked | idle | done | error`).
18
+ - **Sibling source** — where siblings come from (`src/siblings.ts`, `SiblingSource`: `get`,
19
+ `onChange`, `dispose`). Chosen from the environment at session start: the *Herdr source*
20
+ polls `herdr agent list` / `herdr tab list` (`src/herdr.ts`, `HERDR_ENV=1`); the *Rex source*
21
+ follows one `rex events -s SESSION --json` stream and reads OSC 7501 program-status records
22
+ (`src/rex.ts`, `REX_SESSION` + `REX_BLOCK`). Herdr wins when both are set. A sibling's name is
23
+ its tab label, or the program's `app` when the tab only has a Rex auto-label (`Window 1a2b…`).
24
+ - **Hints** — the keybinding labels in the footer (`send · thinking · expand tools`, plus
25
+ `/compact` from 70 % context), shown only when idle with an empty editor (`src/footer.ts`).
26
+ Not task hints and not the change hints other extensions emit.
27
+ - **Tool row** — one line per tool call: `glyph verb item … outcome` (`src/tools.ts`,
28
+ `src/tool-rows.ts`). *Compact* rows are the built-in tools, *generic* rows are tools with no
29
+ renderer of their own (most MCP tools), the *codemode* row has one child row per nested call.
30
+ A row's **outcome** is its right-aligned summary (`34 lines · 1.0s`, `+3 −1`, `exit 1`).
31
+ - **Turn rule** — the dim `── 15:51 · 12s · 4 tools ──` separator appended after each agent run
32
+ (`src/turn-rule.ts`). Stored as a `pi-minimal.turn` custom session entry, never sent to the model.
33
+ Its data is a *TurnSummary*, unrelated to the plan slot.
34
+ - **Working status** — the transient `Thinking · 3s` / `Running ls · 2 tools · 5s` message while
35
+ an agent runs (`src/working-status.ts`), driven by agent/tool/message events and cleared when
36
+ the agent settles. Its state object is a *WorkingSnapshot*.
37
+ - **Thinking gutter** — the `┊` column in front of assistant thinking text (`src/transcript.ts`).
38
+ - **Code box** — the faint frame around fenced code blocks (`src/code-box.ts`, a pi-tui
39
+ `Markdown.prototype.renderToken` patch).
40
+
41
+ - **Open PR** — an open pull request in the repo of `cwd` that I authored (*mine*) or that requests
42
+ my review (*review*); PRs that merely mention me are not shown (`src/prs.ts`, `PullRequest`,
43
+ `kind`). Found with `gh repo view` → `gh api user` (login differs per host) → `gh pr list
44
+ --search involves:@me`. Shown as *chips* right-aligned on the footer's hints line (`src/footer.ts`): `#1675`, an OSC 8 hyperlink to the
45
+ PR, coloured by `prColor` (trouble > review > draft > mine). `/prs` and the `list_open_prs` tool
46
+ live in `src/prs-command.ts`. The *PR source* (`PrSource`) polls every 3 min and on `refreshGit`,
47
+ throttled to once a minute. A **session PR** is one this session created: a `gh pr create` /
48
+ `gt submit` bash call whose output prints a PR URL (`findPrUrls`). Its URL is stored as a
49
+ `pi-minimal.pr` custom session entry (restored on resume) and fetched with `gh pr view URL`
50
+ whatever cwd is, labelled `repo#n` outside the cwd repo, until it is merged or closed.
51
+ - **Background process** — a process a `bash` tool call left running after it returned
52
+ (`src/background.ts`). Found by diffing `ps` before/after the call: new, orphaned (ppid 1) and in a
53
+ *tracked group*, a process group whose leader (the tool shell) was born and died inside the call.
54
+ Groups are polled every 2 s until empty. A process is `running` or `ended` (gone from `ps`, kept
55
+ 60 s; no exit code is knowable). Shown as `⚙ N bg` after the hints on the footer's hints line (`src/footer.ts`) and listed in the
56
+ *background overlay* (`src/background-overlay.ts`, `/bg` or `backgroundShortcut`, default `ctrl+b`).
57
+
58
+ ## Footer contributions (the extension point)
59
+
60
+ - **Contribution** — data another extension publishes for pi-minimal to render in the footer,
61
+ through the in-process `pi-minimal:footer:v1:*` events (`src/footer-contribution-events.ts`).
62
+ pi-minimal keeps ownership of the footer; contributors never draw.
63
+ - **Slot** — one of two independent places a contribution can go: `activity` (agent rows) and
64
+ `plan` (one plan bar). `summary` is the legacy wire name of the plan slot and stays accepted.
65
+ One owner per slot per session (`src/footer-contributions.ts`).
66
+ - **Owner** vs **owner token** — `owner` is the registrant's display identity (a validated
67
+ string). The *owner token* is the `Symbol` handed back on registration; it alone authorises
68
+ `publish`/`dispose`. A slot is *occupied* while a token is live.
69
+ - **Snapshot** — a complete replacement value published to a slot. Two kinds:
70
+ **ActivitySnapshot** (`revision`, `rows`, `omitted`, `motion`, `format`) and **PlanSnapshot**
71
+ (`revision`, `format`, optional `plan`). Publishing never patches; it replaces.
72
+ - **Revision** — a per-slot monotonic counter on each snapshot. A publish whose revision is not
73
+ greater than the current one is *stale* and ignored.
74
+ - **Activity row** — one keyed line in the activity slot: `agent`, `state`, `text`, optional
75
+ `parentKey` (two levels of nesting), `metrics` (`elapsedMs`, `tokens`), `task`
76
+ (`planId`, `id`, `status`). Rendered in producer order, at most four plus `+ N more`
77
+ (`src/footer-activity.ts`, `src/footer-activity-details.ts`).
78
+ - **Activity state** — `queued | running | waiting | paused | stopping | completed | failed |
79
+ stopped | unknown`. Display-only; pi-minimal infers nothing about execution from it.
80
+ - **Plan** — the plan slot's payload: `id`, `accepted`, `total`, `inReview`, `blocked`. Counts
81
+ are supplied by the contributor, never inferred from agent runs (`src/footer-plan.ts`).
82
+ - **Task status (contribution)** — `todo | in_progress | blocked | in_review | done | cancelled`
83
+ on an activity row's `task`. Distinct from the gauntlet task status below.
84
+
85
+ ## Legacy pi-gauntlet projection
86
+
87
+ - **Gauntlet projection** — what pi-gauntlet last reported on the session branch, read from its
88
+ `phase_tracker` / `plan_tracker` tool-result `details` (`src/tracker.ts`,
89
+ `readGauntletProjection`). A projection of another extension's history, not a contribution:
90
+ unversioned and only as stable as those tools' result shapes. A rendered plan line from the
91
+ plan slot replaces it in the footer.
92
+ - **Phase** — `brainstorm → plan → implement → verify → ship`, each
93
+ `pending | in_progress | complete | skipped`.
94
+ - **Gauntlet task status** — `pending | in_progress | complete | failed` (`GauntletTaskStatus`).
95
+ Not interchangeable with the contribution task status.
96
+ - **hideGauntletWidgets** — opt-in setting to clear pi-gauntlet's own widgets on each reclaim,
97
+ since the footer already shows that data (`src/config.ts`).
98
+
99
+ ## Process vs session
100
+
101
+ - **Load-time hooks** — installed once per extension instance: tool renderers, the markdown
102
+ transformer, the code-box patch, working status and the turn rule (`src/index.ts`).
103
+ - **Session** — everything scoped to one pi session: config, cwd, the contribution registry,
104
+ git poller, sibling source, settle timers and chrome (`src/session.ts`, when present). Created on
105
+ `session_start`, disposed on `session_shutdown` or when the next session starts.
106
+ - **Chrome margin** — pi's `outputPad`, mirrored on `globalThis` so module copies reloaded by
107
+ `/reload` agree (`src/chrome.ts`).
package/README.md CHANGED
@@ -7,12 +7,13 @@ A Pi coding-agent package.
7
7
  - Node.js `>=22.19.0`
8
8
  - Pi `>=1.4.0` (uses `registerToolRenderer`, `registerEntryRenderer`, `registerMarkdownTransformer`)
9
9
 
10
- No other Pi extension is required. Pi supplies the peer packages used by this extension.
10
+ No other Pi extension is required. Pi supplies the peer packages used by this extension. The `gh` CLI is needed only for the open-PR chips.
11
11
 
12
12
  Optional integrations:
13
13
 
14
- - `pi-gauntlet`: supplies the phase and task data shown in the footer. Its widgets are hidden by default.
15
- - Herdr: sibling status appears only when Pi is running inside Herdr.
14
+ - `pi-gauntlet`: supplies the phase and task data shown in the footer. Set `hideGauntletWidgets: true` to also hide its own widgets, since the footer already shows that data.
15
+ - Herdr: sibling status (other agents in the workspace, `working` / `blocked` / `idle`) appears when Pi runs inside a Herdr pane (`HERDR_ENV=1`). Herdr's own pi integration reports each pane's state.
16
+ - Rex: the same sibling status appears when Pi runs inside a [Rex](https://www.superlogical.com/rex) session (`REX_SESSION` + `REX_BLOCK`). Rex learns a pane's state from the [Program Status Protocol (OSC 7501)](https://www.superlogical.com/rex/docs/build/program-status), so each pi needs an extension that reports it (e.g. a `rex-agent-state.ts` that writes `ESC ] 7501 ; state=working:app=pi ESC \\` on `agent_start`); other programs that speak the protocol show up too. Herdr wins when both are present.
16
17
 
17
18
  ## Installation
18
19
 
@@ -33,8 +34,10 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
33
34
  - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output. Tools without a renderer of their own (most MCP tools) get the same one-line row; tools that draw themselves keep their look. Bash rows drop a leading `cd <session cwd> &&` and shorten other `cd` prefixes to the directory name.
34
35
  - **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
35
36
  - **Thinking gutter** – every line of a thinking paragraph, wrapped lines included, starts with `┊`, so thinking reads as a column beside prose.
36
- - **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr siblings.
37
+ - **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · siblings (other agents in the same Herdr workspace or Rex session, with their state).
37
38
  - **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
39
+ - **Open PRs** – for the GitHub repo in the working directory (any host `gh` is logged in to), the footer shows chips for open PRs you authored or were asked to review, right-aligned on the key-hints line below the editor: `#1675 #1670 +3`. The chips stay while you type or the agent works, even though the hints hide. PRs the session itself creates (a `bash` call running `gh pr create` or `gt submit`) are remembered — also across `/resume` — and shown wherever the session is, even outside a repo, labelled `repo#1675` when they are not in the cwd repo; they drop off once merged or closed. Each chip is a terminal hyperlink to the PR (OSC 8; click or cmd-click in Rex, Ghostty, iTerm2, WezTerm, Kitty). Colours: accent = mine, dim = my draft, amber = waiting on my review, red = failing CI or changes requested. `/prs [mine|review|all]` lists them with actions (open in browser, paste URL into the editor, `gh pr checkout`); the `list_open_prs` tool gives the model the same list. Refreshed every 3 minutes and after each turn (at most once a minute). Needs the `gh` CLI.
40
+ - **Background processes** – when a `bash` tool call leaves something running (`npm run dev &`, `nohup ./watch.sh`), the footer shows `⚙ 2 bg` after the key hints below the editor (it stays while the hints hide). `ctrl+b` (or `/bg`) opens a list with each process, whether it is running or has ended, and for how long; `k` kills the selected process, `K` its whole process group. Pi kills these groups when it exits, so the list is scoped to this pi. Attribution uses process groups (pi spawns each bash call detached), so commands are never rewritten; a process that has already exited shows as `ended` without an exit code, since it was reparented away from pi. Set `backgroundShortcut` in `pi-minimal.json` to another key, or `null` to keep only `/bg`.
38
41
  - **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
39
42
  - **Alignment** – chrome follows pi's `outputPad` setting. Pi's editor uses the separate `editorPaddingX` setting (default `0`); set `"editorPaddingX": 1` for a flush left edge with the transcript.
40
43
  - **Theme** – ships `minimal-dark`: no background behind tool rows (failures excepted), a grey ladder whose readable tiers stay near 4.5:1, syntax colours kept off the status hues, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
@@ -51,8 +54,8 @@ Project values override global values. A missing file, omitted key, or invalid v
51
54
 
52
55
  Supported keys:
53
56
 
54
- - `hideGauntletWidgets` (boolean, default `true`).
55
- - `editDiff` (`"always"` | `"never"` | `"expanded"`, default `"expanded"`) – when an `edit` row shows its diff. `"expanded"` puts it behind `ctrl+o`; `"always"` shows it inline as the edit lands (first 40 lines, the rest behind `ctrl+o`); `"never"` keeps the `+3 −1` stats only.
57
+ - `hideGauntletWidgets` (boolean, default `false`) – when `true`, pi-minimal clears pi-gauntlet's `phase_tracker` and `plan_tracker` widgets each time it re-asserts its chrome (session start, turn end, tool end).
58
+ - `editDiff` (`"always"` | `"never"` | `"expanded"`, default `"expanded"`) – when an `edit` row shows its diff. `"expanded"` shows diffs of up to 8 lines inline and puts longer ones behind `ctrl+o`; `"always"` shows every diff inline as the edit lands (first 40 lines, the rest behind `ctrl+o`); `"never"` keeps the `+3 −1` stats only. `write` rows show the written content behind `ctrl+o`.
56
59
 
57
60
  ```json
58
61
  { "hideGauntletWidgets": true, "editDiff": "always" }
@@ -0,0 +1,95 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { formatElapsed } from "./working-status.js";
3
+ export function backgroundRows(snap, now) {
4
+ const row = (proc) => ({
5
+ pid: proc.pid,
6
+ command: proc.command,
7
+ status: proc.endedAt === undefined ? "running" : "ended",
8
+ durationMs: (proc.endedAt ?? now) - proc.startedAt,
9
+ });
10
+ return [...snap.running.map(row), ...snap.ended.map(row)];
11
+ }
12
+ /** Status-line segment: `⚙ 2 bg`; undefined when nothing is running. */
13
+ export function backgroundSegment(snap) {
14
+ if (snap.running.length === 0)
15
+ return undefined;
16
+ return `⚙ ${snap.running.length} bg`;
17
+ }
18
+ /** Lines of the overlay body for a given width; pure so it can be tested without a TUI. */
19
+ export function renderBackgroundLines(rows, selected, width, theme, shortcut) {
20
+ const title = theme.fg("accent", "Background processes");
21
+ const hint = theme.fg("dim", shortcut ? `↑↓ select · k kill · K kill group · esc close (${shortcut})` : "↑↓ select · k kill · K kill group · esc close");
22
+ if (rows.length === 0) {
23
+ return [title, theme.fg("muted", "Nothing running in the background."), hint].map((line) => truncateToWidth(line, width));
24
+ }
25
+ const statusWidth = 7;
26
+ const durations = rows.map((row) => (row.status === "running" ? formatElapsed(row.durationMs) : `ran ${formatElapsed(row.durationMs)}`));
27
+ const durationWidth = Math.max(...durations.map((text) => text.length));
28
+ const commandWidth = Math.max(8, width - 2 - 1 - statusWidth - 2 - durationWidth);
29
+ const lines = rows.map((row, index) => {
30
+ const marker = index === selected ? theme.fg("accent", "› ") : " ";
31
+ const command = truncateToWidth(row.command, commandWidth, "…").padEnd(commandWidth);
32
+ const status = row.status === "running" ? theme.fg("success", "running") : theme.fg("muted", "ended ");
33
+ const duration = theme.fg("dim", (durations[index] ?? "").padStart(durationWidth));
34
+ const text = `${marker}${index === selected ? theme.fg("text", command) : theme.fg("muted", command)} ${status} ${duration}`;
35
+ return visibleWidth(text) > width ? truncateToWidth(text, width) : text;
36
+ });
37
+ return [title, ...lines, hint];
38
+ }
39
+ /** Shows the overlay until the user closes it. Resolves when closed. */
40
+ export async function showBackgroundOverlay(ctx, tracker, shortcut) {
41
+ if (ctx.mode !== "tui") {
42
+ const rows = backgroundRows(tracker.get(), Date.now());
43
+ ctx.ui.notify(rows.length === 0 ? "Nothing running in the background." : rows.map((row) => `${row.pid} ${row.status} ${formatElapsed(row.durationMs)} ${row.command}`).join("\n"), "info");
44
+ return;
45
+ }
46
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
47
+ let selected = 0;
48
+ let cached;
49
+ const invalidate = () => { cached = undefined; };
50
+ const redraw = () => { invalidate(); tui.requestRender(); };
51
+ const unsubscribe = tracker.onChange(redraw);
52
+ const ticker = setInterval(redraw, 1000);
53
+ const rows = () => backgroundRows(tracker.get(), Date.now());
54
+ const finish = () => {
55
+ clearInterval(ticker);
56
+ unsubscribe();
57
+ done();
58
+ };
59
+ return {
60
+ render(width) {
61
+ if (cached && cached.width === width)
62
+ return cached.lines;
63
+ const current = rows();
64
+ if (selected >= current.length)
65
+ selected = Math.max(0, current.length - 1);
66
+ cached = { width, lines: renderBackgroundLines(current, selected, width, theme, shortcut) };
67
+ return cached.lines;
68
+ },
69
+ handleInput(data) {
70
+ const current = rows();
71
+ if (keybindings.matches(data, "tui.select.cancel")) {
72
+ finish();
73
+ return;
74
+ }
75
+ if (keybindings.matches(data, "tui.select.up"))
76
+ selected = current.length === 0 ? 0 : (selected - 1 + current.length) % current.length;
77
+ else if (keybindings.matches(data, "tui.select.down"))
78
+ selected = current.length === 0 ? 0 : (selected + 1) % current.length;
79
+ else if (data === "k" || data === "K") {
80
+ const row = current[selected];
81
+ if (row?.status === "running")
82
+ tracker.kill(row.pid, { group: data === "K" });
83
+ }
84
+ else
85
+ return;
86
+ redraw();
87
+ },
88
+ invalidate,
89
+ dispose() {
90
+ clearInterval(ticker);
91
+ unsubscribe();
92
+ },
93
+ };
94
+ }, { overlay: true, overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" } });
95
+ }
@@ -0,0 +1,206 @@
1
+ import { createListeners } from "./listeners.js";
2
+ export const PS_ARGS = ["-ax", "-o", "pid=,ppid=,pgid=,etime=,command="];
3
+ const ENDED_TTL_MS = 60_000;
4
+ const POLL_MS = 2000;
5
+ const PS_TIMEOUT_MS = 3000;
6
+ /** Parses `etime` (`SS`, `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`) into seconds. */
7
+ export function parseEtime(text) {
8
+ const [daysPart, clock] = text.includes("-") ? text.split("-", 2) : [undefined, text];
9
+ const parts = (clock ?? "").split(":").map((part) => Number.parseInt(part, 10));
10
+ if (parts.some((part) => !Number.isFinite(part)))
11
+ return 0;
12
+ let seconds = 0;
13
+ for (const part of parts)
14
+ seconds = seconds * 60 + part;
15
+ const days = daysPart === undefined ? 0 : Number.parseInt(daysPart, 10);
16
+ return seconds + (Number.isFinite(days) ? days : 0) * 86400;
17
+ }
18
+ /** Parses `ps -ax -o pid=,ppid=,pgid=,etime=,command=` output. */
19
+ export function parsePs(stdout) {
20
+ const entries = new Map();
21
+ for (const line of stdout.split("\n")) {
22
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
23
+ if (!match)
24
+ continue;
25
+ const pid = Number(match[1]);
26
+ entries.set(pid, {
27
+ pid,
28
+ ppid: Number(match[2]),
29
+ pgid: Number(match[3]),
30
+ elapsedSec: parseEtime(match[4] ?? ""),
31
+ command: (match[5] ?? "").trim(),
32
+ });
33
+ }
34
+ return entries;
35
+ }
36
+ /**
37
+ * Process groups a finished tool call left behind: processes present only in `after`, orphaned
38
+ * (ppid 1), whose group leader is a pid that did not exist in `before` and is gone in `after`.
39
+ * Any group that was alive before the call, or whose leader still runs, belongs to someone else.
40
+ */
41
+ export function findOrphanGroups(before, after) {
42
+ const groupsBefore = new Set();
43
+ for (const entry of before.values())
44
+ groupsBefore.add(entry.pgid);
45
+ const groups = new Set();
46
+ for (const [pid, entry] of after) {
47
+ if (before.has(pid) || entry.ppid !== 1)
48
+ continue;
49
+ if (groupsBefore.has(entry.pgid) || after.has(entry.pgid))
50
+ continue;
51
+ groups.add(entry.pgid);
52
+ }
53
+ return groups;
54
+ }
55
+ export function createBackgroundState() {
56
+ return { groups: new Set(), processes: new Map() };
57
+ }
58
+ /**
59
+ * Reconciles tracked processes with a fresh `ps` listing: records new members of tracked groups,
60
+ * marks vanished ones ended, forgets ended ones after their TTL, and drops empty groups.
61
+ * Returns whether anything changed.
62
+ */
63
+ export function applyPoll(state, ps, now) {
64
+ let changed = false;
65
+ const seen = new Set();
66
+ for (const entry of ps.values()) {
67
+ if (!state.groups.has(entry.pgid) || entry.pid === entry.pgid)
68
+ continue;
69
+ seen.add(entry.pid);
70
+ const known = state.processes.get(entry.pid);
71
+ if (known && known.endedAt === undefined)
72
+ continue;
73
+ state.processes.set(entry.pid, {
74
+ pid: entry.pid,
75
+ pgid: entry.pgid,
76
+ command: entry.command,
77
+ startedAt: now - entry.elapsedSec * 1000,
78
+ });
79
+ changed = true;
80
+ }
81
+ for (const [pid, proc] of state.processes) {
82
+ if (seen.has(pid))
83
+ continue;
84
+ if (proc.endedAt === undefined) {
85
+ proc.endedAt = now;
86
+ changed = true;
87
+ }
88
+ else if (now - proc.endedAt > ENDED_TTL_MS) {
89
+ state.processes.delete(pid);
90
+ changed = true;
91
+ }
92
+ }
93
+ for (const pgid of state.groups) {
94
+ const live = [...state.processes.values()].some((proc) => proc.pgid === pgid);
95
+ if (!live) {
96
+ state.groups.delete(pgid);
97
+ changed = true;
98
+ }
99
+ }
100
+ return changed;
101
+ }
102
+ export function snapshot(state) {
103
+ const running = [];
104
+ const ended = [];
105
+ for (const proc of state.processes.values())
106
+ (proc.endedAt === undefined ? running : ended).push(proc);
107
+ running.sort((a, b) => a.startedAt - b.startedAt);
108
+ ended.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
109
+ return { running, ended };
110
+ }
111
+ export function createBackgroundTracker(deps) {
112
+ const now = deps.now ?? Date.now;
113
+ const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
114
+ const tools = new Set(deps.tools ?? ["bash", "powershell"]);
115
+ const state = createBackgroundState();
116
+ const listeners = createListeners();
117
+ const starts = new Map();
118
+ let alive = true;
119
+ let timer;
120
+ let polling = false;
121
+ const listProcesses = async () => {
122
+ try {
123
+ const run = await deps.pi.exec("ps", PS_ARGS, { timeout: PS_TIMEOUT_MS });
124
+ if (run.code !== 0 || run.killed)
125
+ return undefined;
126
+ return parsePs(run.stdout);
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ };
132
+ const schedule = () => {
133
+ if (!alive || timer || state.groups.size === 0)
134
+ return;
135
+ timer = setTimeout(() => {
136
+ timer = undefined;
137
+ void poll();
138
+ }, POLL_MS);
139
+ };
140
+ const poll = async () => {
141
+ if (!alive || polling)
142
+ return;
143
+ polling = true;
144
+ try {
145
+ const ps = await listProcesses();
146
+ if (!alive)
147
+ return;
148
+ if (ps && applyPoll(state, ps, now()))
149
+ listeners.notify();
150
+ }
151
+ finally {
152
+ polling = false;
153
+ schedule();
154
+ }
155
+ };
156
+ return {
157
+ get: () => snapshot(state),
158
+ onChange: listeners.add,
159
+ toolStart(toolCallId, toolName) {
160
+ if (!alive || !tools.has(toolName))
161
+ return;
162
+ starts.set(toolCallId, listProcesses());
163
+ },
164
+ toolEnd(toolCallId, toolName) {
165
+ const before = starts.get(toolCallId);
166
+ starts.delete(toolCallId);
167
+ if (!alive || !before || !tools.has(toolName))
168
+ return;
169
+ void (async () => {
170
+ const [beforePs, afterPs] = await Promise.all([before, listProcesses()]);
171
+ if (!alive || !beforePs || !afterPs)
172
+ return;
173
+ const groups = findOrphanGroups(beforePs, afterPs);
174
+ if (groups.size === 0)
175
+ return;
176
+ for (const pgid of groups)
177
+ state.groups.add(pgid);
178
+ if (applyPoll(state, afterPs, now()))
179
+ listeners.notify();
180
+ schedule();
181
+ })();
182
+ },
183
+ kill(pid, options = {}) {
184
+ const proc = state.processes.get(pid);
185
+ if (!proc)
186
+ return false;
187
+ try {
188
+ killProcess(options.group ? -proc.pgid : pid, options.signal ?? "SIGTERM");
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ },
195
+ dispose() {
196
+ if (!alive)
197
+ return;
198
+ alive = false;
199
+ if (timer)
200
+ clearTimeout(timer);
201
+ timer = undefined;
202
+ starts.clear();
203
+ listeners.clear();
204
+ },
205
+ };
206
+ }
package/dist/config.js CHANGED
@@ -3,8 +3,10 @@ import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
4
  export const EDIT_DIFF_MODES = ["always", "never", "expanded"];
5
5
  const DEFAULTS = {
6
- hideGauntletWidgets: true,
6
+ // Hiding another extension's widgets is opt-in.
7
+ hideGauntletWidgets: false,
7
8
  editDiff: "expanded",
9
+ backgroundShortcut: "ctrl+b",
8
10
  };
9
11
  export function loadPiMinimalConfig(options) {
10
12
  const config = { ...DEFAULTS };
@@ -43,6 +45,13 @@ function applyLayer(config, path) {
43
45
  else
44
46
  console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`);
45
47
  }
48
+ if (Object.hasOwn(record, "backgroundShortcut")) {
49
+ const value = record.backgroundShortcut;
50
+ if (value === null || (typeof value === "string" && value.trim().length > 0))
51
+ config.backgroundShortcut = value === null ? null : value.trim();
52
+ else
53
+ console.error(`pi-minimal: backgroundShortcut must be a key name such as "ctrl+b" or null in ${path}`);
54
+ }
46
55
  if (Object.hasOwn(record, "editDiff")) {
47
56
  const value = record.editDiff;
48
57
  if (isEditDiffMode(value))
package/dist/cwd.js CHANGED
@@ -86,13 +86,13 @@ function siblingColor(status) {
86
86
  return "success";
87
87
  if (status === "working")
88
88
  return "accent";
89
- if (status === "blocked")
89
+ if (status === "blocked" || status === "error")
90
90
  return "error";
91
91
  return "muted";
92
92
  }
93
93
  export function installCwd(ctx, sources) {
94
94
  ctx.ui.setWidget("tw-cwd", (tui, theme) => {
95
- const unsub = sources.onGitChange(() => tui.requestRender());
95
+ const unsub = sources.onChange(() => tui.requestRender());
96
96
  return {
97
97
  dispose: unsub,
98
98
  invalidate() { },