gentle-pi 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -1,5 +1,7 @@
1
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel } from "./shell-changes.ts";
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
3
+ import { basename } from "node:path";
4
+ import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel, type WorktreeChanges } from "./shell-changes.ts";
3
5
 
4
6
  // Gentle Shell changes overlay: a framed two-pane view with the working
5
7
  // tree's changed files on the left and the selected file's diff on the right.
@@ -11,11 +13,12 @@ export interface ChangesViewTheme {
11
13
 
12
14
  export interface ChangesViewDeps {
13
15
  theme: ChangesViewTheme;
14
- rows: number;
16
+ rows: number | (() => number);
15
17
  loadDiff(file: ChangedFile): Promise<string>;
16
18
  onOpen(file: ChangedFile): void;
17
19
  onClose(): void;
18
20
  requestRender(): void;
21
+ backLabel?: string;
19
22
  }
20
23
 
21
24
  const ROLE = {
@@ -26,7 +29,6 @@ const ROLE = {
26
29
  PATH_IDLE: "muted",
27
30
  ADDED: "success",
28
31
  REMOVED: "error",
29
- NEW: "success",
30
32
  HUNK: "customMessageLabel",
31
33
  KEY: "accent",
32
34
  KEY_TEXT: "dim",
@@ -63,11 +65,20 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
63
65
  return lines;
64
66
  }
65
67
 
68
+ const FILE_STATUS = {
69
+ [CHANGE_STATUS.MODIFIED]: "M",
70
+ [CHANGE_STATUS.ADDED]: "A",
71
+ [CHANGE_STATUS.DELETED]: "D",
72
+ [CHANGE_STATUS.RENAMED]: "R",
73
+ [CHANGE_STATUS.UNTRACKED]: "??",
74
+ } as const;
75
+
66
76
  function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
67
- if (file.status === CHANGE_STATUS.UNTRACKED || file.status === CHANGE_STATUS.ADDED) {
68
- return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.NEW, "new")}`;
69
- }
70
- return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `−${file.deleted}`)}`;
77
+ return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
78
+ }
79
+
80
+ function fileLabel(file: ChangedFile, theme: ChangesViewTheme, role: string): string {
81
+ return `${theme.fg(role, `${FILE_STATUS[file.status]} ${displayText(file.path)}`)} ${fileCounts(file, theme)}`;
71
82
  }
72
83
 
