gentle-pi 3.2.0 → 3.3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -2,6 +2,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, typ
|
|
|
2
2
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel, type WorktreeChanges } from "./shell-changes.ts";
|
|
5
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
5
6
|
|
|
6
7
|
// Gentle Shell changes overlay: a framed two-pane view with the working
|
|
7
8
|
// tree's changed files on the left and the selected file's diff on the right.
|
|
@@ -78,8 +79,8 @@ function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
|
78
79
|
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function fileLabel(file: ChangedFile, theme: ChangesViewTheme,
|
|
82
|
-
return `${theme
|
|
82
|
+
function fileLabel(file: ChangedFile, theme: ChangesViewTheme, hovered: boolean, idleRole: string): string {
|
|
83
|
+
return `${paintHoverable(theme, `${FILE_STATUS[file.status]} ${displayText(file.path)}`, hovered, idleRole)} ${fileCounts(file, theme)}`;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
function fit(text: string, width: number): string {
|
|
@@ -158,6 +159,7 @@ export class WorktreeChangesView {
|
|
|
158
159
|
private pointerLayout: PointerLayout | undefined;
|
|
159
160
|
private disposed = false;
|
|
160
161
|
private leftPressActive = false;
|
|
162
|
+
private hoveredIndex: number | undefined;
|
|
161
163
|
private readonly expanded = new Set<string>();
|
|
162
164
|
private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
|
|
163
165
|
|
|
@@ -172,6 +174,7 @@ export class WorktreeChangesView {
|
|
|
172
174
|
|
|
173
175
|
update(trees: WorktreeChanges[]): void {
|
|
174
176
|
this.leftPressActive = false;
|
|
177
|
+
this.hoveredIndex = undefined;
|
|
175
178
|
if (this.disposed) return;
|
|
176
179
|
this.pointerLayout = undefined;
|
|
177
180
|
const before = this.visibleRows()[this.selected];
|
|
@@ -239,6 +242,7 @@ export class WorktreeChangesView {
|
|
|
239
242
|
}
|
|
240
243
|
return undefined;
|
|
241
244
|
}
|
|
245
|
+
if (event.type === "move" && event.button === "none") return this.hoverRow(inFiles ? this.listOffset + event.y - 1 : undefined);
|
|
242
246
|
if (!inFiles) return undefined;
|
|
243
247
|
if (event.type === "press") {
|
|
244
248
|
if (event.button !== "left") return undefined;
|
|
@@ -255,6 +259,15 @@ export class WorktreeChangesView {
|
|
|
255
259
|
return { handled: true, render: changed };
|
|
256
260
|
}
|
|
257
261
|
|
|
262
|
+
// Row hover: the shared hover role paints an unselected row while the
|
|
263
|
+
// pointer is over it, never overriding the already-selected row's role.
|
|
264
|
+
private hoverRow(index: number | undefined): TuiMouseEventResult | undefined {
|
|
265
|
+
const next = index !== undefined && index >= 0 && index < this.visibleRows().length ? index : undefined;
|
|
266
|
+
if (next === this.hoveredIndex) return next === undefined ? undefined : { handled: true };
|
|
267
|
+
this.hoveredIndex = next;
|
|
268
|
+
return { handled: true, render: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
render(width: number): string[] {
|
|
259
272
|
const theme = this.deps.theme;
|
|
260
273
|
const rows = this.visibleRows();
|
|
@@ -266,8 +279,14 @@ export class WorktreeChangesView {
|
|
|
266
279
|
const row = rows[index + this.listOffset];
|
|
267
280
|
if (!row) return "";
|
|
268
281
|
const active = index + this.listOffset === this.selected;
|
|
282
|
+
// Selection always outranks hover; the shared hover painter only
|
|
283
|
+
// applies once selection is ruled out.
|
|
284
|
+
const hovered = !active && index + this.listOffset === this.hoveredIndex;
|
|
285
|
+
const idleRole = active ? ROLE.SELECTED : ROLE.PATH_IDLE;
|
|
269
286
|
const marker = active ? theme.fg(ROLE.SELECTED, "▸") : " ";
|
|
270
|
-
const text = row.file
|
|
287
|
+
const text = row.file
|
|
288
|
+
? ` ${fileLabel(row.file, theme, hovered, idleRole)}`
|
|
289
|
+
: paintHoverable(theme, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`, hovered, idleRole);
|
|
271
290
|
return `${marker} ${text}`;
|
|
272
291
|
};
|
|
273
292
|
const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
|
|
@@ -277,6 +296,7 @@ export class WorktreeChangesView {
|
|
|
277
296
|
|
|
278
297
|
invalidate(): void {
|
|
279
298
|
this.leftPressActive = false;
|
|
299
|
+
this.hoveredIndex = undefined;
|
|
280
300
|
this.pointerLayout = undefined;
|
|
281
301
|
for (const preview of this.previews.values()) preview.view.invalidate();
|
|
282
302
|
}
|
|
@@ -284,6 +304,7 @@ export class WorktreeChangesView {
|
|
|
284
304
|
dispose(): void {
|
|
285
305
|
this.disposed = true;
|
|
286
306
|
this.leftPressActive = false;
|
|
307
|
+
this.hoveredIndex = undefined;
|
|
287
308
|
this.pointerLayout = undefined;
|
|
288
309
|
for (const preview of this.previews.values()) preview.view.dispose();
|
|
289
310
|
this.previews.clear();
|
|
@@ -333,6 +354,7 @@ export class ChangesView {
|
|
|
333
354
|
private pointerLayout: PointerLayout | undefined;
|
|
334
355
|
private disposed = false;
|
|
335
356
|
private leftPressActive = false;
|
|
357
|
+
private hoveredIndex: number | undefined;
|
|
336
358
|
private readonly diffs = new Map<string, string[]>();
|
|
337
359
|
|
|
338
360
|
constructor(model: ChangesModel, deps: ChangesViewDeps) {
|
|
@@ -345,6 +367,7 @@ export class ChangesView {
|
|
|
345
367
|
// diffs for files whose counts moved so they reload.
|
|
346
368
|
update(model: ChangesModel): void {
|
|
347
369
|
this.leftPressActive = false;
|
|
370
|
+
this.hoveredIndex = undefined;
|
|
348
371
|
if (this.disposed) return;
|
|
349
372
|
this.pointerLayout = undefined;
|
|
350
373
|
const selectedPath = this.model.files[this.selected]?.path;
|
|
@@ -368,6 +391,7 @@ export class ChangesView {
|
|
|
368
391
|
dispose(): void {
|
|
369
392
|
this.disposed = true;
|
|
370
393
|
this.leftPressActive = false;
|
|
394
|
+
this.hoveredIndex = undefined;
|
|
371
395
|
this.pointerLayout = undefined;
|
|
372
396
|
}
|
|
373
397
|
|
|
@@ -409,6 +433,7 @@ export class ChangesView {
|
|
|
409
433
|
if (inDiff) return { handled: true, render: this.scrollDiff(event.wheelDelta ?? 0, layout.bodyRows) };
|
|
410
434
|
return undefined;
|
|
411
435
|
}
|
|
436
|
+
if (event.type === "move" && event.button === "none") return this.hoverRow(inFiles ? this.fileScroll + event.y - 1 : undefined);
|
|
412
437
|
if (!inFiles) return undefined;
|
|
413
438
|
if (event.type === "press") {
|
|
414
439
|
if (event.button !== "left") return undefined;
|
|
@@ -423,6 +448,15 @@ export class ChangesView {
|
|
|
423
448
|
return { handled: true, render: changed };
|
|
424
449
|
}
|
|
425
450
|
|
|
451
|
+
// Row hover: the shared hover role paints an unselected file row while the
|
|
452
|
+
// pointer is over it, never overriding the already-selected row's role.
|
|
453
|
+
private hoverRow(index: number | undefined): TuiMouseEventResult | undefined {
|
|
454
|
+
const next = index !== undefined && index >= 0 && index < this.model.files.length ? index : undefined;
|
|
455
|
+
if (next === this.hoveredIndex) return next === undefined ? undefined : { handled: true };
|
|
456
|
+
this.hoveredIndex = next;
|
|
457
|
+
return { handled: true, render: true };
|
|
458
|
+
}
|
|
459
|
+
|
|
426
460
|
render(width: number): string[] {
|
|
427
461
|
const theme = this.deps.theme;
|
|
428
462
|
const rows = this.bodyRows();
|
|
@@ -434,6 +468,7 @@ export class ChangesView {
|
|
|
434
468
|
|
|
435
469
|
invalidate(): void {
|
|
436
470
|
this.leftPressActive = false;
|
|
471
|
+
this.hoveredIndex = undefined;
|
|
437
472
|
this.pointerLayout = undefined;
|
|
438
473
|
}
|
|
439
474
|
|
|
@@ -451,8 +486,11 @@ export class ChangesView {
|
|
|
451
486
|
const file = this.model.files[index];
|
|
452
487
|
if (!file) return "";
|
|
453
488
|
const theme = this.deps.theme;
|
|
454
|
-
const
|
|
455
|
-
|
|
489
|
+
const selected = index === this.selected;
|
|
490
|
+
const marker = selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
|
|
491
|
+
const hovered = !selected && index === this.hoveredIndex;
|
|
492
|
+
const idleRole = selected ? ROLE.PATH : ROLE.PATH_IDLE;
|
|
493
|
+
return `${marker} ${fileLabel(file, theme, hovered, idleRole)}`;
|
|
456
494
|
}
|
|
457
495
|
|
|
458
496
|
private visibleDiff(rows: number): string[] {
|
package/lib/shell-changes.ts
CHANGED
|
@@ -90,8 +90,13 @@ export function parsePorcelain(text: string): Map<string, ChangeStatus> {
|
|
|
90
90
|
const record = records[index];
|
|
91
91
|
const code = record.slice(0, 2);
|
|
92
92
|
const path = record.slice(3);
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// A trailing "/" is Git's own marker for an embedded repository (a
|
|
94
|
+
// nested .git directory, e.g. an inner repo inside an outer one) or an
|
|
95
|
+
// excluded directory -- never an individual file. Even with
|
|
96
|
+
// --untracked-files=all, Git reports these as one directory-shaped line
|
|
97
|
+
// instead of expanding into files, so keeping it would show a phantom
|
|
98
|
+
// changed "file" on whatever ancestor repository contains the nested one.
|
|
99
|
+
if (!path.endsWith("/")) statuses.set(path, STATUS_BY_CODE[code[0]] ?? STATUS_BY_CODE[code[1]] ?? CHANGE_STATUS.MODIFIED);
|
|
95
100
|
if (code[0] === "R" || code[0] === "C") index += 1;
|
|
96
101
|
}
|
|
97
102
|
return statuses;
|
|
@@ -163,6 +168,77 @@ export function parseWorktrees(text: string): Array<{ root: string; branch?: str
|
|
|
163
168
|
});
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
// A root the view shows as-is when its own HEAD could not be determined at
|
|
172
|
+
// all -- distinct from "detached", which means git answered and there really
|
|
173
|
+
// is no branch. Exported so callers and tests share one literal.
|
|
174
|
+
export const UNKNOWN_BRANCH = "unknown";
|
|
175
|
+
|
|
176
|
+
// Resolve a foreign clone's own HEAD directly instead of assuming "detached":
|
|
177
|
+
// a real branch name, "no commits yet" for an unborn branch (a branch ref
|
|
178
|
+
// that exists but has no commit), or undefined for a genuine detached HEAD
|
|
179
|
+
// (the caller falls back to "detached" in that case). A GitRunner that
|
|
180
|
+
// cannot even ask -- it rejects, e.g. because the root was removed or git
|
|
181
|
+
// itself is unavailable -- rejects here too, on purpose: that is a real
|
|
182
|
+
// failure to distinguish from git successfully reporting "no branch", and
|
|
183
|
+
// callers must not fold the two into the same undefined result.
|
|
184
|
+
export async function foreignRootBranch(git: GitRunner): Promise<string | undefined> {
|
|
185
|
+
const symbolic = await git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
186
|
+
const branch = symbolic.code === 0 ? symbolic.stdout.trim() : "";
|
|
187
|
+
if (!branch) return undefined;
|
|
188
|
+
const verified = await git(["rev-parse", "--verify", "-q", "HEAD"]);
|
|
189
|
+
return verified.code === 0 ? branch : "no commits yet";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The session-evidence tracker behind /gentle:changes records roots, never
|
|
193
|
+
// branches, so the overlay would label every tree "detached". This resolves
|
|
194
|
+
// each root's own HEAD state once (branch, "no commits yet", undefined for a
|
|
195
|
+
// real detached HEAD, or UNKNOWN_BRANCH when the root could not even be
|
|
196
|
+
// asked), decorates the trees from the cache, and tells the caller when a
|
|
197
|
+
// fresh answer arrived so the view can repaint. Git is touched only while the
|
|
198
|
+
// overlay is open, never at startup or on evidence refresh.
|
|
199
|
+
export class RootBranchLabels {
|
|
200
|
+
private readonly labels = new Map<string, string | undefined>();
|
|
201
|
+
private readonly pending = new Map<string, Promise<void>>();
|
|
202
|
+
private readonly gitForRoot: (root: string) => GitRunner;
|
|
203
|
+
private readonly onChange: () => void;
|
|
204
|
+
constructor(gitForRoot: (root: string) => GitRunner, onChange: () => void = () => {}) {
|
|
205
|
+
this.gitForRoot = gitForRoot;
|
|
206
|
+
this.onChange = onChange;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
decorate(trees: readonly WorktreeChanges[]): WorktreeChanges[] {
|
|
210
|
+
return trees.map((tree) => {
|
|
211
|
+
if (tree.branch !== undefined) return tree;
|
|
212
|
+
if (!this.labels.has(tree.root)) this.resolve(tree.root);
|
|
213
|
+
const branch = this.labels.get(tree.root);
|
|
214
|
+
return branch === undefined ? tree : { ...tree, branch };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every in-flight resolution has finished. */
|
|
219
|
+
async settled(): Promise<void> {
|
|
220
|
+
while (this.pending.size) await Promise.all(this.pending.values());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private resolve(root: string): void {
|
|
224
|
+
if (this.pending.has(root)) return;
|
|
225
|
+
// A rejection here means the root could not even be asked -- distinct
|
|
226
|
+
// from foreignRootBranch resolving to undefined, which means git
|
|
227
|
+
// answered and there really is no branch (a real detached HEAD).
|
|
228
|
+
const task = foreignRootBranch(this.gitForRoot(root))
|
|
229
|
+
.then(
|
|
230
|
+
(branch) => branch,
|
|
231
|
+
() => UNKNOWN_BRANCH,
|
|
232
|
+
)
|
|
233
|
+
.then((branch) => {
|
|
234
|
+
this.labels.set(root, branch);
|
|
235
|
+
this.pending.delete(root);
|
|
236
|
+
this.onChange();
|
|
237
|
+
});
|
|
238
|
+
this.pending.set(root, task);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
166
242
|
export class WorktreeChangesTracker {
|
|
167
243
|
worktrees: WorktreeChanges[] = [];
|
|
168
244
|
private inFlight: Promise<ChangesModel> | undefined;
|
|
@@ -208,11 +284,22 @@ export class WorktreeChangesTracker {
|
|
|
208
284
|
// Discovery labels registered roots; it never grants visibility to siblings.
|
|
209
285
|
const roots = new Set(this.registeredRoots());
|
|
210
286
|
for (const root of roots) {
|
|
211
|
-
const
|
|
287
|
+
const known = metadata.get(root);
|
|
212
288
|
try {
|
|
213
|
-
const tracker = new ChangesTracker(this.gitForRoot(
|
|
289
|
+
const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root));
|
|
214
290
|
await tracker.start();
|
|
215
|
-
if (tracker.model.files.length)
|
|
291
|
+
if (!tracker.model.files.length) continue;
|
|
292
|
+
// A root missing from this discovery scan belongs to a different Git
|
|
293
|
+
// clone entirely (e.g. an inner repository nested inside an outer
|
|
294
|
+
// one) -- worktree list can never describe a foreign clone, so ask
|
|
295
|
+
// it directly instead of defaulting to "detached". A root the scan
|
|
296
|
+
// DID describe is trusted as-is: its own "detached" marker (or lack
|
|
297
|
+
// of a branch) is already accurate for that clone. The direct ask
|
|
298
|
+
// can fail on its own (root gone, git unavailable) without the
|
|
299
|
+
// change scan above failing -- that must label the root
|
|
300
|
+
// UNKNOWN_BRANCH, not drop real, already-captured changes.
|
|
301
|
+
const branch = known ? known.branch : await foreignRootBranch(this.gitForRoot(root)).catch(() => UNKNOWN_BRANCH);
|
|
302
|
+
trees.push({ root, ...(branch ? { branch } : {}), model: tracker.model });
|
|
216
303
|
} catch {
|
|
217
304
|
// Linked roots can disappear between discovery and status.
|
|
218
305
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// One shared hover treatment for every clickable inline text/button surface
|
|
2
|
+
// in the shell: the usage overlay's footer hints, the changes modal's rows
|
|
3
|
+
// and controls, the agents panel, and the todo card's collapse control. A
|
|
4
|
+
// single role swap on hover -- no per-surface variant -- so every clickable
|
|
5
|
+
// text reads the same way once the pointer is over it.
|
|
6
|
+
//
|
|
7
|
+
// The header's usage segment deliberately does NOT use this: it is a single
|
|
8
|
+
// one-row component outside any wrapping region, so pi-tui's fullscreen
|
|
9
|
+
// dispatch (which only calls handleMouse on whichever leaf is under the
|
|
10
|
+
// pointer, lib/shell-sidebar-layout.ts) never delivers a "leave" once the
|
|
11
|
+
// pointer moves off it into the body below -- there is no move event left to
|
|
12
|
+
// clear the paint. A background self-expiry timer could paper over that, but
|
|
13
|
+
// was rejected as inelegant; the header usage segment stays click-only.
|
|
14
|
+
//
|
|
15
|
+
// This is presentation only. Each surface still tracks its own hovered state
|
|
16
|
+
// from its own "move" events (or, where it already uses NativePointerRegion,
|
|
17
|
+
// from that region's onHover/onLeave); this module exists so they all paint
|
|
18
|
+
// that state identically.
|
|
19
|
+
//
|
|
20
|
+
// Under tmux, zellij, and screen, pi-tui's alt-screen driver only forwards
|
|
21
|
+
// button-motion mouse reports, never plain "move" events (see
|
|
22
|
+
// node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js, the input
|
|
23
|
+
// parser around its SGR mouse handling), so nothing here ever activates in
|
|
24
|
+
// those multiplexers -- there is no hover to paint, and clicking still works
|
|
25
|
+
// exactly as before. This is a known platform limitation, not a bug to work
|
|
26
|
+
// around here.
|
|
27
|
+
|
|
28
|
+
export interface HoverTheme {
|
|
29
|
+
fg(role: string, text: string): string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The single role every clickable surface uses to paint its own hover. */
|
|
33
|
+
export const HOVER_ROLE = "warning";
|
|
34
|
+
|
|
35
|
+
/** Paint `text` in the shared hover role when `hovered`, unchanged otherwise. */
|
|
36
|
+
export function paintHoverable(theme: HoverTheme, text: string, hovered: boolean, idleRole?: string): string {
|
|
37
|
+
if (hovered) return theme.fg(HOVER_ROLE, text);
|
|
38
|
+
return idleRole === undefined ? text : theme.fg(idleRole, text);
|
|
39
|
+
}
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface PromptFrameOptions {
|
|
|
48
48
|
borderColor: (text: string) => string;
|
|
49
49
|
fg: (color: string, text: string) => string;
|
|
50
50
|
bold?: (text: string) => string;
|
|
51
|
+
/**
|
|
52
|
+
* Overrides the editor's own bottom scroll indicator (e.g. "esc again to
|
|
53
|
+
* cancel"). Both share the bottom rule's single label slot; an explicit
|
|
54
|
+
* hint always wins because it reflects state the editor cannot render on
|
|
55
|
+
* its own.
|
|
56
|
+
*/
|
|
57
|
+
escHint?: string;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
// A terminal cell cannot grow, so the petal earns presence with weight and
|
|
@@ -55,6 +62,8 @@ export interface PromptFrameOptions {
|
|
|
55
62
|
export const PROMPT_PETAL = "✿";
|
|
56
63
|
const PETAL_FRAMES = ["✿", "❀", "❁", "✾"] as const;
|
|
57
64
|
export const PROMPT_HINT = "type, or / for commands";
|
|
65
|
+
export const DOUBLE_ESC_CANCEL_HINT = "esc again to cancel";
|
|
66
|
+
export const IDLE_ESC_CLEAR_HINT = "esc again to clear";
|
|
58
67
|
const LABEL_ROLE = "muted";
|
|
59
68
|
const HINT_ROLE = "dim";
|
|
60
69
|
const FAKE_CURSOR = "\x1b[7m \x1b[0m";
|
|
@@ -119,7 +128,7 @@ export function framePromptLines(lines: string[], width: number, options: Prompt
|
|
|
119
128
|
const top = lines[0];
|
|
120
129
|
const bottom = lines[lines.length - 1];
|
|
121
130
|
const content = lines.slice(1, -1).map((line) => sideRules(line, innerWidth, options));
|
|
122
|
-
return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))];
|
|
131
|
+
return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, options.escHint ?? scrollIndicator(bottom))];
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ScrollView, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
1
|
+
import { ScrollView, VStack, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
2
2
|
import { sidebarState, type SidebarRail } from "./shell-sidebar.ts";
|
|
3
3
|
import type { ShellBarTheme } from "./shell-bar.ts";
|
|
4
4
|
import { renderSidebarBanner } from "./shell-sidebar-banner.ts";
|
|
@@ -6,15 +6,22 @@ import { renderSidebarBanner } from "./shell-sidebar-banner.ts";
|
|
|
6
6
|
export const SIDEBAR_BREAKPOINT = 140;
|
|
7
7
|
const RAIL_WIDTH = 50;
|
|
8
8
|
const RAIL_PADDING = 1;
|
|
9
|
+
// The rail's ScrollView keeps one column for its scrollbar; with the rail
|
|
10
|
+
// padding that puts the card's right border two columns in from the edge.
|
|
11
|
+
// The header row stops at the same column so its right group lines up with
|
|
12
|
+
// the card instead of touching the terminal edge.
|
|
13
|
+
const HEADER_RIGHT_INSET = RAIL_PADDING + 1;
|
|
9
14
|
const GAP = 3;
|
|
10
15
|
// Experimental Pi 0.85.1 internals. Only the fullscreen layout tree is adapted;
|
|
11
16
|
// regular mode keeps native scrollback and the original bottom components.
|
|
12
17
|
const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
13
|
-
type LayoutNode = { type: string; entries?: unknown[]; gap?: number; align?:
|
|
18
|
+
type LayoutNode = { type: string; entries?: unknown[]; gap?: number; align?: "stretch" | "start" | "center" | "end" };
|
|
19
|
+
type StackLayoutEntry = ConstructorParameters<typeof VStack>[0] extends Array<infer T> | undefined ? Exclude<T, Component> : never;
|
|
14
20
|
type LayoutRoot = Component & { [NODE]?: () => LayoutNode };
|
|
15
21
|
type Host = TUI & { mode?: string; layoutRoot?: LayoutRoot };
|
|
16
22
|
type SidebarCache = { revision: number };
|
|
17
23
|
type RailHit = { key: string; component: Component; startY: number; height: number; width: number };
|
|
24
|
+
type SectionCacheEntry = { component: Component; digest: string | undefined; revision: number; contentWidth: number; theme: ShellBarTheme; lines: string[] };
|
|
18
25
|
type SidebarPresentation = { scrollTop: number; output: LayoutNode };
|
|
19
26
|
type PreparedRail = {
|
|
20
27
|
revision: number;
|
|
@@ -28,6 +35,11 @@ type PreparedRail = {
|
|
|
28
35
|
active: boolean;
|
|
29
36
|
lines: string[];
|
|
30
37
|
hits: RailHit[];
|
|
38
|
+
// The header row is a full-width sibling above the hstack, not a rail
|
|
39
|
+
// section: it never enters `lines`/`hits`, and an empty/blank result
|
|
40
|
+
// falls back to the old hstack-direct shape with the banner restored.
|
|
41
|
+
headerLines: string[];
|
|
42
|
+
headerActive: boolean;
|
|
31
43
|
presentation?: SidebarPresentation;
|
|
32
44
|
};
|
|
33
45
|
const CACHE = Symbol.for("gentle-pi.experimental-sidebar.cache");
|
|
@@ -66,7 +78,15 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
66
78
|
let stopped = false;
|
|
67
79
|
let failed = false;
|
|
68
80
|
let railLines: string[] = [];
|
|
81
|
+
let headerLines: string[] = [];
|
|
69
82
|
let prepared: PreparedRail | undefined;
|
|
83
|
+
// One rendered-lines cache per rail section key, independent of the
|
|
84
|
+
// whole-rail `prepared` memo below: a section with its own digest is
|
|
85
|
+
// revalidated by that digest alone, so a sibling's ticking digest (the
|
|
86
|
+
// header/Status counters) never forces Agents or TODO to re-render. A
|
|
87
|
+
// section with no digest (or a throwing one) falls back to the shared
|
|
88
|
+
// revision counter, exactly like the whole-rail memo already did.
|
|
89
|
+
const sectionCache = new Map<string, SectionCacheEntry>();
|
|
70
90
|
state.active = false;
|
|
71
91
|
state.ownsHost = () => !stopped && host.mode === "fullscreen" && !!host.layoutRoot && roots.has(host.layoutRoot);
|
|
72
92
|
const rail: Component = {
|
|
@@ -76,6 +96,19 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
76
96
|
for (const part of state.parts.values()) part.invalidate();
|
|
77
97
|
},
|
|
78
98
|
};
|
|
99
|
+
// The header row: a plain leaf component, one line tall, painted above the
|
|
100
|
+
// hstack when a "header" part is registered and has something to show.
|
|
101
|
+
// The header is not inside the rail's ScrollView, so it never goes through
|
|
102
|
+
// dispatchPartMouse: it is its own leaf in the layout tree (no [NODE]),
|
|
103
|
+
// and pi-tui's mouse dispatch (tui-alt-screen.js dispatchMouseToLayout)
|
|
104
|
+
// finds and calls handleMouse on whatever leaf box is under the pointer
|
|
105
|
+
// directly, without any wiring of our own. Delegate straight to whatever
|
|
106
|
+
// the registered "header" part declares.
|
|
107
|
+
const header: Component = {
|
|
108
|
+
render: () => headerLines,
|
|
109
|
+
invalidate() {},
|
|
110
|
+
handleMouse: (event) => state.parts.get("header")?.handleMouse?.(event),
|
|
111
|
+
};
|
|
79
112
|
const scroll = new ScrollView(rail, {
|
|
80
113
|
follow: "none",
|
|
81
114
|
primary: false,
|
|
@@ -133,28 +166,55 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
133
166
|
return prepared.active;
|
|
134
167
|
}
|
|
135
168
|
try {
|
|
169
|
+
// The header is a full-width sibling row, not a rail section: it reads
|
|
170
|
+
// the terminal width (minus the rail's right inset), never the
|
|
171
|
+
// 50-column rail's content width.
|
|
172
|
+
const headerPart = state.parts.get("header");
|
|
173
|
+
const preparedHeaderLines = [...(headerPart?.render(Math.max(0, width - HEADER_RIGHT_INSET)) ?? [])];
|
|
174
|
+
const headerActive = headerPart !== undefined && preparedHeaderLines.some((line) => line.trim() !== "");
|
|
136
175
|
const contentWidth = scroll.getContentWidth(RAIL_WIDTH);
|
|
137
176
|
const sections = ["footer", "agents", "todo"].map((key) => {
|
|
138
177
|
const component = state.parts.get(key);
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
if (!component) {
|
|
179
|
+
sectionCache.delete(key);
|
|
180
|
+
return { key, component, lines: [] as string[] };
|
|
181
|
+
}
|
|
182
|
+
const digest = railDigest(component);
|
|
183
|
+
const existing = sectionCache.get(key);
|
|
184
|
+
const reusable = existing?.component === component && existing.contentWidth === contentWidth && existing.theme === theme &&
|
|
185
|
+
(digest !== undefined ? existing.digest === digest : existing.digest === undefined && existing.revision === cache.revision);
|
|
186
|
+
const lines = reusable ? existing.lines : (() => {
|
|
187
|
+
const rendered = [...(component.render(contentWidth - RAIL_PADDING * 2) ?? [])];
|
|
188
|
+
while (rendered.length && rendered[rendered.length - 1]?.trim() === "") rendered.pop();
|
|
189
|
+
return rendered;
|
|
190
|
+
})();
|
|
191
|
+
sectionCache.set(key, { component, digest, revision: cache.revision, contentWidth, theme, lines });
|
|
141
192
|
return { key, component, lines };
|
|
142
193
|
}).filter((section) => section.component !== undefined && section.lines.length > 0) as Array<{ key: string; component: Component; lines: string[] }>;
|
|
143
|
-
|
|
194
|
+
// The header carries the brand once it is active; the banner is the
|
|
195
|
+
// rail's fallback identity when no header is wired up.
|
|
196
|
+
const branding = headerActive ? [] : renderSidebarBanner(theme, contentWidth - RAIL_PADDING * 2);
|
|
144
197
|
const hits: RailHit[] = [];
|
|
145
198
|
railLines = [];
|
|
146
199
|
if (sections.length && branding.length) {
|
|
147
200
|
railLines.push(...branding.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
|
|
201
|
+
} else if (sections.length && headerActive) {
|
|
202
|
+
// The banner used to hold the first card off the top; the header
|
|
203
|
+
// took its place, so keep one blank row between them.
|
|
204
|
+
railLines.push("");
|
|
148
205
|
}
|
|
149
206
|
for (const section of sections) {
|
|
150
|
-
|
|
207
|
+
// One blank row separates a section from the banner or the
|
|
208
|
+
// previous section; the header gap above is not a section.
|
|
209
|
+
if (hits.length > 0 || branding.length > 0) railLines.push("");
|
|
151
210
|
const startY = railLines.length;
|
|
152
211
|
railLines.push(...section.lines.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
|
|
153
212
|
hits.push({ key: section.key, component: section.component, startY, height: section.lines.length, width: contentWidth - RAIL_PADDING * 2 });
|
|
154
213
|
}
|
|
155
214
|
// Height is owned by the native ScrollView, never by the transcript.
|
|
156
215
|
const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
|
|
157
|
-
|
|
216
|
+
headerLines = active && headerActive ? preparedHeaderLines : [];
|
|
217
|
+
prepared = { revision: cache.revision, width, mode: host.mode, root, theme, parts, digests, contentWidth, active, lines: railLines, hits, headerLines, headerActive: headerLines.length > 0 };
|
|
158
218
|
state.active = active;
|
|
159
219
|
return active;
|
|
160
220
|
} catch {
|
|
@@ -174,18 +234,54 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
174
234
|
// Fullscreen gives this stretched stack an explicit viewport height.
|
|
175
235
|
// Its intrinsic-height probe is unused; real painting traverses NODE.
|
|
176
236
|
// Delegating that probe to root.render would render the transcript twice.
|
|
177
|
-
|
|
237
|
+
// Pi's dock reserves one row for the footer (chat-viewport: minSize 1)
|
|
238
|
+
// even though our footer paints nothing while the sidebar is active.
|
|
239
|
+
// The row is baked in twice: the dock's own VStack.render pads it into
|
|
240
|
+
// the intrinsic height the root measures, and its layout node keeps
|
|
241
|
+
// it as minSize. Overriding only the node leaves the measured blank
|
|
242
|
+
// row in place, so the dock is re-hosted in a real VStack over the
|
|
243
|
+
// same children with the footer entry free to shrink to zero. One
|
|
244
|
+
// wrapper per dock keeps component identity stable across frames.
|
|
245
|
+
const docks = new WeakMap<Component, VStack>();
|
|
246
|
+
const reclaimFooterRow = (node: LayoutNode): LayoutNode => {
|
|
247
|
+
if (node.type !== "vstack" || !node.entries?.length) return node;
|
|
248
|
+
const entries = node.entries as Array<{ component: Component & { [NODE]?: () => LayoutNode } }>;
|
|
249
|
+
const dock = entries[entries.length - 1]!.component;
|
|
250
|
+
if (typeof dock[NODE] !== "function") return node;
|
|
251
|
+
let wrapped = docks.get(dock);
|
|
252
|
+
if (!wrapped) {
|
|
253
|
+
const inner = dock[NODE]!();
|
|
254
|
+
if (inner.type !== "vstack" || !inner.entries?.length) return node;
|
|
255
|
+
const last = inner.entries.length - 1;
|
|
256
|
+
wrapped = new VStack(inner.entries.map((entry, index) => index === last ? { ...(entry as StackLayoutEntry), minSize: 0 } : entry as StackLayoutEntry), { gap: inner.gap, align: inner.align });
|
|
257
|
+
docks.set(dock, wrapped);
|
|
258
|
+
}
|
|
259
|
+
return { ...node, entries: entries.map((entry, index) => index === entries.length - 1 ? { ...entry, component: wrapped! } : entry) };
|
|
260
|
+
};
|
|
261
|
+
const left = { render: () => [], invalidate() {}, [NODE]: () => reclaimFooterRow(original.call(root)) };
|
|
262
|
+
// Stable component wrapping the [left, scroll] hstack behind its own
|
|
263
|
+
// NODE, exactly like `left` wraps the native transcript: the header
|
|
264
|
+
// vstack's second entry recurses into it the same way pi-tui already
|
|
265
|
+
// recurses into a nested layout via [NODE].
|
|
266
|
+
const hstackHost: Component & { [NODE](): LayoutNode } = {
|
|
267
|
+
render: () => [],
|
|
268
|
+
invalidate() {},
|
|
269
|
+
[NODE]: () => ({ type: "hstack", gap: GAP, align: "stretch", entries: [
|
|
270
|
+
{ component: left, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
271
|
+
{ component: scroll, basis: RAIL_WIDTH, grow: 0, shrink: 0, minSize: RAIL_WIDTH },
|
|
272
|
+
] }),
|
|
273
|
+
};
|
|
178
274
|
const replacement = () => {
|
|
179
275
|
if (!prepare(tui.terminal.columns, root)) return original.call(root);
|
|
180
276
|
const current = prepared!;
|
|
181
277
|
if (current.presentation?.scrollTop === scroll.scrollTop) return current.presentation.output;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{ component:
|
|
186
|
-
|
|
187
|
-
]
|
|
188
|
-
}).output;
|
|
278
|
+
const output: LayoutNode = current.headerActive
|
|
279
|
+
? { type: "vstack", gap: 0, align: "stretch", entries: [
|
|
280
|
+
{ component: header, basis: 1, grow: 0, shrink: 0, minSize: 1 },
|
|
281
|
+
{ component: hstackHost, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
282
|
+
] }
|
|
283
|
+
: hstackHost[NODE]();
|
|
284
|
+
return (current.presentation = { scrollTop: scroll.scrollTop, output }).output;
|
|
189
285
|
};
|
|
190
286
|
root[NODE] = replacement;
|
|
191
287
|
roots.add(root);
|
package/lib/shell-sidebar.ts
CHANGED
|
@@ -39,3 +39,19 @@ export function sidebarPart<T extends Component & { dispose?(): void }>(tui: TUI
|
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register the fullscreen header rail: the one row above the hstack that
|
|
45
|
+
* carries the brand, session identity, and the per-frame counters. There is
|
|
46
|
+
* no narrow-mode bottom counterpart — the compact bar already carries this
|
|
47
|
+
* data when the sidebar is inactive — so this only ever writes the "header"
|
|
48
|
+
* slot in sidebarState(tui).parts, and returns its own disposer.
|
|
49
|
+
*/
|
|
50
|
+
export function sidebarHeader(tui: TUI, rail: SidebarRail): () => void {
|
|
51
|
+
if (!tui.terminal) return () => {};
|
|
52
|
+
const state = sidebarState(tui);
|
|
53
|
+
state.parts.set("header", rail);
|
|
54
|
+
return () => {
|
|
55
|
+
if (state.parts.get("header") === rail) state.parts.delete("header");
|
|
56
|
+
};
|
|
57
|
+
}
|
package/lib/shell-todo.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { CARD_TONE, cardInnerWidth, renderCard, type CardTheme } from "./shell-card.ts";
|
|
3
3
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
4
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
4
5
|
|
|
5
6
|
// Gentle Todo: the task list the model keeps while it works. Everything here
|
|
6
7
|
// is pure. The state lives in the session branch (every tool result carries
|
|
@@ -77,6 +78,8 @@ export interface TodoRenderOptions {
|
|
|
77
78
|
collapsed: boolean;
|
|
78
79
|
staleTurns: number;
|
|
79
80
|
collapseKey?: string;
|
|
81
|
+
/** The pointer is over the clickable collapse/expand control. */
|
|
82
|
+
hovered?: boolean;
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/** Tool results carry the snapshot under this key; the old rpiv-todo shape is read too. */
|
|
@@ -288,8 +291,11 @@ export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number
|
|
|
288
291
|
const hint = options.collapseKey ? `${options.collapseKey} ${action}` : undefined;
|
|
289
292
|
const rows = options.collapsed ? [collapsedRow(state, theme, width)] : options.scrollable ? state.tasks.map((task) => taskRow(task, theme, width)) : bodyRows(state, theme, width);
|
|
290
293
|
const body = stale ? [theme.fg(NOTE_ROLE, `stale · ${options.staleTurns} turns`), ...rows] : rows;
|
|
294
|
+
// The clickable control paints the shared hover role while hovered --
|
|
295
|
+
// same treatment every other clickable surface uses -- instead of its
|
|
296
|
+
// ordinary accent role.
|
|
291
297
|
return renderCard(
|
|
292
|
-
{ title: `Todos ${theme.
|
|
298
|
+
{ title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${total}`, body, tone: stale ? CARD_TONE.WARNING : CARD_TONE.INFO, glyph: TODO_GLYPH },
|
|
293
299
|
theme,
|
|
294
300
|
width,
|
|
295
301
|
{ expanded: true, hint },
|