gentle-pi 2.4.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -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 +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,133 @@
1
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ // Gentle Shell cards: the shape every Gentle notice takes in the transcript
4
+ // and above the editor. The same rounded frame as the prompt and the
5
+ // overlays, with the title in the card's tone. Pure: strings in, lines out.
6
+
7
+ export const CARD_TONE = {
8
+ INFO: "info",
9
+ SUCCESS: "success",
10
+ WARNING: "warning",
11
+ ERROR: "error",
12
+ } as const;
13
+
14
+ export type CardTone = (typeof CARD_TONE)[keyof typeof CARD_TONE];
15
+
16
+ export interface Card {
17
+ title: string;
18
+ subtitle?: string;
19
+ body: string[];
20
+ tone: CardTone;
21
+ glyph?: string;
22
+ }
23
+
24
+ export interface CardTheme {
25
+ fg(color: string, text: string): string;
26
+ }
27
+
28
+ export interface CardRenderOptions {
29
+ expanded: boolean;
30
+ /** Right-aligned hint in the top rule, e.g. the expand key. May carry ANSI. */
31
+ hint?: string;
32
+ }
33
+
34
+ export const CARD_GLYPH = "✿";
35
+ const TONE_ROLE: Record<CardTone, string> = {
36
+ [CARD_TONE.INFO]: "customMessageLabel",
37
+ [CARD_TONE.SUCCESS]: "success",
38
+ [CARD_TONE.WARNING]: "warning",
39
+ [CARD_TONE.ERROR]: "error",
40
+ };
41
+ const HINT_ROLE = "dim";
42
+ const SUBTITLE_ROLE = "muted";
43
+ const BODY_ROLE = "text";
44
+ const SEPARATOR = "·";
45
+ const FRAME_COLUMNS = 4;
46
+
47
+ function rule(length: number): string {
48
+ return "─".repeat(Math.max(0, length));
49
+ }
50
+
51
+ function titleText(card: Card, theme: CardTheme): { styled: string; width: number } {
52
+ const head = `${card.glyph ?? CARD_GLYPH} ${card.title}`;
53
+ const styled = card.subtitle
54
+ ? `${theme.fg(TONE_ROLE[card.tone], head)} ${theme.fg(SUBTITLE_ROLE, SEPARATOR)} ${theme.fg(SUBTITLE_ROLE, card.subtitle)}`
55
+ : theme.fg(TONE_ROLE[card.tone], head);
56
+ return { styled, width: visibleWidth(head) + (card.subtitle ? visibleWidth(card.subtitle) + 3 : 0) };
57
+ }
58
+
59
+ function bodyLines(card: Card, innerWidth: number): string[] {
60
+ return card.body.flatMap((paragraph) => (paragraph === "" ? [""] : wrapTextWithAnsi(paragraph, innerWidth)));
61
+ }
62
+
63
+ // The left rail, corners included, carries the tone at full strength; the
64
+ // rest of the frame stays in the theme's border color, so the state reads from the rail.
65
+ const FRAME_ROLE = "border";
66
+
67
+ function soft(theme: CardTheme, _tone: CardTone, text: string): string {
68
+ return theme.fg(FRAME_ROLE, text);
69
+ }
70
+
71
+ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: string): string {
72
+ const targetWidth = Math.max(0, Math.floor(width));
73
+ if (targetWidth === 0) return "";
74
+ if (targetWidth < 5) {
75
+ const left = theme.fg(TONE_ROLE[card.tone], "╭");
76
+ if (targetWidth === 1) return left;
77
+ return left + soft(theme, card.tone, `${rule(targetWidth - 2)}╮`);
78
+ }
79
+
80
+ const title = titleText(card, theme);
81
+ const fullHintWidth = hint ? visibleWidth(hint) + 2 : 0;
82
+ const shownHint = hint && title.width + 5 + fullHintWidth <= targetWidth ? hint : undefined;
83
+ const hintWidth = shownHint ? fullHintWidth : 0;
84
+ const titleWidth = Math.max(0, targetWidth - 5 - hintWidth);
85
+ const styledTitle = title.width <= titleWidth ? title.styled : truncateToWidth(title.styled, titleWidth, "");
86
+ const styledTitleWidth = title.width <= titleWidth ? title.width : visibleWidth(styledTitle);
87
+ const fill = rule(targetWidth - styledTitleWidth - 5 - hintWidth);
88
+ const tail = shownHint ? ` ${theme.fg(HINT_ROLE, shownHint)} ` : "";
89
+ return theme.fg(TONE_ROLE[card.tone], "╭") + soft(theme, card.tone, "─ ") + styledTitle + soft(theme, card.tone, ` ${fill}`) + tail + soft(theme, card.tone, "╮");
90
+ }
91
+
92
+ export function cardLine(text: string, tone: CardTone, theme: CardTheme, width: number): string {
93
+ const targetWidth = Math.max(0, Math.floor(width));
94
+ if (targetWidth === 0) return "";
95
+ const left = theme.fg(TONE_ROLE[tone], "│");
96
+ if (targetWidth === 1) return left;
97
+ if (targetWidth === 2) return left + soft(theme, tone, "│");
98
+ if (targetWidth === 3) return `${left} ${soft(theme, tone, "│")}`;
99
+
100
+ const innerWidth = targetWidth - FRAME_COLUMNS;
101
+ const clipped = innerWidth === 0 ? "" : truncateToWidth(text, innerWidth, "…");
102
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
103
+ return `${left} ${clipped}${padding} ${soft(theme, tone, "│")}`;
104
+ }
105
+
106
+ export function cardBottom(tone: CardTone, theme: CardTheme, width: number): string {
107
+ const targetWidth = Math.max(0, Math.floor(width));
108
+ if (targetWidth === 0) return "";
109
+ const left = theme.fg(TONE_ROLE[tone], "╰");
110
+ if (targetWidth === 1) return left;
111
+ return left + soft(theme, tone, `${rule(targetWidth - 2)}╯`);
112
+ }
113
+
114
+ export function cardInnerWidth(width: number): number {
115
+ return Math.max(1, width - FRAME_COLUMNS);
116
+ }
117
+
118
+ export function renderCard(card: Card, theme: CardTheme, width: number, options: CardRenderOptions): string[] {
119
+ const innerWidth = Math.max(1, width - FRAME_COLUMNS);
120
+ const top = cardTop(card, theme, width, options.hint);
121
+ const bottom = cardBottom(card.tone, theme, width);
122
+ const lines = bodyLines(card, innerWidth);
123
+ const body = (() => {
124
+ if (lines.length === 0) return [];
125
+ if (!options.expanded) {
126
+ const first = lines.find((line) => line !== "") ?? "";
127
+ const clipped = lines.length > 1 ? truncateToWidth(first, Math.max(1, innerWidth - 1), "") + "…" : first;
128
+ return [cardLine(theme.fg(BODY_ROLE, clipped), card.tone, theme, width)];
129
+ }
130
+ return lines.map((line) => cardLine(line === "" ? "" : theme.fg(BODY_ROLE, line), card.tone, theme, width));
131
+ })();
132
+ return [top, ...body, bottom];
133
+ }
@@ -0,0 +1,530 @@
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";
5
+
6
+ // Gentle Shell changes overlay: a framed two-pane view with the working
7
+ // tree's changed files on the left and the selected file's diff on the right.
8
+ // Git access is injected so the component renders without a repository.
9
+
10
+ export interface ChangesViewTheme {
11
+ fg(color: string, text: string): string;
12
+ }
13
+
14
+ export interface ChangesViewDeps {
15
+ theme: ChangesViewTheme;
16
+ rows: number | (() => number);
17
+ loadDiff(file: ChangedFile): Promise<string>;
18
+ onOpen(file: ChangedFile): void;
19
+ onClose(): void;
20
+ requestRender(): void;
21
+ backLabel?: string;
22
+ }
23
+
24
+ const ROLE = {
25
+ FRAME: "border",
26
+ TITLE: "customMessageLabel",
27
+ SELECTED: "accent",
28
+ PATH: "text",
29
+ PATH_IDLE: "muted",
30
+ ADDED: "success",
31
+ REMOVED: "error",
32
+ HUNK: "customMessageLabel",
33
+ KEY: "accent",
34
+ KEY_TEXT: "dim",
35
+ EMPTY: "dim",
36
+ } as const;
37
+
38
+ const HEADER_PREFIXES = ["diff --git", "index ", "--- ", "+++ ", "new file mode", "deleted file mode", "similarity index", "rename from", "rename to", "Binary files"];
39
+ const LIST_MAX_WIDTH = 36;
40
+ const LIST_RATIO = 0.35;
41
+ const CHROME_ROWS = 3;
42
+ const MIN_BODY_ROWS = 1;
43
+ const EMPTY_DIFF = "no diff for this file";
44
+ const CLEAN_TREE = "working tree is clean";
45
+ const KEYS = [
46
+ ["j/k", "file"],
47
+ ["ctrl+j/k", "scroll"],
48
+ ["o", "open in editor"],
49
+ ["esc", "close"],
50
+ ] as const;
51
+
52
+ function rule(length: number): string {
53
+ return "─".repeat(Math.max(0, length));
54
+ }
55
+
56
+ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
57
+ const lines: string[] = [];
58
+ for (const line of text.split("\n")) {
59
+ if (line === "" || HEADER_PREFIXES.some((prefix) => line.startsWith(prefix))) continue;
60
+ if (line.startsWith("@@")) lines.push(theme.fg(ROLE.HUNK, line));
61
+ else if (line.startsWith("+")) lines.push(theme.fg("toolDiffAdded", line));
62
+ else if (line.startsWith("-")) lines.push(theme.fg("toolDiffRemoved", line));
63
+ else lines.push(theme.fg("toolDiffContext", line));
64
+ }
65
+ return lines;
66
+ }
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
+
76
+ function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
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)}`;
82
+ }
83
+
84
+ function fit(text: string, width: number): string {
85
+ const clipped = truncateToWidth(text, width, "…");
86
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
87
+ }
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
+
128
+ function fingerprint(file: ChangedFile): string {
129
+ return `${file.status}:${file.added}:${file.deleted}`;
130
+ }
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
+
326
+ export class ChangesView {
327
+ private model: ChangesModel;
328
+ private readonly deps: ChangesViewDeps;
329
+ private selected = 0;
330
+ private fileScroll = 0;
331
+ private diffScroll = 0;
332
+ private pointerLayout: PointerLayout | undefined;
333
+ private disposed = false;
334
+ private leftPressActive = false;
335
+ private readonly diffs = new Map<string, string[]>();
336
+
337
+ constructor(model: ChangesModel, deps: ChangesViewDeps) {
338
+ this.model = model;
339
+ this.deps = deps;
340
+ this.loadSelected();
341
+ }
342
+
343
+ // Replace the model while open: keep the selection by path and drop cached
344
+ // diffs for files whose counts moved so they reload.
345
+ update(model: ChangesModel): void {
346
+ this.leftPressActive = false;
347
+ if (this.disposed) return;
348
+ this.pointerLayout = undefined;
349
+ const selectedPath = this.model.files[this.selected]?.path;
350
+ const before = new Map(this.model.files.map((file) => [file.path, fingerprint(file)]));
351
+ for (const file of model.files) {
352
+ if (before.get(file.path) !== fingerprint(file)) this.diffs.delete(file.path);
353
+ }
354
+ for (const path of this.diffs.keys()) {
355
+ if (!model.files.some((file) => file.path === path)) this.diffs.delete(path);
356
+ }
357
+ this.model = model;
358
+ const index = model.files.findIndex((file) => file.path === selectedPath);
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();
363
+ this.loadSelected();
364
+ this.deps.requestRender();
365
+ }
366
+
367
+ dispose(): void {
368
+ this.disposed = true;
369
+ this.leftPressActive = false;
370
+ this.pointerLayout = undefined;
371
+ }
372
+
373
+ handleInput(data: string): void {
374
+ if (this.disposed) return;
375
+ if (matchesKey(data, Key.escape) || data === "q") {
376
+ this.deps.onClose();
377
+ return;
378
+ }
379
+ // ctrl+j arrives as a bare line feed, which pi also reads as enter, so
380
+ // the scroll keys are checked before the open key; a real Enter is CR.
381
+ if (data === "j" || matchesKey(data, Key.down)) this.select(this.selected + 1);
382
+ else if (data === "k" || matchesKey(data, Key.up)) this.select(this.selected - 1);
383
+ else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.bodyRows());
384
+ else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.bodyRows());
385
+ else if (data === "o" || matchesKey(data, Key.enter)) {
386
+ const file = this.model.files[this.selected];
387
+ if (file) this.deps.onOpen(file);
388
+ }
389
+ }
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
+
425
+ render(width: number): string[] {
426
+ const theme = this.deps.theme;
427
+ const rows = this.bodyRows();
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);
432
+ }
433
+
434
+ invalidate(): void {
435
+ this.leftPressActive = false;
436
+ this.pointerLayout = undefined;
437
+ }
438
+
439
+ previewLines(): string[] {
440
+ return this.visibleDiff(this.bodyRows());
441
+ }
442
+
443
+ private bodyRows(): number {
444
+ const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
445
+ return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
446
+ }
447
+
448
+ private fileLine(row: number): string {
449
+ const index = this.fileScroll + row;
450
+ const file = this.model.files[index];
451
+ if (!file) return "";
452
+ const theme = this.deps.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)}`;
455
+ }
456
+
457
+ private visibleDiff(rows: number): string[] {
458
+ const file = this.model.files[this.selected];
459
+ if (!file) return [this.deps.theme.fg(ROLE.EMPTY, CLEAN_TREE)];
460
+ const lines = this.diffs.get(file.path);
461
+ if (!lines) return [];
462
+ if (lines.length === 0) return [this.deps.theme.fg(ROLE.EMPTY, EMPTY_DIFF)];
463
+ this.diffScroll = Math.min(this.diffScroll, Math.max(0, lines.length - rows));
464
+ return lines.slice(this.diffScroll, this.diffScroll + rows);
465
+ }
466
+
467
+ private select(index: number): void {
468
+ const next = Math.max(0, Math.min(this.model.files.length - 1, index));
469
+ if (next === this.selected) return;
470
+ this.selected = next;
471
+ this.diffScroll = 0;
472
+ this.ensureSelectedVisible();
473
+ this.loadSelected();
474
+ this.deps.requestRender();
475
+ }
476
+
477
+ private scrollBy(delta: number): void {
478
+ this.scrollDiffBy(delta, this.bodyRows());
479
+ this.deps.requestRender();
480
+ }
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
+
514
+ private loadSelected(): void {
515
+ const file = this.model.files[this.selected];
516
+ if (!file || this.diffs.has(file.path)) return;
517
+ void this.deps.loadDiff(file).then(
518
+ (text) => {
519
+ if (this.disposed) return;
520
+ this.diffs.set(file.path, colorDiff(text, this.deps.theme));
521
+ this.deps.requestRender();
522
+ },
523
+ () => {
524
+ if (this.disposed) return;
525
+ this.diffs.set(file.path, []);
526
+ this.deps.requestRender();
527
+ },
528
+ );
529
+ }
530
+ }