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.
- package/CHANGELOG.md +81 -1
- package/CONTEXT.md +107 -0
- package/README.md +9 -6
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +10 -1
- package/dist/cwd.js +2 -2
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/footer.js +15 -3
- package/dist/git-state.js +31 -11
- package/dist/herdr.js +78 -0
- package/dist/index.js +79 -202
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/reclaim.js +0 -3
- package/dist/rex.js +289 -0
- package/dist/session.js +168 -0
- package/dist/siblings.js +14 -0
- package/dist/tools.js +213 -374
- 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 +5 -3
- package/release-notes.md +33 -12
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +10 -1
- package/src/cwd.ts +5 -4
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/footer.ts +23 -3
- package/src/git-state.ts +29 -10
- package/src/herdr.ts +78 -5
- package/src/index.ts +81 -195
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/reclaim.ts +0 -4
- package/src/rex.ts +304 -0
- package/src/session.ts +185 -0
- package/src/siblings.ts +38 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +24 -11
- package/test/footer-composition.test.ts +61 -0
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +202 -4
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
- package/test/tools.test.ts +36 -3
- 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/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.
|
|
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.
|
|
15
|
-
- Herdr: sibling status appears
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
95
|
+
const unsub = sources.onChange(() => tui.requestRender());
|
|
96
96
|
return {
|
|
97
97
|
dispose: unsub,
|
|
98
98
|
invalidate() { },
|