pi-jar 0.1.7 → 0.1.8
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/README.md +4 -4
- package/docs/DIFF-REVIEW.md +5 -2
- package/docs/WORKFLOWS.md +2 -2
- package/package.json +1 -1
- package/src/changes.ts +147 -12
- package/src/delegate-worktree.ts +71 -9
- package/src/delegate.ts +7 -1
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +196 -59
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
package/README.md
CHANGED
|
@@ -254,14 +254,14 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
|
|
|
254
254
|
```
|
|
255
255
|
Use `parent` with `append` to add a genuinely new subtask to active work; `start`, `done`, `open`, `edit`, `list`, `add` and `delete` remain available.
|
|
256
256
|
- **`jar_ask`** — structured questions: numbered options with descriptions, single or multi-select, *Type your own answer* (multi-line, paste-friendly) and *Chat about this* to discuss before choosing.
|
|
257
|
-
- **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a full-path header and numbered, colored diff on the right. `/` filters files; `t` switches unified
|
|
257
|
+
- **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a full-path header and numbered, colored diff on the right. Changed lines get a colored rail and tinted background, replaced lines highlight the changed words, code is syntax-highlighted for known languages, and collapsed regions are labeled `┄ N unchanged lines ┄`. `/` filters files; `t` switches unified and aligned side-by-side views (unified below 70 columns), `←`/`→` (or `[`/`]`) change the context from 0 lines to the whole file, `w` wraps long lines, and `n`/`p`, `Home`/`End` navigate. Large changes offer summary or explicit windowed review (`v`), with clearly labeled coarse fallback and existing safety limits—not arbitrary-file streaming. See [diff review](docs/DIFF-REVIEW.md). `a` accept (keep, stop tracking), `r` then `y` revert (restore the original, or remove a file it created), `A`/`R` for all. The footer shows `± N files · /diff` while anything is unreviewed; each file counts once no matter who edited it. Only `edit`/`write` changes inside the project are tracked; shell-made changes are not.
|
|
258
258
|
- **`jar_shell`** — `start` (with optional `name`, `watch` regex, `notify` and task/service `purpose`), `list`, `peek`, bounded `wait`, `output`, `kill`. Output is ANSI-stripped and bounded (2000 lines). Watch/exit notifications are compact and coalesced; reads acknowledge observed events so obsolete completions do not replay. Relevant finite checks should be verified with `wait`/`peek` before claiming success; services should not block completion. Each running shell gets a footer row; the activity view (`/jar shells`, a click on the row) tails it live (`x` kill, `f` follow). All shells stop when the session ends.
|
|
259
259
|
- **`jar_delegate`** — spawns **retained session-scoped** subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 4). Idle/paused children and live legacy workers count toward the shared limit, not the parent; over-cap batches are rejected before launch. Lowering the limit does not terminate existing children, but blocks new launches until there is room. The parent is given moderator context and should coordinate rather than duplicate their work. `mode: "scout"` is fresh/read-only and defaults to the `scout` role; `fork` is a true Pi session fork, read-only, and defaults to `reviewer`; `worktree` forks context and defaults to `worker`, editing inside a path-guarded disposable Git worktree. A settled retained agent becomes `idle`, keeping its process, model context and workspace for reuse. Launch returns before startup or the first turn completes, including the old one-shot `write: true` shared-workspace mode retained for compatibility.
|
|
260
260
|
- **`jar_subagent`** — moderator control plane: `peek` returns only task progress (done/total, the in-progress task, activity, the next open tasks), read from memory; `steer` redirects a running worker; `resume` wakes that same agent with an optional instruction. `ask`, `pause` and `stop` return **accepted operation receipts immediately**, never waiting for a child turn, Git reconciliation, or process shutdown; their answers and final handoffs arrive as `pi-jar.subagent` events. Acceptance does not mean a worker is already stopped or its files applied. Equivalent in-flight requests share an operation ID; conflicting controls are rejected, while `stop` can preempt an outstanding ask/pause. `pause` preserves context/workspace and `stop` reports progress, work directory, changed/applied files and what remains. When an initial or resumed turn ends, a visible `pi-jar.subagent` event-bus message wakes the moderator (or queues for its current run) with progress and the turn's report, so it can keep responding to user steering and never polls `peek`. Subagents use Pi's native `--mode rpc` JSONL protocol with streamed lifecycle events, not polling. Input queues and transcript buffers are bounded; shutdown signals the entire process group and drains inherited pipes for a bounded interval. Background control failures also produce a terminal event. A pause attempt settles or reports failure after 15 s, and a working child with no RPC activity for 10 minutes is stopped as hung.
|
|
261
261
|
- **Subagent capabilities** — each task can set `tools` or `inheritTools: true`; known-tool mode filters and child guards remain in force. See [tool capabilities](docs/SUBAGENT-TOOLS.md).
|
|
262
262
|
- **Worktree finalization** — worktree edits do **not** touch the parent merely because a worker becomes idle. They remain private across pause/resume cycles. On `jar_subagent stop`, pi-jar verifies parent drift and reviewability, applies safe changes, and adds them to `/diff`; conflicts remain isolated and the workspace path is reported. Resolve parent drift and retry `stop` to reconcile; failed workspaces are retained rather than trimmed out of report history. Process closure is awaited before finalization. All replacements/rollback backups are prepared before parent mutation; failed multi-file application rolls back rather than silently leaving a partial result.
|
|
263
263
|
- **`jar_democracy`** — **exceptional only**, for a persistent **super-complex** issue with several viable options and evidence of at least two distinct failed approaches. The moderator opens 2–8 evidence-backed options, resumes explicitly relevant idle/paused **fresh-context read-only scouts** or spawns fresh scouts within the same pool limit, and collects one private ballot per voter before publishing the tally. A **strict majority of the invited electorate** wins; ties, plurality without majority, and missing/invalid ballots require the user to choose among leading options, with evidence/tradeoffs rather than invented probabilities. TUI uses the structured question picker; RPC returns `needs-user` for the moderator to resolve with `jar_ask`. Voting returns a recommendation and **never implements it** or bypasses safety/approval. Prefer new scouts if past discussions could bias reused agents. Do not use democracy for ordinary complexity or routine decisions. A round is bounded: a 10-minute ballot deadline records unfinished voters as failures, rationales are capped at 600 characters, and scouts spawned for the round are retired when it ends.
|
|
264
|
-
- **`jar_discuss`** — a bounded shared discussion paper for virtual agent-to-agent discussion. Questions and answers have stable IDs plus author/target metadata; entries are capped at 1600 characters, the newest 64 are retained, and serialized UTF-8 is capped at 64 KiB (older entries are evicted until it fits). `list`
|
|
264
|
+
- **`jar_discuss`** — a bounded shared discussion paper for virtual agent-to-agent discussion. Questions and answers have stable IDs plus author/target metadata; entries are capped at 1600 characters, the newest 64 are retained, and serialized UTF-8 is capped at 64 KiB (older entries are evicted until it fits). `ask`/`answer` reply with the new id only (never the text) plus, when they fit, unseen answers to your questions and questions addressed to you. `list` returns only entries by others that this agent has not seen yet (capped at 4000 characters; one line when nothing changed); `since: "d0"` rereads everything and `questionId` reads one thread in full. Agents use it for narrow Q/A, not as another transcript.
|
|
265
265
|
- **Activity view** — one split view (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>, `/jar activity`, or a click on a footer row) for subagents, background shells and other extensions' teammates: list on the left, live details on the right (auto-follows; scroll up to pause, `f` to follow again). For a subagent the details show its task, its own checklist with subtasks, and a transcript like omp's: each tool call and message is one collapsed line that expands on demand (`Tab` into it, `↑↓`, `Enter`, or click) to show arguments and output, while streaming text shows live. `s` opens a steering input: type a message and press `Enter` to redirect the running subagent. `x` stops just that one. Finished runs stay listed for review.
|
|
266
266
|
- **`/jar history`** — separate, read-only timeline of the active branch (paging, search, expandable details). Pi's native transcript is untouched.
|
|
267
267
|
- Pi's built-in read/shell/edit/write tool cards render compactly: collapsed, each is one padded, state-colored card with its call line and a one-line summary (edit included), and partially streamed arguments never flicker to a fallback; the full output or diff stays one click or <kbd>Ctrl</kbd>+<kbd>O</kbd> away. Background sources (subagent streams, shell output) repaint the footer at most every 250 ms and the activity view every 100 ms, because every requested frame re-renders the whole transcript. The sliding bar some terminals show in the tab while the agent works is Pi's own `OSC 9;4` progress (`terminal.showTerminalProgress` in Pi settings), not pi-jar.
|
|
@@ -299,7 +299,7 @@ pi-jar/
|
|
|
299
299
|
│ ├── commit.ts /jar commit
|
|
300
300
|
│ ├── usage-view.ts /usage; context-view.ts is /context; panel.ts frames both
|
|
301
301
|
│ ├── split-view.ts shared two-pane frame
|
|
302
|
-
│ ├── changes.ts change tracker and
|
|
302
|
+
│ ├── changes.ts change tracker, line diff and split layout; diff-view.ts is /diff, diff-inline.ts its word highlights
|
|
303
303
|
│ ├── shells.ts background shells and jar_shell
|
|
304
304
|
│ ├── delegate.ts retained subagents, moderator controls and RPC lifecycle
|
|
305
305
|
│ ├── delegate-worktree.ts isolated Git worktrees, path guard and safe reconciliation
|
|
@@ -319,7 +319,7 @@ pi-jar/
|
|
|
319
319
|
|
|
320
320
|
## Git executable and subprocess safety
|
|
321
321
|
|
|
322
|
-
pi-jar runs worktree Git plumbing asynchronously with a 30-second per-command deadline and terminates the process group on timeout or cancellation. To bypass a Git proxy/wrapper (for example, git-ai), set `PI_JAR_GIT_PATH` to the real Git executable before starting Pi:
|
|
322
|
+
pi-jar runs worktree Git plumbing asynchronously with a 30-second per-command deadline and terminates the process group on timeout or cancellation. Worktree snapshots and change inspection hash files in bounded batches (at most 1,000 files or 128 MiB per Git call), so startup and finalization cost a handful of Git processes rather than several per repository file. To bypass a Git proxy/wrapper (for example, git-ai), set `PI_JAR_GIT_PATH` to the real Git executable before starting Pi:
|
|
323
323
|
|
|
324
324
|
```bash
|
|
325
325
|
PI_JAR_GIT_PATH=/usr/bin/git pi
|
package/docs/DIFF-REVIEW.md
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
- Type `/` in the file list to filter by absolute/relative path or change status (content search is intentionally omitted to keep keystrokes bounded over large retained snapshots). Press Enter to keep the filter, Escape to clear it, and `n`/`N` to move through matching files.
|
|
6
6
|
- The right pane starts with the selected file's absolute path and change summary.
|
|
7
|
-
- Normal previews render a viewport of
|
|
7
|
+
- Normal previews render a viewport of Git-style hunks. Each hunk header is preceded by a `┄ N unchanged lines ┄` label for the region it skips. `PgUp`/`PgDn`, `g`/`G`, `Home`/`End`, or the mouse wheel move through the preview; `n`/`p` jump hunks. Rendering windows the requested rows instead of constructing a rendered row for every file line.
|
|
8
|
+
- `t` toggles unified rows and aligned side-by-side Original/Updated columns: within a replacement, the k-th removed line sits beside the k-th added line, and a longer side continues alone. Panes narrower than 70 columns keep the unified layout. Toggling keeps the hunk at the top of the viewport.
|
|
9
|
+
- `←`/`→` (or `[`/`]`) step the context through 0, 1, 3 (default), 5, 10, 25 lines and the whole file. `w` wraps long lines onto continuation rows instead of truncating them; with wrapping, `End` shows the last full page.
|
|
10
|
+
- Changed lines carry a colored `▌` rail and the theme's tool success/error background. For a replaced line pair, the changed words are shown in inverse video, computed by a bounded token LCS (lines that share no word, very long lines and coarse previews are not emphasized). Files whose language Pi recognizes are syntax-highlighted per line with the active theme; lines over 1,000 characters keep plain diff colours.
|
|
8
11
|
- Changes over 256 KiB or 1,500 combined lines initially show summary counts. Press `v` to explicitly review a bounded preview or return to summary. Previews are capped at 100,000 combined lines per file. When exact LCS diff exceeds its existing CPU/memory budget, the full-review choice uses a clearly marked coarse replacement hunk that preserves common prefix/suffix and line order.
|
|
9
12
|
- Safe revert tracking remains capped at 1 MiB per file and 8 MiB of retained baselines. Binary, oversized, or otherwise untracked files cannot be reverted from this UI. The summary does not imply content beyond those safety caps is reviewable.
|
|
10
13
|
|
|
11
|
-
The implementation uses the pi-tui custom component and windowed split-frame renderer already used by pi-jar. Upstream visual reference: [xRyul/pi-show-diffs README](https://github.com/xRyul/pi-show-diffs#diff-review-ux), MIT-licensed. No upstream source was copied.
|
|
14
|
+
The implementation uses the pi-tui custom component and windowed split-frame renderer already used by pi-jar. Upstream visual reference: [xRyul/pi-show-diffs README](https://github.com/xRyul/pi-show-diffs#diff-review-ux), MIT-licensed: aligned split rows, intraline highlights, rail markers, labeled collapsed regions, adjustable context and wrapping. No upstream source was copied, and pi-show-diffs' pre-apply approval and inline editing are not part of `/diff`, which reviews changes after they are applied.
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -120,7 +120,7 @@ Manual choices win: a model or effort you select yourself (model picker, cycling
|
|
|
120
120
|
## Change review
|
|
121
121
|
|
|
122
122
|
- A `tool_call` hook records a file's content right before the agent's **first** `edit` or `write` to it (project files only, up to 200 files, 1 MB each, text only). Later edits keep that baseline, so the review always shows everything since the agent started on the file.
|
|
123
|
-
- `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context. Files that are back to their baseline drop out.
|
|
123
|
+
- `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context by default (`←`/`→` change it, up to the whole file). Files that are back to their baseline drop out.
|
|
124
124
|
- **Accept** forgets the baseline. **Revert** writes the baseline back (or removes a file the agent created) after a second key press to confirm. Errors are reported and the file stays listed.
|
|
125
125
|
- Baselines live in memory for the session; changes made through `bash` or by you are not tracked.
|
|
126
126
|
- **Subagent edits** join the same review in two ways. Retained `worktree` writers remain isolated while working, idle or paused. Only `jar_subagent stop` finalizes them: pi-jar verifies that each parent target still equals the private baseline snapshot, copies reviewable safe results back, then captures/marks those parent files in the normal `ChangeTracker`. The deprecated one-shot `write: true` shared-workspace mode keeps its older `PI_JAR_BASELINE_DIR` bridge for compatibility.
|
|
@@ -146,7 +146,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
|
|
|
146
146
|
- The moderator never has to poll: when a turn started by `jar_subagent resume` ends, pi-jar coalesces the settled agents into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) that triggers or queues a moderator turn. `pause`, `ask` and `stop` cancel that wake-up because the moderator is handling the agent directly.
|
|
147
147
|
- Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
|
|
148
148
|
- Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
|
|
149
|
-
- `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write). `list` returns the newest
|
|
149
|
+
- `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
|
|
150
150
|
- Fork/worktree children inherit the parent model conversation but reset pi-jar goal/plan/todo automation. All child modes inherit explicit extension flags, set `PI_JAR_CHILD=1` to prevent recursive delegation, and share the discussion-paper path/name through environment variables.
|
|
151
151
|
- Progress (current tool, streaming text, tools used, turns, cost) stays in the activity view. Retained `idle` and `paused` agents remain visible in the footer/activity fleet; the newest eight ordinary retired reports remain for review, while unresolved private workspaces remain recoverable until explicit finalization/session cleanup. `s` in the activity view still steers a working subagent and `x` stops the selected agent through the same registry.
|
|
152
152
|
- The deprecated `write: true` mode remains a one-shot shared-workspace compatibility path. New delegated implementation should use `mode: "worktree"`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/changes.ts
CHANGED
|
@@ -15,7 +15,11 @@ const MAX_DIFF_MIDDLE_LINES = 4_000;
|
|
|
15
15
|
export type ChangeStatus = "added" | "modified" | "deleted";
|
|
16
16
|
export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
|
|
17
17
|
export type DiffOp = { op: " " | "+" | "-"; text: string };
|
|
18
|
-
|
|
18
|
+
/** `hidden` counts unchanged lines skipped before a hunk; `pair` is the aligned replacement line for intraline diffs. */
|
|
19
|
+
export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number; hidden?: number; pair?: string };
|
|
20
|
+
export type SplitSide = { line: number; text: string; changed: boolean };
|
|
21
|
+
/** One aligned Original/Updated row: a replacement run pairs its k-th removed and k-th added line. */
|
|
22
|
+
export type SplitRow = { kind: "hunk"; text: string; hidden: number } | { kind: "pair"; left?: SplitSide; right?: SplitSide };
|
|
19
23
|
|
|
20
24
|
/** Text content, `null` when the file is missing, or `undefined` when it cannot be tracked. */
|
|
21
25
|
function readText(path: string): string | null | undefined {
|
|
@@ -104,7 +108,8 @@ export function diffHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
|
|
|
104
108
|
while (to < ops.length) {
|
|
105
109
|
let next = to + 1;
|
|
106
110
|
while (next < ops.length && ops[next]!.op === " ") next++;
|
|
107
|
-
|
|
111
|
+
// Merge when the unchanged gap fits inside both hunks' context (adjacent changes at context 0).
|
|
112
|
+
if (next < ops.length && next - to - 1 <= context * 2) { to = next; continue; }
|
|
108
113
|
break;
|
|
109
114
|
}
|
|
110
115
|
const end = Math.min(ops.length, to + context + 1);
|
|
@@ -122,14 +127,7 @@ export function diffRowCount(ops: readonly DiffOp[], context = 3): number {
|
|
|
122
127
|
return hunkCache.get(ops)!.rows;
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
|
|
127
|
-
const rows: DiffRow[] = [];
|
|
128
|
-
const firstRow = Math.max(0, offset);
|
|
129
|
-
const lastRow = firstRow + Math.max(0, limit);
|
|
130
|
-
if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
|
|
131
|
-
const layout = hunkCache.get(ops)!;
|
|
132
|
-
if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
|
|
130
|
+
const linePositions = (ops: readonly DiffOp[]) => {
|
|
133
131
|
let positions = linePositionCache.get(ops);
|
|
134
132
|
if (!positions) {
|
|
135
133
|
const old = new Uint32Array(ops.length + 1), next = new Uint32Array(ops.length + 1);
|
|
@@ -145,6 +143,57 @@ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0,
|
|
|
145
143
|
positions = { old, next };
|
|
146
144
|
linePositionCache.set(ops, positions);
|
|
147
145
|
}
|
|
146
|
+
return positions;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Replacement runs (maximal non-context spans) as flat typed arrays, independent of context:
|
|
151
|
+
* `order[start..start+removes)` lists the run's removed ops and the rest its added ops, so the
|
|
152
|
+
* k-th removal pairs with the k-th addition. `split` prefixes aligned rows (a run takes max(r, a)).
|
|
153
|
+
*/
|
|
154
|
+
type RunLayout = { start: Uint32Array; removes: Uint32Array; length: Uint32Array; order: Uint32Array; ordinal: Uint32Array; split: Uint32Array };
|
|
155
|
+
const runCache = new WeakMap<readonly DiffOp[], RunLayout>();
|
|
156
|
+
|
|
157
|
+
const runLayout = (ops: readonly DiffOp[]): RunLayout => {
|
|
158
|
+
const cached = runCache.get(ops);
|
|
159
|
+
if (cached) return cached;
|
|
160
|
+
const n = ops.length;
|
|
161
|
+
const layout: RunLayout = { start: new Uint32Array(n), removes: new Uint32Array(n), length: new Uint32Array(n),
|
|
162
|
+
order: new Uint32Array(n), ordinal: new Uint32Array(n), split: new Uint32Array(n + 1) };
|
|
163
|
+
const { start, removes, length, order, ordinal, split } = layout;
|
|
164
|
+
for (let at = 0; at < n;) {
|
|
165
|
+
if (ops[at]!.op === " ") { order[at] = at; split[at + 1] = split[at]! + 1; at++; continue; }
|
|
166
|
+
let end = at;
|
|
167
|
+
while (end < n && ops[end]!.op !== " ") end++;
|
|
168
|
+
let r = 0, a = 0;
|
|
169
|
+
for (let i = at; i < end; i++) if (ops[i]!.op === "-") { ordinal[i] = r; order[at + r++] = i; }
|
|
170
|
+
for (let i = at; i < end; i++) if (ops[i]!.op === "+") { ordinal[i] = a; order[at + r + a++] = i; }
|
|
171
|
+
for (let i = at; i < end; i++) { start[i] = at; removes[i] = r; length[i] = end - at; split[i + 1] = split[at]!; }
|
|
172
|
+
split[at + 1] = split[at]! + Math.max(r, a);
|
|
173
|
+
for (let i = at + 1; i < end; i++) split[i + 1] = split[at + 1]!;
|
|
174
|
+
at = end;
|
|
175
|
+
}
|
|
176
|
+
runCache.set(ops, layout);
|
|
177
|
+
return layout;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The aligned counterpart of a changed op (k-th removal ↔ k-th addition in its run), if any. */
|
|
181
|
+
const partner = (ops: readonly DiffOp[], at: number): number | undefined => {
|
|
182
|
+
const { start, removes, length, order, ordinal } = runLayout(ops);
|
|
183
|
+
const s = start[at]!, r = removes[at]!, a = length[at]! - r, k = ordinal[at]!;
|
|
184
|
+
if (ops[at]!.op === "-") return k < a ? order[s + r + k] : undefined;
|
|
185
|
+
return k < r ? order[s + k] : undefined;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Compute only the requested output window; diff operations stay indexed without a second full row array. */
|
|
189
|
+
export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
|
|
190
|
+
const rows: DiffRow[] = [];
|
|
191
|
+
const firstRow = Math.max(0, offset);
|
|
192
|
+
const lastRow = firstRow + Math.max(0, limit);
|
|
193
|
+
if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
|
|
194
|
+
const layout = hunkCache.get(ops)!;
|
|
195
|
+
if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
|
|
196
|
+
const positions = linePositions(ops);
|
|
148
197
|
// Binary-search the first intersecting hunk, then visit only visible operations. In
|
|
149
198
|
// particular, a 100k-line replacement must not slice/filter/allocate 100k rows per frame.
|
|
150
199
|
let lo = 0, hi = layout.hunks.length;
|
|
@@ -157,13 +206,99 @@ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0,
|
|
|
157
206
|
for (let index = lo; index < layout.hunks.length; index++) {
|
|
158
207
|
const { from, end, offset: start } = layout.hunks[index]!;
|
|
159
208
|
if (start >= lastRow) break;
|
|
160
|
-
if (start >= firstRow) rows.push({ kind: "hunk", text:
|
|
209
|
+
if (start >= firstRow) rows.push({ kind: "hunk", text: hunkHeader(positions, from, end), hidden: from - (layout.hunks[index - 1]?.end ?? 0) });
|
|
161
210
|
const visibleFrom = from + Math.max(0, firstRow - start - 1);
|
|
162
211
|
const visibleEnd = Math.min(end, from + lastRow - start - 1);
|
|
163
212
|
for (let at = visibleFrom; at < visibleEnd; at++) {
|
|
164
213
|
const op = ops[at]!;
|
|
214
|
+
const pair = op.op === " " ? undefined : partner(ops, at);
|
|
165
215
|
rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
|
|
166
|
-
...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[at]! } : {})
|
|
216
|
+
...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[at]! } : {}),
|
|
217
|
+
...(pair !== undefined ? { pair: ops[pair]!.text } : {}) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Git convention: an empty side (pure insertion/deletion) names the line before the change. */
|
|
224
|
+
const hunkHeader = (positions: { old: Uint32Array; next: Uint32Array }, from: number, end: number) => {
|
|
225
|
+
const range = (lines: Uint32Array) => {
|
|
226
|
+
const count = lines[end]! - lines[from]!;
|
|
227
|
+
return `${count ? lines[from]! : lines[from]! - 1},${count}`;
|
|
228
|
+
};
|
|
229
|
+
return `@@ -${range(positions.old)} +${range(positions.next)} @@`;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
type SplitHunk = DiffHunk & { split: number };
|
|
233
|
+
const splitCache = new WeakMap<readonly DiffOp[], { context: number; hunks: SplitHunk[]; offsets: number[]; rows: number }>();
|
|
234
|
+
|
|
235
|
+
const splitLayout = (ops: readonly DiffOp[], context: number) => {
|
|
236
|
+
const cached = splitCache.get(ops);
|
|
237
|
+
if (cached?.context === context) return cached;
|
|
238
|
+
if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
|
|
239
|
+
const { split } = runLayout(ops);
|
|
240
|
+
const hunks: SplitHunk[] = [];
|
|
241
|
+
let rows = 0;
|
|
242
|
+
// Hunks start and end on context ops, so a replacement run never straddles two hunks.
|
|
243
|
+
for (const hunk of hunkCache.get(ops)!.hunks) {
|
|
244
|
+
hunks.push({ ...hunk, split: rows });
|
|
245
|
+
rows += 1 + split[hunk.end]! - split[hunk.from]!;
|
|
246
|
+
}
|
|
247
|
+
const layout = { context, hunks, offsets: hunks.map(hunk => hunk.split), rows };
|
|
248
|
+
splitCache.set(ops, layout);
|
|
249
|
+
return layout;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Aligned side-by-side row offsets of each hunk header. */
|
|
253
|
+
export function diffSplitHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
|
|
254
|
+
return splitLayout(ops, context).offsets.slice();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function diffSplitRowCount(ops: readonly DiffOp[], context = 3): number {
|
|
258
|
+
return splitLayout(ops, context).rows;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Window of aligned Original/Updated rows; like `diffRowsWindow`, it visits only visible operations. */
|
|
262
|
+
export function diffSplitRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): SplitRow[] {
|
|
263
|
+
const rows: SplitRow[] = [];
|
|
264
|
+
const firstRow = Math.max(0, offset);
|
|
265
|
+
const lastRow = firstRow + Math.max(0, limit);
|
|
266
|
+
const layout = splitLayout(ops, context);
|
|
267
|
+
if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
|
|
268
|
+
const positions = linePositions(ops);
|
|
269
|
+
const { split, order, removes, length } = runLayout(ops);
|
|
270
|
+
const size = (hunk: SplitHunk) => 1 + split[hunk.end]! - split[hunk.from]!;
|
|
271
|
+
let lo = 0, hi = layout.hunks.length;
|
|
272
|
+
while (lo < hi) {
|
|
273
|
+
const mid = (lo + hi) >>> 1;
|
|
274
|
+
if (layout.hunks[mid]!.split + size(layout.hunks[mid]!) <= firstRow) lo = mid + 1;
|
|
275
|
+
else hi = mid;
|
|
276
|
+
}
|
|
277
|
+
for (let index = lo; index < layout.hunks.length; index++) {
|
|
278
|
+
const hunk = layout.hunks[index]!;
|
|
279
|
+
if (hunk.split >= lastRow) break;
|
|
280
|
+
if (hunk.split >= firstRow) rows.push({ kind: "hunk", text: hunkHeader(positions, hunk.from, hunk.end), hidden: hunk.from - (layout.hunks[index - 1]?.end ?? 0) });
|
|
281
|
+
const visibleFrom = Math.max(0, firstRow - hunk.split - 1);
|
|
282
|
+
const visibleEnd = Math.min(size(hunk) - 1, lastRow - hunk.split - 1);
|
|
283
|
+
for (let row = visibleFrom; row < visibleEnd; row++) {
|
|
284
|
+
const target = split[hunk.from]! + row;
|
|
285
|
+
// First op whose aligned-row interval contains target; only run starts carry run rows.
|
|
286
|
+
let low = hunk.from, high = hunk.end - 1;
|
|
287
|
+
while (low < high) {
|
|
288
|
+
const mid = (low + high) >>> 1;
|
|
289
|
+
if (split[mid + 1]! > target) high = mid; else low = mid + 1;
|
|
290
|
+
}
|
|
291
|
+
const op = ops[low]!;
|
|
292
|
+
if (op.op === " ") {
|
|
293
|
+
rows.push({ kind: "pair", left: { line: positions.old[low]!, text: op.text, changed: false }, right: { line: positions.next[low]!, text: op.text, changed: false } });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const k = target - split[low]!, r = removes[low]!, a = length[low]! - r;
|
|
297
|
+
const removed = k < r ? order[low + k]! : undefined;
|
|
298
|
+
const added = k < a ? order[low + r + k]! : undefined;
|
|
299
|
+
rows.push({ kind: "pair",
|
|
300
|
+
...(removed !== undefined ? { left: { line: positions.old[removed]!, text: ops[removed]!.text, changed: true } } : {}),
|
|
301
|
+
...(added !== undefined ? { right: { line: positions.next[added]!, text: ops[added]!.text, changed: true } } : {}) });
|
|
167
302
|
}
|
|
168
303
|
}
|
|
169
304
|
return rows;
|
package/src/delegate-worktree.ts
CHANGED
|
@@ -154,6 +154,31 @@ const writeSnapshot = async (root: string, rel: string, bytes: Buffer, stat: Sta
|
|
|
154
154
|
}
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
+
// Each git call has its own GIT_TIMEOUT_MS deadline, so bound the work per batched call.
|
|
158
|
+
const HASH_BATCH_FILES = 1000;
|
|
159
|
+
const HASH_BATCH_BYTES = 128 * 1024 * 1024;
|
|
160
|
+
|
|
161
|
+
/** Object ids for regular files, in order, from bounded `hash-object --stdin-paths` chunks. */
|
|
162
|
+
const hashPaths = async (root: string, files: readonly { path: string; size: number }[], write: boolean,
|
|
163
|
+
signal?: AbortSignal): Promise<string[]> => {
|
|
164
|
+
const oids: string[] = [];
|
|
165
|
+
for (let start = 0; start < files.length;) {
|
|
166
|
+
let end = start;
|
|
167
|
+
let bytes = 0;
|
|
168
|
+
// A file larger than the byte budget still gets its own chunk, matching per-file hashing.
|
|
169
|
+
while (end < files.length && end - start < HASH_BATCH_FILES && (end === start || bytes + files[end]!.size <= HASH_BATCH_BYTES)) {
|
|
170
|
+
bytes += files[end++]!.size;
|
|
171
|
+
}
|
|
172
|
+
const chunk = files.slice(start, end);
|
|
173
|
+
const output = (await gitText(root, ["hash-object", ...(write ? ["-w"] : []), "--no-filters", "--stdin-paths"],
|
|
174
|
+
chunk.map(file => file.path).join("\n") + "\n", signal)).split("\n").filter(Boolean);
|
|
175
|
+
if (output.length !== chunk.length) throw new Error("git hash-object returned incomplete hashes");
|
|
176
|
+
oids.push(...output);
|
|
177
|
+
start = end;
|
|
178
|
+
}
|
|
179
|
+
return oids;
|
|
180
|
+
};
|
|
181
|
+
|
|
157
182
|
/**
|
|
158
183
|
* Create a detached disposable worktree and commit a private baseline snapshot containing the
|
|
159
184
|
* parent's tracked modifications plus non-ignored untracked files. The parent index/branch is not
|
|
@@ -173,6 +198,10 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
|
|
|
173
198
|
added = true;
|
|
174
199
|
await gitText(root, ["read-tree", "--empty"], undefined, signal);
|
|
175
200
|
const paths = new Set(nulList(await gitText(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal)));
|
|
201
|
+
// Git process startup (often behind wrappers such as git-ai) dominates setup time, so hash
|
|
202
|
+
// and stage in bounded batches rather than two git processes per repository file.
|
|
203
|
+
const entries: { rel: string; mode: string; oid: string }[] = [];
|
|
204
|
+
const batched: { entry: (typeof entries)[number]; path: string; size: number }[] = [];
|
|
176
205
|
for (const rel of paths) {
|
|
177
206
|
if (!safeFilePath(repoRoot, rel)) throw new Error("unsafe snapshot path: " + rel);
|
|
178
207
|
const source = resolve(repoRoot, rel);
|
|
@@ -180,11 +209,22 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
|
|
|
180
209
|
if (!stat) continue;
|
|
181
210
|
if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("unsupported snapshot file: " + rel);
|
|
182
211
|
const bytes = stat.isSymbolicLink() ? Buffer.from(await readlink(source)) : await readFile(source);
|
|
183
|
-
const oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
|
|
184
|
-
const mode = stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644";
|
|
185
|
-
await gitText(root, ["update-index", "--add", "--cacheinfo", mode, oid, rel], undefined, signal);
|
|
186
212
|
signal?.throwIfAborted();
|
|
187
213
|
await writeSnapshot(root, rel, bytes, stat);
|
|
214
|
+
const entry = { rel, mode: stat.isSymbolicLink() ? "120000" : (stat.mode & 0o111) ? "100755" : "100644", oid: "" };
|
|
215
|
+
entries.push(entry);
|
|
216
|
+
// The private copy holds exactly the bytes read above. Hashing it by path would follow a
|
|
217
|
+
// symlink, and --stdin-paths is line-based, so those entries hash their bytes directly.
|
|
218
|
+
const path = resolve(root, rel);
|
|
219
|
+
if (stat.isSymbolicLink() || path.includes("\n")) {
|
|
220
|
+
entry.oid = (await gitText(root, ["hash-object", "-w", "--no-filters", "--stdin"], bytes, signal)).trim();
|
|
221
|
+
} else batched.push({ entry, path, size: bytes.length });
|
|
222
|
+
}
|
|
223
|
+
const oids = await hashPaths(root, batched, true, signal);
|
|
224
|
+
batched.forEach((item, index) => { item.entry.oid = oids[index]!; });
|
|
225
|
+
if (entries.length) {
|
|
226
|
+
await gitText(root, ["update-index", "-z", "--index-info"],
|
|
227
|
+
entries.map(entry => `${entry.mode} ${entry.oid}\t${entry.rel}\0`).join(""), signal);
|
|
188
228
|
}
|
|
189
229
|
const tree = (await gitText(root, ["write-tree"], undefined, signal)).trim();
|
|
190
230
|
const baseline = (await gitText(root, ["-c", "user.name=pi-jar", "-c", "user.email=pi-jar@local",
|
|
@@ -205,12 +245,37 @@ export async function createDelegateWorktree(cwd: string, signal?: AbortSignal):
|
|
|
205
245
|
|
|
206
246
|
/** Files whose final worktree state differs from the snapshot, including new untracked files. */
|
|
207
247
|
export async function worktreeChangedFiles(worktree: DelegateWorktree, signal?: AbortSignal): Promise<string[]> {
|
|
208
|
-
const
|
|
248
|
+
const baseline = new Map<string, { mode: string; oid: string }>();
|
|
249
|
+
for (const record of nulList(await gitText(worktree.root, ["ls-tree", "-r", "-z", worktree.baseline], undefined, signal))) {
|
|
250
|
+
const tab = record.indexOf("\t");
|
|
251
|
+
const [mode, type, oid] = record.slice(0, tab).split(" ");
|
|
252
|
+
if (type !== "blob" || !mode || !oid) throw new Error("unsupported baseline entry: " + record.slice(tab + 1));
|
|
253
|
+
baseline.set(record.slice(tab + 1), { mode, oid });
|
|
254
|
+
}
|
|
209
255
|
const currentPaths = nulList(await gitText(worktree.root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], undefined, signal));
|
|
210
256
|
const changed: string[] = [];
|
|
211
|
-
|
|
212
|
-
|
|
257
|
+
// Compare object ids from bounded hash-object batches instead of two git processes per file.
|
|
258
|
+
const batched: { rel: string; oid: string; path: string; size: number }[] = [];
|
|
259
|
+
const hashBytes = async (bytes: Buffer) => (await gitText(worktree.root, ["hash-object", "--no-filters", "--stdin"], bytes, signal)).trim();
|
|
260
|
+
for (const rel of new Set([...baseline.keys(), ...currentPaths])) {
|
|
261
|
+
const entry = baseline.get(rel);
|
|
262
|
+
const target = resolve(worktree.root, rel);
|
|
263
|
+
if (!safeFilePath(worktree.root, rel)) { changed.push(rel); continue; }
|
|
264
|
+
const stat = statIfPresent(target);
|
|
265
|
+
if (!entry || !stat) {
|
|
266
|
+
if (entry || stat) changed.push(rel);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (entry.mode === "120000") {
|
|
270
|
+
if (!stat.isSymbolicLink() || await hashBytes(Buffer.from(await readlink(target))) !== entry.oid) changed.push(rel);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!stat.isFile() || (entry.mode === "100755") !== ((stat.mode & 0o111) !== 0)) changed.push(rel);
|
|
274
|
+
else if (target.includes("\n")) { if (await hashBytes(await readFile(target)) !== entry.oid) changed.push(rel); }
|
|
275
|
+
else batched.push({ rel, oid: entry.oid, path: target, size: stat.size });
|
|
213
276
|
}
|
|
277
|
+
const oids = await hashPaths(worktree.root, batched, false, signal);
|
|
278
|
+
batched.forEach((item, index) => { if (oids[index] !== item.oid) changed.push(item.rel); });
|
|
214
279
|
return changed.sort();
|
|
215
280
|
}
|
|
216
281
|
|
|
@@ -224,9 +289,6 @@ const baselineEntry = async (worktree: DelegateWorktree, rel: string, signal?: A
|
|
|
224
289
|
return { mode, content: await gitBuffer(worktree.root, ["cat-file", "blob", oid], signal) };
|
|
225
290
|
};
|
|
226
291
|
|
|
227
|
-
const parentMatchesBaseline = async (worktree: DelegateWorktree, parentRoot: string, rel: string, signal?: AbortSignal): Promise<boolean> =>
|
|
228
|
-
matchesBaseline(await baselineEntry(worktree, rel, signal), parentRoot, rel);
|
|
229
|
-
|
|
230
292
|
const matchesBaseline = (baseline: BaselineEntry, parentRoot: string, rel: string): boolean => {
|
|
231
293
|
const target = resolve(parentRoot, rel);
|
|
232
294
|
if (!safeFilePath(parentRoot, rel)) return false;
|
package/src/delegate.ts
CHANGED
|
@@ -976,9 +976,15 @@ const remainingTasks = (run: DelegateRun): string[] => {
|
|
|
976
976
|
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
977
977
|
};
|
|
978
978
|
|
|
979
|
+
const taskLabel = (task: string): string => {
|
|
980
|
+
const text = cleanText(task, 4096);
|
|
981
|
+
return text.length > 160 ? text.slice(0, 159) + "…" : text;
|
|
982
|
+
};
|
|
983
|
+
|
|
979
984
|
const controlReport = (report: SubagentStopReport): string => [
|
|
980
985
|
`${report.key} · ${report.name} · ${report.state}`,
|
|
981
|
-
|
|
986
|
+
// The moderator already holds the full task in its own jar_delegate call; echo only a label.
|
|
987
|
+
`task: ${taskLabel(report.task)}`,
|
|
982
988
|
`workspace: ${report.workspace ?? "none"}`,
|
|
983
989
|
`changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
|
|
984
990
|
`applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Word-level changes inside one replaced line, for intraline emphasis in /diff. */
|
|
2
|
+
|
|
3
|
+
export type Span = readonly [start: number, end: number];
|
|
4
|
+
|
|
5
|
+
/** Token LCS stays tiny: long or wholly rewritten lines are shown without intraline emphasis. */
|
|
6
|
+
const MAX_INLINE_CELLS = 40_000;
|
|
7
|
+
const TOKEN = /\w+|\s+|[^\w\s]/gu;
|
|
8
|
+
|
|
9
|
+
const tokens = (text: string) => Array.from(text.matchAll(TOKEN), match => ({ text: match[0], at: match.index! }));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Changed `[start, end)` code-unit spans of `before` and `after`. Undefined when the lines are equal,
|
|
13
|
+
* too long to compare cheaply, or share no non-whitespace token (emphasizing everything adds nothing).
|
|
14
|
+
*/
|
|
15
|
+
export function inlineChanges(before: string, after: string): { before: Span[]; after: Span[] } | undefined {
|
|
16
|
+
if (before === after) return undefined;
|
|
17
|
+
const a = tokens(before), b = tokens(after);
|
|
18
|
+
let start = 0;
|
|
19
|
+
while (start < a.length && start < b.length && a[start]!.text === b[start]!.text) start++;
|
|
20
|
+
let endA = a.length, endB = b.length;
|
|
21
|
+
while (endA > start && endB > start && a[endA - 1]!.text === b[endB - 1]!.text) { endA--; endB--; }
|
|
22
|
+
const n = endA - start, m = endB - start;
|
|
23
|
+
if (n * m > MAX_INLINE_CELLS) return undefined;
|
|
24
|
+
// lcs[i][j] = LCS length of a[start+i..endA) and b[start+j..endB), flattened.
|
|
25
|
+
const lcs = new Uint16Array((n + 1) * (m + 1));
|
|
26
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
lcs[i * (m + 1) + j] = a[start + i]!.text === b[start + j]!.text ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
|
|
28
|
+
: Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
|
|
29
|
+
}
|
|
30
|
+
const changedA = new Uint8Array(n), changedB = new Uint8Array(m);
|
|
31
|
+
let common = /\S/.test(a.slice(0, start).map(token => token.text).join("") + a.slice(endA).map(token => token.text).join(""));
|
|
32
|
+
for (let i = 0, j = 0; i < n || j < m;) {
|
|
33
|
+
if (i < n && j < m && a[start + i]!.text === b[start + j]!.text) {
|
|
34
|
+
if (/\S/.test(a[start + i]!.text)) common = true;
|
|
35
|
+
i++; j++;
|
|
36
|
+
} else if (j < m && (i >= n || lcs[i * (m + 1) + j + 1]! >= lcs[(i + 1) * (m + 1) + j]!)) changedB[j++] = 1;
|
|
37
|
+
else changedA[i++] = 1;
|
|
38
|
+
}
|
|
39
|
+
if (!common) return undefined;
|
|
40
|
+
return { before: spans(a, start, changedA), after: spans(b, start, changedB) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Merge consecutive changed tokens into spans; whitespace between two changes joins them. */
|
|
44
|
+
function spans(list: readonly { text: string; at: number }[], offset: number, changed: Uint8Array): Span[] {
|
|
45
|
+
const result: [number, number][] = [];
|
|
46
|
+
let previous = -1;
|
|
47
|
+
for (let index = 0; index < changed.length; index++) {
|
|
48
|
+
if (!changed[index]) continue;
|
|
49
|
+
const token = list[offset + index]!;
|
|
50
|
+
let joined = previous >= 0;
|
|
51
|
+
for (let gap = previous + 1; joined && gap < index; gap++) if (/\S/.test(list[offset + gap]!.text)) joined = false;
|
|
52
|
+
if (joined) result.at(-1)![1] = token.at + token.text.length;
|
|
53
|
+
else result.push([token.at, token.at + token.text.length]);
|
|
54
|
+
previous = index;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SGR = /\x1b\[[0-?]*[ -/]*[@-~]/y;
|
|
60
|
+
const INVERSE_ON = "\x1b[7m";
|
|
61
|
+
const INVERSE_OFF = "\x1b[27m";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Insert inverse-video markers around `spans` of the visible text inside an SGR-styled string.
|
|
65
|
+
* Offsets count visible code units, so styling (syntax colours) never shifts the emphasis.
|
|
66
|
+
*/
|
|
67
|
+
export function emphasize(styled: string, spans: readonly Span[]): string {
|
|
68
|
+
if (!spans.length) return styled;
|
|
69
|
+
let out = "", visible = 0, span = 0, open = false;
|
|
70
|
+
for (let at = 0; at < styled.length;) {
|
|
71
|
+
SGR.lastIndex = at;
|
|
72
|
+
const escape = SGR.exec(styled);
|
|
73
|
+
if (escape) { out += escape[0]; at += escape[0].length; continue; }
|
|
74
|
+
while (span < spans.length && open && visible >= spans[span]![1]) { out += INVERSE_OFF; open = false; span++; }
|
|
75
|
+
if (span < spans.length && !open && visible >= spans[span]![0]) { out += INVERSE_ON; open = true; }
|
|
76
|
+
out += styled[at++];
|
|
77
|
+
visible++;
|
|
78
|
+
}
|
|
79
|
+
return open ? out + INVERSE_OFF : out;
|
|
80
|
+
}
|
package/src/diff-view.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow,
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, diffSplitHunkOffsets, diffSplitRowCount, diffSplitRowsWindow,
|
|
4
|
+
lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type FileChange } from "./changes.ts";
|
|
5
|
+
import { emphasize, inlineChanges } from "./diff-inline.ts";
|
|
4
6
|
import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
|
|
5
7
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
8
|
|
|
@@ -48,51 +50,118 @@ export function fullReviewSupported(change: FileChange): boolean {
|
|
|
48
50
|
export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, " ")
|
|
49
51
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, "·");
|
|
50
52
|
|
|
53
|
+
/** Optional styling hooks; tests and plain output omit them. */
|
|
54
|
+
export interface DiffPaint {
|
|
55
|
+
/** Syntax colours (foreground SGR only) for one display line, or undefined to use diff colours. */
|
|
56
|
+
syntax?: (text: string) => string | undefined;
|
|
57
|
+
/** SGR background sequence that tints a changed line's text. */
|
|
58
|
+
tint?: (kind: "add" | "remove") => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Context sizes cycled by ←/→ (and [/]); Infinity shows whole files. */
|
|
62
|
+
export const CONTEXT_STEPS = [0, 1, 3, 5, 10, 25, Infinity] as const;
|
|
63
|
+
/** Split columns narrower than this fall back to unified rows. */
|
|
64
|
+
export const MIN_SPLIT_WIDTH = 70;
|
|
65
|
+
|
|
66
|
+
export interface DiffRenderOptions {
|
|
67
|
+
fullReview?: boolean; scroll?: number; rows?: number; unified?: boolean; ops?: readonly DiffOp[]; coarse?: boolean;
|
|
68
|
+
context?: number; wrap?: boolean; paint?: DiffPaint;
|
|
69
|
+
}
|
|
70
|
+
/** Rendered page plus how many logical rows it consumed (wrapping can show fewer than `rows`). */
|
|
71
|
+
export interface DiffWindow { lines: string[]; shown: number; total: number }
|
|
72
|
+
|
|
73
|
+
type Kind = "add" | "remove" | "context";
|
|
74
|
+
const RESET = "\x1b[0m";
|
|
75
|
+
const KIND_COLOR = { add: "success", remove: "error", context: "muted" } as const;
|
|
76
|
+
const SIGN = { add: "+", remove: "-", context: " " } as const;
|
|
77
|
+
|
|
51
78
|
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
52
|
-
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string,
|
|
53
|
-
|
|
79
|
+
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): string[] {
|
|
80
|
+
return renderDiffWindow(change, width, fg, options).lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderDiffWindow(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): DiffWindow {
|
|
54
84
|
const large = fullReviewRequired(change);
|
|
55
|
-
if (large && (!options.fullReview || !fullReviewSupported(change))) return [
|
|
85
|
+
if (large && (!options.fullReview || !fullReviewSupported(change))) return { shown: 0, total: 0, lines: [
|
|
56
86
|
fg("warning", `Summary preview · +${change.added} −${change.removed} · ${(Buffer.byteLength(change.before) + Buffer.byteLength(change.after)) / 1024 | 0} KiB`),
|
|
57
87
|
fg("dim", fullReviewSupported(change) ? "Press v for a windowed full review, or keep this summary." : "Full review unavailable: preview is bounded to 100,000 combined lines."),
|
|
58
88
|
fg("dim", "Older/binary/over-1 MiB files are not tracked for safe revert.")
|
|
59
|
-
];
|
|
89
|
+
] };
|
|
60
90
|
const exact = options.ops ? change.diff : (change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after)));
|
|
61
91
|
const ops = options.ops ?? exact ?? coarseLineDiff(change.before, change.after);
|
|
62
|
-
const rows = Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW);
|
|
92
|
+
const rows = Math.max(1, Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW));
|
|
63
93
|
const scroll = Math.max(0, options.scroll ?? 0);
|
|
64
|
-
const
|
|
65
|
-
|
|
94
|
+
const context = options.context ?? 3;
|
|
95
|
+
const unified = options.unified !== false;
|
|
96
|
+
const wrap = options.wrap === true;
|
|
97
|
+
const paint = options.paint ?? {};
|
|
98
|
+
const total = unified ? diffRowCount(ops, context) : diffSplitRowCount(ops, context);
|
|
99
|
+
const unifiedRows = unified ? diffRowsWindow(ops, context, scroll, rows) : [];
|
|
100
|
+
const splitRows = unified ? [] : diffSplitRowsWindow(ops, context, scroll, rows);
|
|
101
|
+
if (!unifiedRows.length && !splitRows.length) return { lines: [fg("dim", scroll ? "End of diff." : "No textual changes.")], shown: 0, total };
|
|
66
102
|
const bounded = options.coarse ?? (change.diff === null || (!options.ops && exact === undefined));
|
|
67
|
-
|
|
103
|
+
let widest = 0;
|
|
104
|
+
for (const row of unifiedRows) widest = Math.max(widest, row.oldLine ?? 0, row.newLine ?? 0);
|
|
105
|
+
for (const row of splitRows) if (row.kind === "pair") widest = Math.max(widest, row.left?.line ?? 0, row.right?.line ?? 0);
|
|
106
|
+
const gutter = Math.max(3, String(widest).length);
|
|
68
107
|
const num = (value?: number) => (value ? String(value) : "").padStart(gutter);
|
|
69
|
-
|
|
108
|
+
|
|
109
|
+
// Coarse replacement groups pair unrelated lines, so intraline emphasis would mislead there.
|
|
110
|
+
const style = (text: string, kind: Kind, pair: string | undefined) => {
|
|
111
|
+
const display = safeLine(text);
|
|
112
|
+
const styled = paint.syntax?.(display) ?? fg(KIND_COLOR[kind], display);
|
|
113
|
+
if (bounded || kind === "context" || pair === undefined) return styled;
|
|
114
|
+
const changes = kind === "remove" ? inlineChanges(display, safeLine(pair)) : inlineChanges(safeLine(pair), display);
|
|
115
|
+
return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after) : styled;
|
|
116
|
+
};
|
|
117
|
+
// Rail, numbers and sign, then text truncated or wrapped into the cell; continuation lines keep
|
|
118
|
+
// the rail only. Truncation emits full resets, so a tint is re-applied after each one.
|
|
119
|
+
const cell = (kind: Kind, lead: string, text: string, width: number, pair?: string): string[] => {
|
|
120
|
+
const leadWidth = visibleWidth(lead);
|
|
121
|
+
const textWidth = Math.max(1, width - leadWidth - 2);
|
|
122
|
+
const rail = kind === "context" ? " " : fg(KIND_COLOR[kind], "▌");
|
|
123
|
+
const tint = kind === "context" ? undefined : paint.tint?.(kind);
|
|
124
|
+
const styled = style(text, kind, pair);
|
|
125
|
+
return (wrap ? wrapTextWithAnsi(styled, textWidth) : [truncateToWidth(styled, textWidth, "…")]).map((body, index) => {
|
|
126
|
+
const prefix = index ? " ".repeat(leadWidth + 1) : fg("dim", lead) + fg(KIND_COLOR[kind], SIGN[kind]);
|
|
127
|
+
const content = body + " ".repeat(Math.max(0, textWidth - visibleWidth(body)));
|
|
128
|
+
return rail + prefix + (tint ? tint + content.replaceAll(RESET, RESET + tint) : content) + RESET;
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const hunkLine = (text: string, hidden: number | undefined) => truncateToWidth(
|
|
132
|
+
(hidden ? fg("dim", `┄ ${hidden} unchanged line${hidden === 1 ? "" : "s"} ┄ `) : "") + fg("accent", text), width, "…");
|
|
133
|
+
|
|
134
|
+
const blocks: string[][] = unified
|
|
135
|
+
? unifiedRows.map(row => row.kind === "hunk" ? [hunkLine(row.text, row.hidden)]
|
|
136
|
+
: cell(row.kind === "add" ? "add" : row.kind === "remove" ? "remove" : "context", `${num(row.oldLine)} ${num(row.newLine)} `, row.text, width, row.pair))
|
|
137
|
+
: splitRows.map(row => {
|
|
138
|
+
if (row.kind === "hunk") return [hunkLine(row.text, row.hidden)];
|
|
139
|
+
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
140
|
+
const replaced = row.left?.changed && row.right?.changed;
|
|
141
|
+
const left = row.left ? cell(row.left.changed ? "remove" : "context", num(row.left.line) + " ", row.left.text, column, replaced ? row.right!.text : undefined) : [];
|
|
142
|
+
const right = row.right ? cell(row.right.changed ? "add" : "context", num(row.right.line) + " ", row.right.text, column, replaced ? row.left!.text : undefined) : [];
|
|
143
|
+
return Array.from({ length: Math.max(left.length, right.length) }, (_, index) =>
|
|
144
|
+
(left[index] ?? " ".repeat(column)) + fg("dim", " │ ") + (right[index] ?? ""));
|
|
145
|
+
});
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
if (bounded) lines.push(fg("warning", unified ? "Coarse preview: LCS budget exceeded; full review preserves file order but replacement groups are approximate."
|
|
148
|
+
: "Coarse preview: replacement groups are approximate."));
|
|
149
|
+
if (!unified) {
|
|
70
150
|
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
71
|
-
|
|
72
|
-
truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"),
|
|
73
|
-
...(bounded ? [fg("warning", "Coarse preview: replacement groups are approximate.")] : []),
|
|
74
|
-
...visible.map(row => {
|
|
75
|
-
if (row.kind === "hunk") return fg("accent", row.text);
|
|
76
|
-
const oldText = row.kind === "add" ? "" : `${num(row.oldLine)} ${row.kind === "remove" ? "-" : " "}${safeLine(row.text)}`;
|
|
77
|
-
const newText = row.kind === "remove" ? "" : `${num(row.newLine)} ${row.kind === "add" ? "+" : " "}${safeLine(row.text)}`;
|
|
78
|
-
const left = truncateToWidth(oldText, column, "…", true);
|
|
79
|
-
const right = truncateToWidth(newText, column, "…", true);
|
|
80
|
-
return (row.kind === "remove" ? fg("error", left) : fg("muted", left)) + fg("dim", " │ ")
|
|
81
|
-
+ (row.kind === "add" ? fg("success", right) : fg("muted", right));
|
|
82
|
-
})
|
|
83
|
-
];
|
|
151
|
+
lines.push(truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"));
|
|
84
152
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
// Fill the viewport with whole logical rows; a first row taller than the viewport is clipped.
|
|
154
|
+
let used = 0, shown = 0;
|
|
155
|
+
for (const block of blocks) {
|
|
156
|
+
if (shown && used + block.length > rows) break;
|
|
157
|
+
const visible = block.slice(0, rows - used);
|
|
158
|
+
lines.push(...visible);
|
|
159
|
+
used += visible.length;
|
|
160
|
+
shown++;
|
|
161
|
+
if (used >= rows) break;
|
|
162
|
+
}
|
|
163
|
+
if (scroll > 0 || scroll + shown < total) lines.push(fg("dim", `Windowed preview · rows ${scroll + 1}–${scroll + shown} of ${total} · PgUp/PgDn`));
|
|
164
|
+
return { lines, shown, total };
|
|
96
165
|
}
|
|
97
166
|
|
|
98
167
|
/**
|
|
@@ -111,15 +180,54 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
111
180
|
let searchMode = false;
|
|
112
181
|
let fullReview = false;
|
|
113
182
|
let unified = true;
|
|
183
|
+
let contextIndex = CONTEXT_STEPS.indexOf(3);
|
|
184
|
+
let wrap = false;
|
|
114
185
|
let bodyRows = 0;
|
|
186
|
+
let bodyWidth = 80;
|
|
115
187
|
let viewportRows = 1;
|
|
116
188
|
let width = 80;
|
|
117
189
|
let armed: "one" | "all" | undefined;
|
|
118
190
|
let message = "";
|
|
119
191
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
120
|
-
let cache: { key: string; lines: string[] } | undefined;
|
|
121
|
-
let opCache: { key: string; ops: readonly DiffOp[];
|
|
192
|
+
let cache: { key: string; lines: string[]; shown: number } | undefined;
|
|
193
|
+
let opCache: { key: string; ops: readonly DiffOp[]; coarse: boolean } | undefined;
|
|
194
|
+
let paintCache: { path: string; paint: DiffPaint } | undefined;
|
|
122
195
|
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
196
|
+
const context = () => CONTEXT_STEPS[contextIndex]!;
|
|
197
|
+
const contextLabel = () => Number.isFinite(context()) ? `${context()} line${context() === 1 ? "" : "s"}` : "whole file";
|
|
198
|
+
// Split columns need room; narrower panes keep the unified layout instead of unreadable halves.
|
|
199
|
+
const splitMode = () => !unified && bodyWidth >= MIN_SPLIT_WIDTH;
|
|
200
|
+
const rowLayout = () => {
|
|
201
|
+
if (!opCache) return { hunks: [] as number[], rows: 0 };
|
|
202
|
+
return splitMode()
|
|
203
|
+
? { hunks: diffSplitHunkOffsets(opCache.ops, context()), rows: diffSplitRowCount(opCache.ops, context()) }
|
|
204
|
+
: { hunks: diffHunkOffsets(opCache.ops, context()), rows: diffRowCount(opCache.ops, context()) };
|
|
205
|
+
};
|
|
206
|
+
// Row offsets change with context and split mode; keep the hunk at the top of the viewport.
|
|
207
|
+
const relayout = (change: () => void) => {
|
|
208
|
+
const before = rowLayout().hunks;
|
|
209
|
+
let hunk = -1;
|
|
210
|
+
for (let index = 0; index < before.length && before[index]! <= scroll; index++) hunk = index;
|
|
211
|
+
change();
|
|
212
|
+
scroll = hunk >= 0 ? rowLayout().hunks[hunk] ?? 0 : 0;
|
|
213
|
+
cache = undefined;
|
|
214
|
+
};
|
|
215
|
+
const paintFor = (change: FileChange): DiffPaint => {
|
|
216
|
+
if (paintCache?.path !== change.path) {
|
|
217
|
+
const language = getLanguageFromPath(change.path);
|
|
218
|
+
const bg = (theme as { getBgAnsi?: (color: "toolSuccessBg" | "toolErrorBg") => string }).getBgAnsi?.bind(theme);
|
|
219
|
+
paintCache = { path: change.path, paint: {
|
|
220
|
+
// Per-line highlighting is bounded; very long lines, or a host without Pi's highlighter
|
|
221
|
+
// theme initialised, keep plain diff colours instead of failing the review.
|
|
222
|
+
...(language ? { syntax: (text: string) => {
|
|
223
|
+
if (text.length > 1000) return undefined;
|
|
224
|
+
try { return highlightCode(text, language)[0]; } catch { return undefined; }
|
|
225
|
+
} } : {}),
|
|
226
|
+
...(bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {})
|
|
227
|
+
} };
|
|
228
|
+
}
|
|
229
|
+
return paintCache.paint;
|
|
230
|
+
};
|
|
123
231
|
const refresh = () => {
|
|
124
232
|
changes = tracker.changes();
|
|
125
233
|
cache = undefined;
|
|
@@ -136,10 +244,24 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
136
244
|
const visibleChanges = () => filterChanges(changes, query).files;
|
|
137
245
|
const currentChange = () => visibleChanges()[selected];
|
|
138
246
|
const select = (index: number) => { selected = Math.max(0, Math.min(visibleChanges().length - 1, index)); scroll = 0; fullReview = false; cache = undefined; opCache = undefined; };
|
|
247
|
+
const windowAt = (change: FileChange, at: number) => renderDiffWindow(change, bodyWidth, fg, { fullReview, scroll: at, rows: viewportRows,
|
|
248
|
+
ops: opCache?.ops, coarse: opCache?.coarse, unified: !splitMode(), context: context(), wrap, paint: paintFor(change) });
|
|
249
|
+
// Wrapped rows vary in height: the last page starts at the first row whose tail fits the
|
|
250
|
+
// viewport. Measured only when scrolling past the cheap bound, at most one viewport of renders.
|
|
251
|
+
const lastScroll = () => {
|
|
252
|
+
const total = rowLayout().rows;
|
|
253
|
+
let at = Math.max(0, total - viewportRows);
|
|
254
|
+
const current = currentChange();
|
|
255
|
+
if (!wrap || !current) return at;
|
|
256
|
+
while (at < total - 1 && at + windowAt(current, at).shown < total) at++;
|
|
257
|
+
return at;
|
|
258
|
+
};
|
|
139
259
|
const scrollBody = (delta: number) => {
|
|
140
|
-
const
|
|
141
|
-
|
|
260
|
+
const target = scroll + delta;
|
|
261
|
+
const cheap = Math.max(0, rowLayout().rows - viewportRows);
|
|
262
|
+
scroll = Math.max(0, target <= cheap ? target : Math.min(target, lastScroll())); cache = undefined;
|
|
142
263
|
};
|
|
264
|
+
const page = () => Math.max(1, (wrap ? cache?.shown ?? 1 : viewportRows) - 1);
|
|
143
265
|
const component = {
|
|
144
266
|
invalidate() { cache = undefined; },
|
|
145
267
|
handleInput(data: string) {
|
|
@@ -168,7 +290,17 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
168
290
|
if (data === "/") { searchMode = true; query = ""; }
|
|
169
291
|
else if (data === "n" && query) select(selected + 1);
|
|
170
292
|
else if (data === "N" && query) select(selected - 1);
|
|
171
|
-
else if (data === "t") {
|
|
293
|
+
else if (data === "t") {
|
|
294
|
+
relayout(() => { unified = !unified; });
|
|
295
|
+
message = unified ? "Unified diff" : bodyWidth >= MIN_SPLIT_WIDTH ? "Side-by-side diff" : `Side-by-side needs ${MIN_SPLIT_WIDTH} columns; showing unified`;
|
|
296
|
+
}
|
|
297
|
+
else if (matchesKey(data, Key.left) || data === "[") {
|
|
298
|
+
relayout(() => { contextIndex = Math.max(0, contextIndex - 1); }); message = `Context: ${contextLabel()}`;
|
|
299
|
+
}
|
|
300
|
+
else if (matchesKey(data, Key.right) || data === "]") {
|
|
301
|
+
relayout(() => { contextIndex = Math.min(CONTEXT_STEPS.length - 1, contextIndex + 1); }); message = `Context: ${contextLabel()}`;
|
|
302
|
+
}
|
|
303
|
+
else if (data === "w") { wrap = !wrap; cache = undefined; message = wrap ? "Wrapping long lines" : "Truncating long lines"; }
|
|
172
304
|
else if (data === "v" && current && fullReviewRequired(current) && fullReviewSupported(current)) {
|
|
173
305
|
fullReview = !fullReview; scroll = 0; cache = undefined; opCache = undefined; message = fullReview ? "Full windowed review · LCS budget may label a coarse replacement" : "Summary preview";
|
|
174
306
|
}
|
|
@@ -178,16 +310,18 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
178
310
|
else if (data === "R" && current) { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
179
311
|
else if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
|
|
180
312
|
else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
|
|
181
|
-
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(
|
|
182
|
-
else if (matchesKey(data, Key.pageUp)) scrollBody(-
|
|
183
|
-
else if (data === "n" && !query &&
|
|
184
|
-
|
|
313
|
+
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(page());
|
|
314
|
+
else if (matchesKey(data, Key.pageUp)) scrollBody(-page());
|
|
315
|
+
else if (data === "n" && !query && rowLayout().hunks.length) {
|
|
316
|
+
const hunks = rowLayout().hunks;
|
|
317
|
+
scroll = hunks.find(offset => offset > scroll) ?? hunks[0]!; cache = undefined;
|
|
185
318
|
}
|
|
186
|
-
else if (data === "p" &&
|
|
187
|
-
|
|
319
|
+
else if (data === "p" && rowLayout().hunks.length) {
|
|
320
|
+
const hunks = rowLayout().hunks;
|
|
321
|
+
scroll = [...hunks].reverse().find(offset => offset < scroll) ?? hunks.at(-1)!; cache = undefined;
|
|
188
322
|
}
|
|
189
|
-
else if (data === "g") scrollBody(-Infinity);
|
|
190
|
-
else if (data === "G") scrollBody(Infinity);
|
|
323
|
+
else if (data === "g" || matchesKey(data, Key.home)) scrollBody(-Infinity);
|
|
324
|
+
else if (data === "G" || matchesKey(data, Key.end)) scrollBody(Infinity);
|
|
191
325
|
tui.requestRender();
|
|
192
326
|
},
|
|
193
327
|
handleMouse(event: TuiMouseEvent) {
|
|
@@ -208,23 +342,24 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
208
342
|
width = Math.max(24, available);
|
|
209
343
|
bodyRows = contentRows(7 + ACTIONS.length, 6);
|
|
210
344
|
const listWidth = sidebarWidth(width, 20, 36);
|
|
211
|
-
|
|
345
|
+
bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
212
346
|
const filtered = visibleChanges();
|
|
213
347
|
const current = filtered[selected];
|
|
214
348
|
if (!current) return [fg("dim", `No files match /${safeLine(query)}`), fg("dim", "Press / to edit the filter or Esc to clear it.")];
|
|
215
349
|
const opKey = `${current.path}:${current.before.length}:${current.after.length}`;
|
|
216
350
|
if (opCache?.key !== opKey && (!fullReviewRequired(current) || fullReview)) {
|
|
217
351
|
const exact = current.diff === null ? undefined : (current.diff ?? lineDiff(current.before, current.after));
|
|
218
|
-
|
|
219
|
-
const ops = exact ?? coarseLineDiff(current.before, current.after);
|
|
220
|
-
opCache = { key: opKey, ops, hunks: diffHunkOffsets(ops), rows: diffRowCount(ops), coarse };
|
|
352
|
+
opCache = { key: opKey, ops: exact ?? coarseLineDiff(current.before, current.after), coarse: exact === undefined };
|
|
221
353
|
}
|
|
222
354
|
const pathLines = wrapPath(current.path.replace(/[\x00-\x1f\x7f-\x9f]/g, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`), bodyWidth);
|
|
223
355
|
// Reserve wrapped path, metadata, split/coarse headers and the optional window footer.
|
|
224
|
-
viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (
|
|
225
|
-
scroll = Math.min(scroll, Math.max(0, (
|
|
226
|
-
const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${
|
|
227
|
-
if (cache?.key !== key)
|
|
356
|
+
viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (splitMode() ? 1 : 0) - (opCache?.coarse ? 1 : 0) - 1);
|
|
357
|
+
scroll = Math.min(scroll, Math.max(0, rowLayout().rows - (wrap ? 1 : viewportRows)));
|
|
358
|
+
const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${splitMode()}:${fullReview}:${scroll}:${contextIndex}:${wrap}`;
|
|
359
|
+
if (cache?.key !== key) {
|
|
360
|
+
const page = windowAt(current, scroll);
|
|
361
|
+
cache = { key, lines: page.lines, shown: page.shown };
|
|
362
|
+
}
|
|
228
363
|
// The absolute path is intentionally the first row of the right pane, even in split mode.
|
|
229
364
|
const content = [...pathLines.map(path => fg("accent", path)),
|
|
230
365
|
fg("dim", `${selected + 1}/${filtered.length} · ${current.status} · +${current.added} −${current.removed}`), ...cache.lines];
|
|
@@ -240,8 +375,10 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
240
375
|
const title = `± CHANGES · ${filtered.length}/${changes.length} files · +${totals[0]} −${totals[1]}`;
|
|
241
376
|
const footer = [
|
|
242
377
|
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
243
|
-
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
|
|
244
|
-
|
|
378
|
+
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
|
|
379
|
+
: query ? `/${query} · n/N next match · / new search`
|
|
380
|
+
: `${SEARCH_HINT} · n/p hunk · t split · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
381
|
+
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn/Home/End scroll · a accept · r revert · Esc close")
|
|
245
382
|
];
|
|
246
383
|
const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
|
|
247
384
|
layout = split.layout;
|
package/src/discussion.ts
CHANGED
|
@@ -88,9 +88,10 @@ async function withPaperLock<T>(file: string, run: () => T): Promise<T> {
|
|
|
88
88
|
throw new Error("discussion paper is busy");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** Append under the lock; `accept` validates against the same snapshot, so there is one read per write.
|
|
91
|
+
/** Append under the lock; `accept` validates against the same snapshot, so there is one read per write.
|
|
92
|
+
* The written snapshot is returned too, so the caller can piggyback unseen entries without a second read. */
|
|
92
93
|
function append(file: string, actor: string, entry: Omit<DiscussionEntry, "id" | "from" | "at">,
|
|
93
|
-
accept: (paper: DiscussionPaper) => string | undefined = () => undefined): Promise<DiscussionEntry | string> {
|
|
94
|
+
accept: (paper: DiscussionPaper) => string | undefined = () => undefined): Promise<{ entry: DiscussionEntry; paper: DiscussionPaper } | string> {
|
|
94
95
|
return withPaperLock(file, () => {
|
|
95
96
|
const paper = readPaper(file);
|
|
96
97
|
const rejected = accept(paper);
|
|
@@ -106,7 +107,7 @@ function append(file: string, actor: string, entry: Omit<DiscussionEntry, "id" |
|
|
|
106
107
|
paper.entries.push(next);
|
|
107
108
|
if (paper.entries.length > MAX_DISCUSSION_ENTRIES) paper.entries.splice(0, paper.entries.length - MAX_DISCUSSION_ENTRIES);
|
|
108
109
|
writePaper(file, paper);
|
|
109
|
-
return next;
|
|
110
|
+
return { entry: next, paper };
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -133,57 +134,94 @@ const render = (entries: readonly DiscussionEntry[], limit: number): string => {
|
|
|
133
134
|
}
|
|
134
135
|
const omitted = entries.length - lines.length;
|
|
135
136
|
return (omitted ? `(${omitted} older entries omitted; use questionId to read one thread)\n` : "") + lines.join("\n")
|
|
136
|
-
+ `\n(
|
|
137
|
+
+ `\n(latest ${entries.at(-1)!.id})`;
|
|
137
138
|
};
|
|
138
139
|
|
|
140
|
+
/** Unseen entries piggybacked on an ask/answer reply never exceed this. */
|
|
141
|
+
const MAX_PIGGYBACK_CHARS = 1000;
|
|
142
|
+
|
|
139
143
|
export function registerDiscussionTool(pi: ExtensionAPI, file: () => string | undefined,
|
|
140
144
|
actor: () => string = () => safeActor(process.env[SUBAGENT_NAME_ENV] ?? process.env[SUBAGENT_KEY_ENV])): void {
|
|
145
|
+
// Every Pi process (moderator or child) registers its own tool, so this closure is one actor's read
|
|
146
|
+
// cursor: the highest seq it has been shown on the current paper. A different path, or a paper whose
|
|
147
|
+
// seq went backwards (recreated), starts over at zero.
|
|
148
|
+
let cursor = { path: "", seq: 0 };
|
|
149
|
+
const unseen = (path: string, paper: DiscussionPaper, me: string) => {
|
|
150
|
+
if (cursor.path !== path || paper.seq < cursor.seq) cursor = { path, seq: 0 };
|
|
151
|
+
const after = cursor.seq;
|
|
152
|
+
return paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > after && entry.from !== me);
|
|
153
|
+
};
|
|
154
|
+
const advance = (seq: number) => { cursor.seq = Math.max(cursor.seq, seq); };
|
|
155
|
+
// The cursor is one max-seq, so it may only move past everything unseen. An ask/answer reply therefore
|
|
156
|
+
// attaches unseen entries (and advances) only when every unseen non-own entry is relevant to the caller
|
|
157
|
+
// (a question addressed to it, or an answer to one of its questions) and all of them fit
|
|
158
|
+
// MAX_PIGGYBACK_CHARS; otherwise it adds a one-line count and leaves them for `list`. The question being
|
|
159
|
+
// answered (`known`) counts as seen: the caller already has it.
|
|
160
|
+
const piggyback = (path: string, paper: DiscussionPaper, me: string, known?: string): string => {
|
|
161
|
+
const fresh = unseen(path, paper, me).filter((entry) => entry.id !== known);
|
|
162
|
+
if (!fresh.length) { advance(paper.seq); return ""; }
|
|
163
|
+
const asked = new Set(paper.entries.filter((entry) => entry.kind === "question" && entry.from === me).map((entry) => entry.id));
|
|
164
|
+
const relevant = fresh.filter((entry) => entry.kind === "question" ? entry.to === me : asked.has(entry.questionId ?? ""));
|
|
165
|
+
const shown = relevant.map((entry) => entryLine(entry, MAX_LISTED_TEXT)).join("\n");
|
|
166
|
+
if (relevant.length === fresh.length && shown.length <= MAX_PIGGYBACK_CHARS) { advance(paper.seq); return "\n" + shown; }
|
|
167
|
+
return `\n(${fresh.length} new ${fresh.length === 1 ? "entry" : "entries"}${relevant.length ? `, ${relevant.length} for you` : ""}; list to read)`;
|
|
168
|
+
};
|
|
141
169
|
const parameters = Type.Object({
|
|
142
170
|
action: Type.Union([Type.Literal("list"), Type.Literal("ask"), Type.Literal("answer")]),
|
|
143
171
|
text: Type.Optional(Type.String()),
|
|
144
172
|
to: Type.Optional(Type.String()),
|
|
145
173
|
questionId: Type.Optional(Type.String({ description: "answer: the question being answered. list: read only that question and its answers, in full." })),
|
|
146
|
-
since: Type.Optional(Type.String({ description: "list:
|
|
174
|
+
since: Type.Optional(Type.String({ description: "list: entries newer than this id (e.g. d12) instead of only unseen ones; \"d0\" rereads everything." }))
|
|
147
175
|
});
|
|
148
176
|
pi.registerTool?.<typeof parameters, { path?: string; entry?: DiscussionEntry }>({
|
|
149
177
|
name: "jar_discuss",
|
|
150
178
|
label: "discuss",
|
|
151
|
-
description: `
|
|
152
|
-
promptSnippet: "
|
|
179
|
+
description: `Shared bounded Q/A paper between this session's agents; not a transcript. ask/answer reply with the new id only. list returns only entries you have not seen (≤${MAX_LIST_CHARS} chars).`,
|
|
180
|
+
promptSnippet: "Ask or answer one concrete cross-agent question on the shared discussion paper.",
|
|
153
181
|
promptGuidelines: [
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"Do not poll list while waiting for an answer: continue your task and check again later with since set to the newest id you saw; use questionId to read one thread."
|
|
182
|
+
"jar_discuss: one precise question or answer per call; answer only the given questionId. Replies never echo your text.",
|
|
183
|
+
"Answers to your questions also arrive on your next ask/answer. list shows only unseen entries; questionId reads one thread. Don't poll."
|
|
157
184
|
],
|
|
158
185
|
parameters,
|
|
159
186
|
async execute(_id, params) {
|
|
160
187
|
const path = file();
|
|
161
188
|
if (!path) return { content: [{ type: "text", text: "No shared discussion paper is active." }], details: {} };
|
|
189
|
+
const me = safeActor(actor());
|
|
162
190
|
if (params.action === "list") {
|
|
163
|
-
const
|
|
191
|
+
const paper = readPaper(path);
|
|
164
192
|
const thread = params.questionId ? cleanText(params.questionId, 24) : undefined;
|
|
165
193
|
if (thread) {
|
|
166
|
-
const items = entries.filter((entry) => entry.id === thread || entry.questionId === thread);
|
|
194
|
+
const items = paper.entries.filter((entry) => entry.id === thread || entry.questionId === thread);
|
|
167
195
|
return { content: [{ type: "text", text: render(items, MAX_DISCUSSION_TEXT) }], details: { path } };
|
|
168
196
|
}
|
|
169
197
|
const since = seqOf(params.since);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
const fresh = unseen(path, paper, me);
|
|
199
|
+
advance(paper.seq);
|
|
200
|
+
if (since !== undefined) {
|
|
201
|
+
const newer = paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > since);
|
|
202
|
+
const text = newer.length ? render(newer, MAX_LISTED_TEXT) : `No entries newer than ${params.since}.`;
|
|
203
|
+
return { content: [{ type: "text", text }], details: { path } };
|
|
204
|
+
}
|
|
205
|
+
const latest = paper.entries.at(-1)?.id;
|
|
206
|
+
const text = fresh.length ? render(fresh, MAX_LISTED_TEXT) : `No new entries${latest ? ` (latest ${latest})` : ""}.`;
|
|
207
|
+
return { content: [{ type: "text", text }], details: { path } };
|
|
173
208
|
}
|
|
174
209
|
const text = cleanText(params.text ?? "", MAX_DISCUSSION_TEXT);
|
|
175
210
|
if (!text) return { content: [{ type: "text", text: "Discussion text is required." }], details: {}, isError: true };
|
|
176
211
|
if (params.action === "ask") {
|
|
177
|
-
const
|
|
178
|
-
if (typeof
|
|
179
|
-
|
|
212
|
+
const added = await append(path, me, { kind: "question", text, ...(params.to ? { to: safeActor(params.to) } : {}) });
|
|
213
|
+
if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
|
|
214
|
+
const { entry, paper } = added;
|
|
215
|
+
const reply = `Added ${entry.id}${entry.to ? ` (→ ${entry.to})` : ""}.` + piggyback(path, paper, me);
|
|
216
|
+
return { content: [{ type: "text", text: reply }], details: { path, entry } };
|
|
180
217
|
}
|
|
181
218
|
const questionId = cleanText(params.questionId ?? "", 24);
|
|
182
219
|
if (!questionId) return { content: [{ type: "text", text: "questionId is required for an answer." }], details: {}, isError: true };
|
|
183
|
-
const
|
|
220
|
+
const added = await append(path, me, { kind: "answer", questionId, text }, (paper) =>
|
|
184
221
|
paper.entries.some((item) => item.kind === "question" && item.id === questionId) ? undefined : "Unknown discussion question: " + questionId);
|
|
185
|
-
if (typeof
|
|
186
|
-
|
|
222
|
+
if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
|
|
223
|
+
const { entry, paper } = added;
|
|
224
|
+
return { content: [{ type: "text", text: `Answered ${questionId} as ${entry.id}.` + piggyback(path, paper, me, questionId) }], details: { path, entry } };
|
|
187
225
|
}
|
|
188
226
|
});
|
|
189
227
|
}
|
package/src/quota.ts
CHANGED
|
@@ -84,7 +84,18 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
84
84
|
const item = window as Record<string, unknown>;
|
|
85
85
|
return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
|
|
86
86
|
};
|
|
87
|
-
|
|
87
|
+
// Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
|
|
88
|
+
// secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
|
|
89
|
+
// to position only when the duration is absent.
|
|
90
|
+
const quota: Quota = {};
|
|
91
|
+
for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
|
|
92
|
+
const value = parse(window);
|
|
93
|
+
if (!value) continue;
|
|
94
|
+
const seconds = (window as Record<string, unknown>).limit_window_seconds;
|
|
95
|
+
const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
|
|
96
|
+
if (!quota[slot]) quota[slot] = value;
|
|
97
|
+
}
|
|
98
|
+
return parseQuota(quota);
|
|
88
99
|
}
|
|
89
100
|
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
90
101
|
if (!window || typeof window !== "object") return undefined;
|