73
84
  function fit(text: string, width: number): string {
@@ -75,15 +86,252 @@ function fit(text: string, width: number): string {
75
86
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
76
87
  }
77
88
 
89
+ // Both standalone files and the worktree accordion use the original frame.
90
+ function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, rightLines: string[], keys: string): string[] {
91
+ const inner = Math.max(8, width - 2);
92
+ const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
93
+ const diffWidth = inner - listWidth - 4;
94
+ const titleText = truncateToWidth(title, inner - 4, "…");
95
+ const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
96
+ const body: string[] = [];
97
+ for (let row = 0; row < rows; row += 1) {
98
+ const left = fit(leftLine(row), listWidth);
99
+ const right = fit(rightLines[row] ?? "", diffWidth);
100
+ body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
101
+ }
102
+ const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
103
+ const bottom = theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`);
104
+ return [top, ...body, keysLine, bottom].map((line) => truncateToWidth(line, width));
105
+ }
106
+
107
+ interface PointerLayout {
108
+ width: number;
109
+ height: number;
110
+ bodyRows: number;
111
+ listStart: number;
112
+ listEnd: number;
113
+ diffStart: number;
114
+ diffEnd: number;
115
+ }
116
+
117
+ function pointerLayout(width: number, bodyRows: number): PointerLayout {
118
+ const inner = Math.max(8, width - 2);
119
+ const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
120
+ const diffWidth = inner - listWidth - 4;
121
+ return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
122
+ }
123
+
124
+ function displayText(text: string): string {
125
+ return sanitizeTerminalText(text).replace(/[\t\n]/g, " ");
126
+ }
127
+
78
128
  function fingerprint(file: ChangedFile): string {
79
129
  return `${file.status}:${file.added}:${file.deleted}`;
80
130
  }
81
131
 
132
+ export interface WorktreeChangesViewDeps {
133
+ theme: ChangesViewTheme;
134
+ rows: number | (() => number);
135
+ loadDiff(root: string, file: ChangedFile): Promise<string>;
136
+ onOpen(root: string, file: ChangedFile): void;
137
+ onClose(): void;
138
+ onRefresh(): void;
139
+ requestRender(): void;
140
+ }
141
+
142
+ interface WorktreeRow {
143
+ tree: WorktreeChanges;
144
+ file?: ChangedFile;
145
+ }
146
+
147
+ function rowKey(row: WorktreeRow): string {
148
+ return JSON.stringify([row.tree.root, row.file?.path ?? null]);
149
+ }
150
+
151
+ // The accordion owns navigation; standalone file views still own lazy previews.
152
+ export class WorktreeChangesView {
153
+ private trees: WorktreeChanges[];
154
+ private readonly deps: WorktreeChangesViewDeps;
155
+ private selected = 0;
156
+ private listOffset = 0;
157
+ private pointerLayout: PointerLayout | undefined;
158
+ private disposed = false;
159
+ private leftPressActive = false;
160
+ private readonly expanded = new Set<string>();
161
+ private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
162
+
163
+ constructor(trees: WorktreeChanges[], deps: WorktreeChangesViewDeps) {
164
+ this.trees = trees;
165
+ this.deps = deps;
166
+ }
167
+
168
+ private visibleRows(): WorktreeRow[] {
169
+ return this.trees.flatMap((tree) => [{ tree }, ...(this.expanded.has(tree.root) ? tree.model.files.map((file) => ({ tree, file })) : [])]);
170
+ }
171
+
172
+ update(trees: WorktreeChanges[]): void {
173
+ this.leftPressActive = false;
174
+ if (this.disposed) return;
175
+ this.pointerLayout = undefined;
176
+ const before = this.visibleRows()[this.selected];
177
+ this.trees = trees;
178
+ for (const root of this.expanded) if (!trees.some((tree) => tree.root === root)) this.expanded.delete(root);
179
+ const files = new Map(trees.flatMap((tree) => tree.model.files.map((file) => [rowKey({ tree, file }), fingerprint(file)])));
180
+ for (const [key, preview] of this.previews) {
181
+ if (files.get(key) !== preview.fingerprint) {
182
+ preview.view.dispose();
183
+ this.previews.delete(key);
184
+ }
185
+ }
186
+ const rows = this.visibleRows();
187
+ let index = before ? rows.findIndex((row) => rowKey(row) === rowKey(before)) : -1;
188
+ if (index < 0 && before) index = rows.findIndex((row) => row.tree.root === before.tree.root);
189
+ this.selected = index < 0 ? Math.max(0, Math.min(this.selected, rows.length - 1)) : index;
190
+ this.ensurePreview();
191
+ this.deps.requestRender();
192
+ }
193
+
194
+ handleInput(data: string): void {
195
+ if (this.disposed) return;
196
+ if (data === "r") return this.deps.onRefresh();
197
+ if (matchesKey(data, Key.escape) || data === "q") return this.deps.onClose();
198
+ const rows = this.visibleRows();
199
+ const row = rows[this.selected];
200
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
201
+ if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
202
+ return;
203
+ }
204
+ if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
205
+ else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
206
+ else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
207
+ if (row.file) this.selected = rows.findIndex((item) => item.tree.root === row.tree.root && !item.file);
208
+ else this.expanded.delete(row.tree.root);
209
+ } else if (row && !row.file && (matchesKey(data, Key.enter) || data === " " || matchesKey(data, Key.right))) {
210
+ if (this.expanded.has(row.tree.root)) this.expanded.delete(row.tree.root);
211
+ else this.expanded.add(row.tree.root);
212
+ } else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) this.deps.onOpen(row.tree.root, row.file);
213
+ this.ensureSelectedVisible();
214
+ this.ensurePreview();
215
+ this.deps.requestRender();
216
+ }
217
+
218
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
219
+ if (this.disposed) return { handled: true, render: false };
220
+ if (event.type === "release") {
221
+ if (this.leftPressActive && (event.button === "left" || event.button === "none")) {
222
+ this.leftPressActive = false;
223
+ return { handled: true, render: false };
224
+ }
225
+ return undefined;
226
+ }
227
+ if (event.type === "press" && event.button !== "left") return undefined;
228
+ const layout = this.pointerLayout;
229
+ if (!layout || event.width !== layout.width || event.height !== layout.height) return { handled: true, render: false };
230
+ const inBody = event.y >= 1 && event.y <= layout.bodyRows;
231
+ const inFiles = inBody && event.x >= layout.listStart && event.x < layout.listEnd;
232
+ const inDiff = inBody && event.x >= layout.diffStart && event.x < layout.diffEnd;
233
+ if (event.type === "wheel") {
234
+ if (inFiles) return { handled: true, render: this.scrollList(event.wheelDelta ?? 0, layout.bodyRows) };
235
+ if (inDiff) {
236
+ const row = this.visibleRows()[this.selected];
237
+ return { handled: true, render: row?.file ? Boolean(this.previews.get(rowKey(row))?.view.scrollDiffBy(event.wheelDelta ?? 0, layout.bodyRows)) : false };
238
+ }
239
+ return undefined;
240
+ }
241
+ if (!inFiles) return undefined;
242
+ if (event.type === "press") {
243
+ if (event.button !== "left") return undefined;
244
+ this.leftPressActive = true;
245
+ return { handled: true, capture: true, render: false };
246
+ }
247
+ if (event.type !== "click" || event.button !== "left") return undefined;
248
+ const index = this.listOffset + event.y - 1;
249
+ if (index < 0 || index >= this.visibleRows().length) return { handled: true, render: false };
250
+ const changed = index !== this.selected;
251
+ this.selected = index;
252
+ this.ensurePreview();
253
+ this.deps.requestRender();
254
+ return { handled: true, render: changed };
255
+ }
256
+
257
+ render(width: number): string[] {
258
+ const theme = this.deps.theme;
259
+ const rows = this.visibleRows();
260
+ const height = this.bodyRows();
261
+ this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, rows.length - height)));
262
+ const selected = rows[this.selected];
263
+ const preview = selected?.file ? this.previews.get(rowKey(selected))?.view.previewLines() ?? [] : [selected ? displayText(selected.tree.root) : "No dirty worktrees.", selected ? changesSummary(selected.tree.model) : "", selected ? "Expand a group and select a file." : ""];
264
+ const leftLine = (index: number) => {
265
+ const row = rows[index + this.listOffset];
266
+ if (!row) return "";
267
+ const active = index + this.listOffset === this.selected;
268
+ const marker = active ? theme.fg(ROLE.SELECTED, "▸") : " ";
269
+ 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))}`);
270
+ return `${marker} ${text}`;
271
+ };
272
+ const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
273
+ this.pointerLayout = pointerLayout(width, height);
274
+ return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys);
275
+ }
276
+
277
+ invalidate(): void {
278
+ this.leftPressActive = false;
279
+ this.pointerLayout = undefined;
280
+ for (const preview of this.previews.values()) preview.view.invalidate();
281
+ }
282
+
283
+ dispose(): void {
284
+ this.disposed = true;
285
+ this.leftPressActive = false;
286
+ this.pointerLayout = undefined;
287
+ for (const preview of this.previews.values()) preview.view.dispose();
288
+ this.previews.clear();
289
+ }
290
+
291
+ private bodyRows(): number {
292
+ const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
293
+ return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
294
+ }
295
+
296
+ private scrollList(delta: number, rows: number): boolean {
297
+ const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
298
+ if (next === this.listOffset) return false;
299
+ this.listOffset = next;
300
+ return true;
301
+ }
302
+
303
+ private ensureSelectedVisible(): void {
304
+ const height = this.bodyRows();
305
+ if (this.selected < this.listOffset) this.listOffset = this.selected;
306
+ else if (this.selected >= this.listOffset + height) this.listOffset = this.selected - height + 1;
307
+ this.listOffset = Math.max(0, Math.min(this.listOffset, Math.max(0, this.visibleRows().length - height)));
308
+ }
309
+
310
+ private ensurePreview(): void {
311
+ const row = this.visibleRows()[this.selected];
312
+ if (!row?.file || this.previews.has(rowKey(row))) return;
313
+ const { tree, file } = row;
314
+ this.previews.set(rowKey(row), {
315
+ fingerprint: fingerprint(file),
316
+ view: new ChangesView({ files: [file], added: file.added, deleted: file.deleted }, {
317
+ ...this.deps,
318
+ rows: () => typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows,
319
+ loadDiff: (target) => this.deps.loadDiff(tree.root, target),
320
+ onOpen: (target) => this.deps.onOpen(tree.root, target),
321
+ }),
322
+ });
323
+ }
324
+ }
325
+
82
326
  export class ChangesView {
83
327
  private model: ChangesModel;
84
328
  private readonly deps: ChangesViewDeps;
85
329
  private selected = 0;
86
- private scroll = 0;
330
+ private fileScroll = 0;
331
+ private diffScroll = 0;
332
+ private pointerLayout: PointerLayout | undefined;
333
+ private disposed = false;
334
+ private leftPressActive = false;
87
335
  private readonly diffs = new Map<string, string[]>();
88
336
 
89
337
  constructor(model: ChangesModel, deps: ChangesViewDeps) {
@@ -95,6 +343,9 @@ export class ChangesView {
95
343
  // Replace the model while open: keep the selection by path and drop cached
96
344
  // diffs for files whose counts moved so they reload.
97
345
  update(model: ChangesModel): void {
346
+ this.leftPressActive = false;
347
+ if (this.disposed) return;
348
+ this.pointerLayout = undefined;
98
349
  const selectedPath = this.model.files[this.selected]?.path;
99
350
  const before = new Map(this.model.files.map((file) => [file.path, fingerprint(file)]));
100
351
  for (const file of model.files) {
@@ -105,12 +356,22 @@ export class ChangesView {
105
356
  }
106
357
  this.model = model;
107
358
  const index = model.files.findIndex((file) => file.path === selectedPath);
108
- this.selected = index === -1 ? Math.max(0, Math.min(this.selected, model.files.length - 1)) : index;
359
+ const selectionChanged = index === -1;
360
+ this.selected = selectionChanged ? Math.max(0, Math.min(this.selected, model.files.length - 1)) : index;
361
+ this.fileScroll = this.clampFileScroll(this.fileScroll, this.bodyRows());
362
+ if (selectionChanged) this.ensureSelectedVisible();
109
363
  this.loadSelected();
110
364
  this.deps.requestRender();
111
365
  }
112
366
 
367
+ dispose(): void {
368
+ this.disposed = true;
369
+ this.leftPressActive = false;
370
+ this.pointerLayout = undefined;
371
+ }
372
+
113
373
  handleInput(data: string): void {
374
+ if (this.disposed) return;
114
375
  if (matchesKey(data, Key.escape) || data === "q") {
115
376
  this.deps.onClose();
116
377
  return;
@@ -127,40 +388,70 @@ export class ChangesView {
127
388
  }
128
389
  }
129
390
 
391
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
392
+ if (this.disposed) return { handled: true, render: false };
393
+ if (event.type === "release") {
394
+ if (this.leftPressActive && (event.button === "left" || event.button === "none")) {
395
+ this.leftPressActive = false;
396
+ return { handled: true, render: false };
397
+ }
398
+ return undefined;
399
+ }
400
+ if (event.type === "press" && event.button !== "left") return undefined;
401
+ const layout = this.pointerLayout;
402
+ if (!layout || event.width !== layout.width || event.height !== layout.height) return { handled: true, render: false };
403
+ const inBody = event.y >= 1 && event.y <= layout.bodyRows;
404
+ const inFiles = inBody && event.x >= layout.listStart && event.x < layout.listEnd;
405
+ const inDiff = inBody && event.x >= layout.diffStart && event.x < layout.diffEnd;
406
+ if (event.type === "wheel") {
407
+ if (inFiles) return { handled: true, render: this.scrollFiles(event.wheelDelta ?? 0, layout.bodyRows) };
408
+ if (inDiff) return { handled: true, render: this.scrollDiff(event.wheelDelta ?? 0, layout.bodyRows) };
409
+ return undefined;
410
+ }
411
+ if (!inFiles) return undefined;
412
+ if (event.type === "press") {
413
+ if (event.button !== "left") return undefined;
414
+ this.leftPressActive = true;
415
+ return { handled: true, capture: true, render: false };
416
+ }
417
+ if (event.type !== "click" || event.button !== "left") return undefined;
418
+ const index = this.fileScroll + event.y - 1;
419
+ if (index < 0 || index >= this.model.files.length) return { handled: true, render: false };
420
+ const changed = index !== this.selected;
421
+ this.select(index);
422
+ return { handled: true, render: changed };
423
+ }
424
+
130
425
  render(width: number): string[] {
131
426
  const theme = this.deps.theme;
132
- const inner = width - 2;
133
- const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
134
- const diffWidth = inner - listWidth - 4;
135
- const titleText = `✎ Changes · ${changesSummary(this.model)}`;
136
- const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
137
427
  const rows = this.bodyRows();
138
- const diff = this.visibleDiff(rows);
139
- const body: string[] = [];
140
- for (let row = 0; row < rows; row += 1) {
141
- const left = fit(this.fileLine(row), listWidth);
142
- const right = fit(diff[row] ?? "", diffWidth);
143
- body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
144
- }
145
- const keys = KEYS.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
146
- const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
147
- const bottom = theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`);
148
- return [top, ...body, keysLine, bottom];
428
+ this.fileScroll = this.clampFileScroll(this.fileScroll, rows);
429
+ const keys = KEYS.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, key === "esc" ? (this.deps.backLabel ?? label) : label)}`).join(" ");
430
+ this.pointerLayout = pointerLayout(width, rows);
431
+ return renderPanes(width, rows, theme, `✎ Changes · ${changesSummary(this.model)}`, (row) => this.fileLine(row), this.visibleDiff(rows), keys);
149
432
  }
150
433
 
151
- invalidate(): void {}
434
+ invalidate(): void {
435
+ this.leftPressActive = false;
436
+ this.pointerLayout = undefined;
437
+ }
438
+
439
+ previewLines(): string[] {
440
+ return this.visibleDiff(this.bodyRows());
441
+ }
152
442
 
153
443
  private bodyRows(): number {
154
- return Math.max(MIN_BODY_ROWS, this.deps.rows - CHROME_ROWS);
444
+ const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
445
+ return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
155
446
  }
156
447
 
157
448
  private fileLine(row: number): string {
158
- const file = this.model.files[row];
449
+ const index = this.fileScroll + row;
450
+ const file = this.model.files[index];
159
451
  if (!file) return "";
160
452
  const theme = this.deps.theme;
161
- const marker = row === this.selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
162
- const path = theme.fg(row === this.selected ? ROLE.PATH : ROLE.PATH_IDLE, file.path);
163
- return `${marker} ${path} ${fileCounts(file, theme)}`;
453
+ const marker = index === this.selected ? theme.fg(ROLE.SELECTED, "▸") : " ";
454
+ return `${marker} ${fileLabel(file, theme, index === this.selected ? ROLE.PATH : ROLE.PATH_IDLE)}`;
164
455
  }
165
456
 
166
457
  private visibleDiff(rows: number): string[] {
@@ -169,34 +460,68 @@ export class ChangesView {
169
460
  const lines = this.diffs.get(file.path);
170
461
  if (!lines) return [];
171
462
  if (lines.length === 0) return [this.deps.theme.fg(ROLE.EMPTY, EMPTY_DIFF)];
172
- const maxScroll = Math.max(0, lines.length - rows);
173
- this.scroll = Math.min(this.scroll, maxScroll);
174
- return lines.slice(this.scroll, this.scroll + rows);
463
+ this.diffScroll = Math.min(this.diffScroll, Math.max(0, lines.length - rows));
464
+ return lines.slice(this.diffScroll, this.diffScroll + rows);
175
465
  }
176
466
 
177
467
  private select(index: number): void {
178
468
  const next = Math.max(0, Math.min(this.model.files.length - 1, index));
179
469
  if (next === this.selected) return;
180
470
  this.selected = next;
181
- this.scroll = 0;
471
+ this.diffScroll = 0;
472
+ this.ensureSelectedVisible();
182
473
  this.loadSelected();
183
474
  this.deps.requestRender();
184
475
  }
185
476
 
186
477
  private scrollBy(delta: number): void {
187
- this.scroll = Math.max(0, this.scroll + delta);
478
+ this.scrollDiffBy(delta, this.bodyRows());
188
479
  this.deps.requestRender();
189
480
  }
190
481
 
482
+ scrollDiffBy(delta: number, rows = this.bodyRows()): boolean {
483
+ return this.scrollDiff(delta, rows);
484
+ }
485
+
486
+ private scrollFiles(delta: number, rows: number): boolean {
487
+ const next = this.clampFileScroll(this.fileScroll + Math.trunc(delta), rows);
488
+ if (next === this.fileScroll) return false;
489
+ this.fileScroll = next;
490
+ return true;
491
+ }
492
+
493
+ private scrollDiff(delta: number, rows: number): boolean {
494
+ const file = this.model.files[this.selected];
495
+ const lines = file ? this.diffs.get(file.path) : undefined;
496
+ if (!lines) return false;
497
+ const next = Math.max(0, Math.min(Math.max(0, lines.length - rows), this.diffScroll + Math.trunc(delta)));
498
+ if (next === this.diffScroll) return false;
499
+ this.diffScroll = next;
500
+ return true;
501
+ }
502
+
503
+ private clampFileScroll(scroll: number, rows: number): number {
504
+ return Math.max(0, Math.min(Math.max(0, this.model.files.length - rows), scroll));
505
+ }
506
+
507
+ private ensureSelectedVisible(): void {
508
+ const rows = this.bodyRows();
509
+ if (this.selected < this.fileScroll) this.fileScroll = this.selected;
510
+ else if (this.selected >= this.fileScroll + rows) this.fileScroll = this.selected - rows + 1;
511
+ this.fileScroll = this.clampFileScroll(this.fileScroll, rows);
512
+ }
513
+
191
514
  private loadSelected(): void {
192
515
  const file = this.model.files[this.selected];
193
516
  if (!file || this.diffs.has(file.path)) return;
194
517
  void this.deps.loadDiff(file).then(
195
518
  (text) => {
519
+ if (this.disposed) return;
196
520
  this.diffs.set(file.path, colorDiff(text, this.deps.theme));
197
521
  this.deps.requestRender();
198
522
  },
199
523
  () => {
524
+ if (this.disposed) return;
200
525
  this.diffs.set(file.path, []);
201
526
  this.deps.requestRender();
202
527
  },
@@ -143,6 +143,86 @@ export interface GitResult {
143
143
  export type GitRunner = (args: string[]) => Promise<GitResult>;
144
144
  export type LineCounter = (path: string) => Promise<number>;
145
145
 
146
+ export interface WorktreeChanges {
147
+ root: string;
148
+ branch?: string;
149
+ model: ChangesModel;
150
+ }
151
+
152
+ // -z avoids Git's quoting of paths containing whitespace or newlines.
153
+ export function parseWorktrees(text: string): Array<{ root: string; branch?: string }> {
154
+ return text.split("\0\0").flatMap((record) => {
155
+ const fields = record.split("\0");
156
+ const root = fields.find((field) => field.startsWith("worktree "))?.slice(9);
157
+ if (!root || fields.some((field) => field === "bare" || field.startsWith("prunable"))) return [];
158
+ const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
159
+ return [branch ? { root, branch } : { root }];
160
+ });
161
+ }
162
+
163
+ export class WorktreeChangesTracker {
164
+ worktrees: WorktreeChanges[] = [];
165
+ private inFlight: Promise<ChangesModel> | undefined;
166
+ private readonly discover: GitRunner;
167
+ private readonly gitForRoot: (root: string) => GitRunner;
168
+ private readonly linesForRoot: (root: string) => LineCounter;
169
+ private readonly registeredRoots: () => readonly string[];
170
+
171
+ constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => []) {
172
+ this.discover = discover;
173
+ this.gitForRoot = gitForRoot;
174
+ this.linesForRoot = linesForRoot;
175
+ this.registeredRoots = registeredRoots;
176
+ }
177
+
178
+ get model(): ChangesModel {
179
+ return changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
180
+ ...file,
181
+ path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
182
+ }))));
183
+ }
184
+
185
+ async start(): Promise<void> {
186
+ await this.refresh();
187
+ }
188
+
189
+ async refresh(): Promise<ChangesModel> {
190
+ // A scan can take longer than the polling interval in a large clone.
191
+ // Share it rather than extending it indefinitely with queued polls.
192
+ if (this.inFlight) return this.inFlight;
193
+ this.inFlight = this.capture();
194
+ try {
195
+ return await this.inFlight;
196
+ } finally {
197
+ this.inFlight = undefined;
198
+ }
199
+ }
200
+
201
+ private async capture(): Promise<ChangesModel> {
202
+ const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
203
+ const trees: WorktreeChanges[] = [];
204
+ const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
205
+ // Discovery labels registered roots; it never grants visibility to siblings.
206
+ const roots = new Set(this.registeredRoots());
207
+ for (const root of roots) {
208
+ const tree = metadata.get(root) ?? { root };
209
+ try {
210
+ const tracker = new ChangesTracker(this.gitForRoot(tree.root), this.linesForRoot(tree.root));
211
+ await tracker.start();
212
+ if (tracker.model.files.length) trees.push({ ...tree, model: tracker.model });
213
+ } catch {
214
+ // Linked roots can disappear between discovery and status.
215
+ }
216
+ }
217
+ const latest = new Set(this.registeredRoots());
218
+ // A root admitted during status must not wait for a later poll (which may
219
+ // be disabled). Ordinary overlapping polls still share exactly one scan.
220
+ if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
221
+ this.worktrees = trees;
222
+ return this.model;
223
+ }
224
+ }
225
+
146
226
  const noLines: LineCounter = async () => 0;
147
227
 
148
228
  const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
@@ -199,7 +279,7 @@ export class ChangesTracker {
199
279
 
200
280
  private async capture(): Promise<ChangedFile[] | undefined> {
201
281
  const [numstat, porcelain] = await Promise.all([this.git(NUMSTAT_ARGS), this.git(PORCELAIN_ARGS)]);
202
- if (numstat.code !== 0 || porcelain.code !== 0) return undefined;
282
+ if (porcelain.code !== 0) return undefined;
203
283
  const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
204
284
  // Untracked files never appear in numstat; count their lines directly.
205
285
  for (const file of files) {
@@ -1,4 +1,4 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { stripAnsi } from "./terminal-theme.ts";
3
3
 
4
4
  // Gentle Shell prompt frame. pi's editor renders a top rule, padded content
@@ -33,8 +33,6 @@ export interface PromptFrameOptions {
33
33
  borderColor: (text: string) => string;
34
34
  fg: (color: string, text: string) => string;
35
35
  bold?: (text: string) => string;
36
- /** Paints a finished line, e.g. with the panel background. */
37
- paint?: (line: string) => string;
38
36
  }
39
37
 
40
38
  // A terminal cell cannot grow, so the petal earns presence with weight and
@@ -78,28 +76,33 @@ function topRule(width: number, options: PromptFrameOptions, indicator: string |
78
76
  const labelText = label ? ` ${options.fg(LABEL_ROLE, label)}` : "";
79
77
  const labelWidth = label ? label.length + 1 : 0;
80
78
  const fill = width - 3 - visibleWidth(glyph) - labelWidth - 1 - 1;
79
+ if (fill < 0) return options.borderColor(`╭${rule(width - 2)}╮`);
81
80
  return options.borderColor("╭─ ") + petal + labelText + options.borderColor(` ${rule(fill)}╮`);
82
81
  }
83
82
 
84
83
  function bottomRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
85
84
  if (!indicator) return options.borderColor(`╰${rule(width - 2)}╯`);
86
85
  const fill = width - 3 - indicator.length - 1 - 1;
86
+ if (fill < 0) return options.borderColor(`╰${rule(width - 2)}╯`);
87
87
  return options.borderColor("╰─ ") + options.fg(LABEL_ROLE, indicator) + options.borderColor(` ${rule(fill)}╯`);
88
88
  }
89
89
 
90
90
  function sideRules(line: string, innerWidth: number, options: PromptFrameOptions): string {
91
- const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
92
- return options.borderColor("│") + line + padding + options.borderColor("│");
91
+ const clipped = innerWidth === 0 ? "" : truncateToWidth(line, innerWidth, "");
92
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
93
+ const content = clipped + padding;
94
+ return options.borderColor("│") + content + options.borderColor("│");
93
95
  }
94
96
 
95
97
  export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions): string[] {
96
- if (lines.length < 2) return lines;
98
+ width = Math.max(0, Math.floor(width));
99
+ if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
100
+ if (width < 2) return lines.map((_line, index) => width === 0 ? "" : options.borderColor(index === 0 ? "╭" : index === lines.length - 1 ? "╰" : "│"));
97
101
  const innerWidth = width - 2;
98
102
  const top = lines[0];
99
103
  const bottom = lines[lines.length - 1];
100
104
  const content = lines.slice(1, -1).map((line) => sideRules(line, innerWidth, options));
101
- const paint = options.paint ?? ((line: string) => line);
102
- return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))].map(paint);
105
+ return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))];
103
106
  }
104
107
 
105
108
  export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
@@ -110,10 +113,3 @@ export function withPromptHint(line: string, hint: string, fg: PromptFrameOption
110
113
  if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
111
114
  return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
112
115
  }
113
-
114
- // Paints a line with a background that survives the resets pi's editor
115
- // emits around the cursor, so the panel color runs edge to edge.
116
- export function panelPainter(bgAnsi: string): (line: string) => string {
117
- const RESET = "\x1b[0m";
118
- return (line) => `${bgAnsi}${line.split(RESET).join(`${RESET}${bgAnsi}`)}\x1b[49m`;
119
- }