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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. 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, role: string): string {
82
- return `${theme.fg(role, `${FILE_STATUS[file.status]} ${displayText(file.path)}`)} ${fileCounts(file, 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 ? ` ${fileLabel(row.file, theme, active ? ROLE.SELECTED : ROLE.PATH_IDLE)}` : theme.fg(active ? ROLE.SELECTED : ROLE.PATH_IDLE, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`);
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 marker = index === this.selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
455
- return `${marker} ${fileLabel(file, theme, index === this.selected ? ROLE.PATH : ROLE.PATH_IDLE)}`;
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[] {
@@ -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
- const status = STATUS_BY_CODE[code[0]] ?? STATUS_BY_CODE[code[1]] ?? CHANGE_STATUS.MODIFIED;
94
- statuses.set(path, status);
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 tree = metadata.get(root) ?? { root };
287
+ const known = metadata.get(root);
212
288
  try {
213
- const tracker = new ChangesTracker(this.gitForRoot(tree.root), this.linesForRoot(tree.root));
289
+ const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root));
214
290
  await tracker.start();
215
- if (tracker.model.files.length) trees.push({ ...tree, model: tracker.model });
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
+ }
@@ -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?: string };
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
- const lines = [...(component?.render(contentWidth - RAIL_PADDING * 2) ?? [])];
140
- while (lines.length && lines[lines.length - 1]?.trim() === "") lines.pop();
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
- const branding = renderSidebarBanner(theme, contentWidth - RAIL_PADDING * 2);
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
- if (railLines.length > 0) railLines.push("");
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
- prepared = { revision: cache.revision, width, mode: host.mode, root, theme, parts, digests, contentWidth, active, lines: railLines, hits };
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
- const left = { render: () => [], invalidate() {}, [NODE]: () => original.call(root) };
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
- return (current.presentation = {
183
- scrollTop: scroll.scrollTop,
184
- output: { type: "hstack", gap: GAP, align: "stretch", entries: [
185
- { component: left, basis: 0, grow: 1, shrink: 1, minSize: 1 },
186
- { component: scroll, basis: RAIL_WIDTH, grow: 0, shrink: 0, minSize: RAIL_WIDTH },
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);
@@ -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.fg("accent", control)}`, subtitle: `${done} of ${total}`, body, tone: stale ? CARD_TONE.WARNING : CARD_TONE.INFO, glyph: TODO_GLYPH },
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 },