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,290 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ // Gentle Shell changes: the working tree against HEAD, new files included.
4
+ // Git is the source of truth; this module turns raw `git diff --numstat` and
5
+ // `git status --porcelain -z` output into a model and renders the widget.
6
+
7
+ export const CHANGE_STATUS = {
8
+ MODIFIED: "modified",
9
+ ADDED: "added",
10
+ DELETED: "deleted",
11
+ RENAMED: "renamed",
12
+ UNTRACKED: "untracked",
13
+ } as const;
14
+
15
+ export type ChangeStatus = (typeof CHANGE_STATUS)[keyof typeof CHANGE_STATUS];
16
+
17
+ export interface ChangedFile {
18
+ path: string;
19
+ added: number;
20
+ deleted: number;
21
+ status: ChangeStatus;
22
+ }
23
+
24
+ export interface ChangesModel {
25
+ files: ChangedFile[];
26
+ added: number;
27
+ deleted: number;
28
+ }
29
+
30
+ export interface ChangesSnapshot {
31
+ numstat: string;
32
+ porcelain: string;
33
+ }
34
+
35
+ export interface ChangesTheme {
36
+ fg(color: string, text: string): string;
37
+ }
38
+
39
+ interface NumstatEntry {
40
+ path: string;
41
+ added: number;
42
+ deleted: number;
43
+ }
44
+
45
+ export const CHANGES_COMMAND = "/gentle:changes";
46
+ const WIDGET_GLYPH = "✎";
47
+ const STATUS_BY_CODE: Record<string, ChangeStatus> = {
48
+ A: CHANGE_STATUS.ADDED,
49
+ D: CHANGE_STATUS.DELETED,
50
+ R: CHANGE_STATUS.RENAMED,
51
+ C: CHANGE_STATUS.ADDED,
52
+ "?": CHANGE_STATUS.UNTRACKED,
53
+ };
54
+ const RENAME_BRACES = /\{([^{}]*) => ([^{}]*)\}/g;
55
+ const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
56
+ const RENAME_ARROW = " => ";
57
+
58
+ export function emptyChanges(): ChangesModel {
59
+ return { files: [], added: 0, deleted: 0 };
60
+ }
61
+
62
+ function renamedPath(path: string): string {
63
+ if (HAS_RENAME_BRACES.test(path)) return path.replace(RENAME_BRACES, "$2").replace(/\/\//g, "/");
64
+ const arrow = path.indexOf(RENAME_ARROW);
65
+ return arrow === -1 ? path : path.slice(arrow + RENAME_ARROW.length);
66
+ }
67
+
68
+ function count(value: string): number {
69
+ const parsed = Number.parseInt(value, 10);
70
+ return Number.isFinite(parsed) ? parsed : 0;
71
+ }
72
+
73
+ export function parseNumstat(text: string): NumstatEntry[] {
74
+ const entries: NumstatEntry[] = [];
75
+ for (const line of text.split("\n")) {
76
+ const [added, deleted, ...rest] = line.split("\t");
77
+ if (added === undefined || deleted === undefined || rest.length === 0) continue;
78
+ entries.push({ path: renamedPath(rest.join("\t")), added: count(added), deleted: count(deleted) });
79
+ }
80
+ return entries;
81
+ }
82
+
83
+ export function parsePorcelain(text: string): Map<string, ChangeStatus> {
84
+ const statuses = new Map<string, ChangeStatus>();
85
+ const records = text.split("\0").filter((record) => record.length > 0);
86
+ for (let index = 0; index < records.length; index += 1) {
87
+ const record = records[index];
88
+ const code = record.slice(0, 2);
89
+ const path = record.slice(3);
90
+ const status = STATUS_BY_CODE[code[0]] ?? STATUS_BY_CODE[code[1]] ?? CHANGE_STATUS.MODIFIED;
91
+ statuses.set(path, status);
92
+ if (code[0] === "R" || code[0] === "C") index += 1;
93
+ }
94
+ return statuses;
95
+ }
96
+
97
+ export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
98
+ const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
99
+ const files: ChangedFile[] = [];
100
+ for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
101
+ const entry = counts.get(path);
102
+ files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
103
+ }
104
+ return files;
105
+ }
106
+
107
+ export function changesModel(changed: ChangedFile[]): ChangesModel {
108
+ const files = [...changed].sort((a, b) => a.path.localeCompare(b.path));
109
+ return {
110
+ files,
111
+ added: files.reduce((total, file) => total + file.added, 0),
112
+ deleted: files.reduce((total, file) => total + file.deleted, 0),
113
+ };
114
+ }
115
+
116
+ export function changesSummary(model: ChangesModel): string {
117
+ const noun = model.files.length === 1 ? "file" : "files";
118
+ return `${model.files.length} ${noun} · +${model.added} −${model.deleted}`;
119
+ }
120
+
121
+ // One line: summary, the files joined by dots, and the command pushed to
122
+ // the right edge. The file list goes first when the terminal is narrow.
123
+ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
124
+ if (model.files.length === 0) return [];
125
+ const noun = model.files.length === 1 ? "file" : "files";
126
+ const dot = theme.fg("muted", "·");
127
+ const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`;
128
+ const hint = theme.fg("dim", CHANGES_COMMAND);
129
+ const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
130
+ const left = `${head} ${dot} ${list}`;
131
+ const gap = width - visibleWidth(left) - visibleWidth(hint);
132
+ if (gap >= 2) return [`${left}${" ".repeat(gap)}${hint}`];
133
+ const headGap = width - visibleWidth(head) - visibleWidth(hint);
134
+ if (headGap >= 2) return [`${head}${" ".repeat(headGap)}${hint}`];
135
+ return [truncateToWidth(head, width, "…")];
136
+ }
137
+
138
+ export interface GitResult {
139
+ stdout: string;
140
+ code: number;
141
+ }
142
+
143
+ export type GitRunner = (args: string[]) => Promise<GitResult>;
144
+ export type LineCounter = (path: string) => Promise<number>;
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
+
226
+ const noLines: LineCounter = async () => 0;
227
+
228
+ const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
229
+ const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
230
+
231
+ // Tracks working-tree changes. Concurrent refreshes coalesce: one git
232
+ // round-trip runs at a time and a refresh requested meanwhile triggers
233
+ // exactly one more.
234
+ export class ChangesTracker {
235
+ private readonly git: GitRunner;
236
+ private readonly countLines: LineCounter;
237
+ private current: ChangesModel = emptyChanges();
238
+ private available = false;
239
+ private inFlight: Promise<ChangesModel> | undefined;
240
+ private queued = false;
241
+
242
+ constructor(git: GitRunner, countLines: LineCounter = noLines) {
243
+ this.git = git;
244
+ this.countLines = countLines;
245
+ }
246
+
247
+ get model(): ChangesModel {
248
+ return this.current;
249
+ }
250
+
251
+ async start(): Promise<void> {
252
+ const files = await this.capture();
253
+ this.available = files !== undefined;
254
+ this.current = files ? changesModel(files) : emptyChanges();
255
+ }
256
+
257
+ async refresh(): Promise<ChangesModel> {
258
+ if (!this.available) return this.current;
259
+ if (this.inFlight) {
260
+ this.queued = true;
261
+ return this.inFlight;
262
+ }
263
+ this.inFlight = this.runRefresh();
264
+ try {
265
+ return await this.inFlight;
266
+ } finally {
267
+ this.inFlight = undefined;
268
+ }
269
+ }
270
+
271
+ private async runRefresh(): Promise<ChangesModel> {
272
+ do {
273
+ this.queued = false;
274
+ const files = await this.capture();
275
+ if (files) this.current = changesModel(files);
276
+ } while (this.queued);
277
+ return this.current;
278
+ }
279
+
280
+ private async capture(): Promise<ChangedFile[] | undefined> {
281
+ const [numstat, porcelain] = await Promise.all([this.git(NUMSTAT_ARGS), this.git(PORCELAIN_ARGS)]);
282
+ if (porcelain.code !== 0) return undefined;
283
+ const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
284
+ // Untracked files never appear in numstat; count their lines directly.
285
+ for (const file of files) {
286
+ if (file.status === CHANGE_STATUS.UNTRACKED) file.added = await this.countLines(file.path).catch(() => 0);
287
+ }
288
+ return files;
289
+ }
290
+ }
@@ -0,0 +1,40 @@
1
+ // Shared gauge primitives for the Gentle Shell bar and panels.
2
+
3
+ const GAUGE_TONE = {
4
+ ACCENT: "accent",
5
+ WARNING: "warning",
6
+ ERROR: "error",
7
+ DIM: "dim",
8
+ } as const;
9
+
10
+ export type GaugeTone = (typeof GAUGE_TONE)[keyof typeof GAUGE_TONE];
11
+
12
+ export interface GaugeTheme {
13
+ fg(color: string, text: string): string;
14
+ }
15
+
16
+ export const GAUGE_CELLS = 8;
17
+ const GAUGE_FILLED = "▰";
18
+ const GAUGE_EMPTY = "▱";
19
+ const GAUGE_EMPTY_ROLE = "border";
20
+ const WARNING_THRESHOLD = 80;
21
+ const ERROR_THRESHOLD = 95;
22
+
23
+ export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS): string {
24
+ const clamped = Math.max(0, Math.min(100, percent ?? 0));
25
+ const filled = Math.round((clamped / 100) * cells);
26
+ return GAUGE_FILLED.repeat(filled) + GAUGE_EMPTY.repeat(cells - filled);
27
+ }
28
+
29
+ export function gaugeTone(percent: number | null): GaugeTone {
30
+ if (percent === null) return GAUGE_TONE.DIM;
31
+ if (percent >= ERROR_THRESHOLD) return GAUGE_TONE.ERROR;
32
+ if (percent >= WARNING_THRESHOLD) return GAUGE_TONE.WARNING;
33
+ return GAUGE_TONE.ACCENT;
34
+ }
35
+
36
+ export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
37
+ const gauge = renderGauge(percent, cells);
38
+ const filled = gauge.replace(new RegExp(`${GAUGE_EMPTY}+$`), "");
39
+ return theme.fg(gaugeTone(percent), filled) + theme.fg(GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
40
+ }
@@ -0,0 +1,115 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { stripAnsi } from "./terminal-theme.ts";
3
+
4
+ // Gentle Shell prompt frame. pi's editor renders a top rule, padded content
5
+ // lines, and a bottom rule; this module wraps those lines in a rounded frame
6
+ // with a petal that shows what the agent is doing. Everything here is pure.
7
+
8
+ export const PROMPT_STATE = {
9
+ IDLE: "idle",
10
+ WORKING: "working",
11
+ QUEUED: "queued",
12
+ } as const;
13
+
14
+ export type PromptState = (typeof PROMPT_STATE)[keyof typeof PROMPT_STATE];
15
+
16
+ const PETAL_TONE = {
17
+ BRIGHT: "borderAccent",
18
+ ROSE: "accent",
19
+ SOFT: "thinkingHigh",
20
+ DEEP: "mdQuoteBorder",
21
+ WARNING: "warning",
22
+ } as const;
23
+
24
+ export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
25
+
26
+ // The Gentle themes map these to the rose ramp: active pink, rose, soft
27
+ // rose, deep pink. The spin walks the ramp one shade per frame.
28
+ const PETAL_TONE_FRAMES = [PETAL_TONE.BRIGHT, PETAL_TONE.ROSE, PETAL_TONE.SOFT, PETAL_TONE.DEEP] as const;
29
+
30
+ export interface PromptFrameOptions {
31
+ state: PromptState;
32
+ tick: number;
33
+ borderColor: (text: string) => string;
34
+ fg: (color: string, text: string) => string;
35
+ bold?: (text: string) => string;
36
+ }
37
+
38
+ // A terminal cell cannot grow, so the petal earns presence with weight and
39
+ // the brightest rose in the theme. Working spins through four flowers.
40
+ export const PROMPT_PETAL = "✿";
41
+ const PETAL_FRAMES = ["✿", "❀", "❁", "✾"] as const;
42
+ export const PROMPT_HINT = "type, or / for commands";
43
+ const LABEL_ROLE = "muted";
44
+ const HINT_ROLE = "dim";
45
+ const FAKE_CURSOR = "\x1b[7m \x1b[0m";
46
+ const SCROLL_INDICATOR = /[↑↓] \d+ more/;
47
+ const STATE_LABEL: Record<PromptState, string | undefined> = {
48
+ [PROMPT_STATE.IDLE]: undefined,
49
+ [PROMPT_STATE.WORKING]: "working",
50
+ [PROMPT_STATE.QUEUED]: "queued",
51
+ };
52
+
53
+ export function petalTone(state: PromptState, tick: number): PetalTone {
54
+ if (state === PROMPT_STATE.QUEUED) return PETAL_TONE.WARNING;
55
+ if (state === PROMPT_STATE.WORKING) return PETAL_TONE_FRAMES[tick % PETAL_TONE_FRAMES.length];
56
+ return PETAL_TONE.BRIGHT;
57
+ }
58
+
59
+ export function petalGlyph(state: PromptState, tick: number): string {
60
+ if (state === PROMPT_STATE.IDLE) return PROMPT_PETAL;
61
+ return PETAL_FRAMES[tick % PETAL_FRAMES.length];
62
+ }
63
+
64
+ function scrollIndicator(rule: string): string | undefined {
65
+ return stripAnsi(rule).match(SCROLL_INDICATOR)?.[0];
66
+ }
67
+
68
+ function rule(length: number): string {
69
+ return "─".repeat(Math.max(0, length));
70
+ }
71
+
72
+ function topRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
73
+ const label = indicator ?? STATE_LABEL[options.state];
74
+ const glyph = petalGlyph(options.state, options.tick);
75
+ const petal = options.fg(petalTone(options.state, options.tick), options.bold ? options.bold(glyph) : glyph);
76
+ const labelText = label ? ` ${options.fg(LABEL_ROLE, label)}` : "";
77
+ const labelWidth = label ? label.length + 1 : 0;
78
+ const fill = width - 3 - visibleWidth(glyph) - labelWidth - 1 - 1;
79
+ if (fill < 0) return options.borderColor(`╭${rule(width - 2)}╮`);
80
+ return options.borderColor("╭─ ") + petal + labelText + options.borderColor(` ${rule(fill)}╮`);
81
+ }
82
+
83
+ function bottomRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
84
+ if (!indicator) return options.borderColor(`╰${rule(width - 2)}╯`);
85
+ const fill = width - 3 - indicator.length - 1 - 1;
86
+ if (fill < 0) return options.borderColor(`╰${rule(width - 2)}╯`);
87
+ return options.borderColor("╰─ ") + options.fg(LABEL_ROLE, indicator) + options.borderColor(` ${rule(fill)}╯`);
88
+ }
89
+
90
+ function sideRules(line: string, innerWidth: number, options: PromptFrameOptions): string {
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("│");
95
+ }
96
+
97
+ export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions): string[] {
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 ? "╰" : "│"));
101
+ const innerWidth = width - 2;
102
+ const top = lines[0];
103
+ const bottom = lines[lines.length - 1];
104
+ const content = lines.slice(1, -1).map((line) => sideRules(line, innerWidth, options));
105
+ return [topRule(width, options, scrollIndicator(top)), ...content, bottomRule(width, options, scrollIndicator(bottom))];
106
+ }
107
+
108
+ export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
109
+ const cursorAt = line.indexOf(FAKE_CURSOR);
110
+ if (cursorAt === -1) return line;
111
+ const afterCursor = cursorAt + FAKE_CURSOR.length;
112
+ const trailing = line.slice(afterCursor);
113
+ if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
114
+ return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
115
+ }
@@ -0,0 +1,11 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { ShellBarTheme } from "./shell-bar.ts";
3
+
4
+ export function renderSidebarBanner(theme: ShellBarTheme, width: number): string[] {
5
+ const label = "✿ Gentle-Pi ✿";
6
+ const space = width - visibleWidth(label);
7
+ if (space < 0) return [];
8
+ // Gentleman's accent is pink; card titles use a separate champagne role.
9
+ const title = theme.fg("accent", "✿") + " " + theme.fg("text", "Gentle-Pi") + " " + theme.fg("accent", "✿");
10
+ return [" ".repeat(Math.floor(space / 2)) + title + " ".repeat(Math.ceil(space / 2))];
11
+ }
@@ -0,0 +1,213 @@
1
+ import { ScrollView, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import { sidebarState, type SidebarRail } from "./shell-sidebar.ts";
3
+ import type { ShellBarTheme } from "./shell-bar.ts";
4
+ import { renderSidebarBanner } from "./shell-sidebar-banner.ts";
5
+
6
+ export const SIDEBAR_BREAKPOINT = 140;
7
+ const RAIL_WIDTH = 50;
8
+ const RAIL_PADDING = 1;
9
+ const GAP = 3;
10
+ // Experimental Pi 0.85.1 internals. Only the fullscreen layout tree is adapted;
11
+ // regular mode keeps native scrollback and the original bottom components.
12
+ const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
13
+ type LayoutNode = { type: string; entries?: unknown[]; gap?: number; align?: string };
14
+ type LayoutRoot = Component & { [NODE]?: () => LayoutNode };
15
+ type Host = TUI & { mode?: string; layoutRoot?: LayoutRoot };
16
+ type SidebarCache = { revision: number };
17
+ type RailHit = { key: string; component: Component; startY: number; height: number; width: number };
18
+ type SidebarPresentation = { scrollTop: number; output: LayoutNode };
19
+ type PreparedRail = {
20
+ revision: number;
21
+ width: number;
22
+ mode: string | undefined;
23
+ root: LayoutRoot;
24
+ theme: ShellBarTheme;
25
+ parts: Array<[string, SidebarRail]>;
26
+ digests: Array<string | undefined>;
27
+ contentWidth: number;
28
+ active: boolean;
29
+ lines: string[];
30
+ hits: RailHit[];
31
+ presentation?: SidebarPresentation;
32
+ };
33
+ const CACHE = Symbol.for("gentle-pi.experimental-sidebar.cache");
34
+
35
+ function sidebarCache(tui: TUI): SidebarCache {
36
+ const terminal = tui.terminal as unknown as Record<symbol, SidebarCache>;
37
+ return terminal[CACHE] ??= { revision: 0 };
38
+ }
39
+
40
+ /** Mark terminal-owned fullscreen sidebar output stale after a part state change. */
41
+ export function invalidateSidebar(tui: TUI): void {
42
+ if (tui.terminal) sidebarCache(tui).revision++;
43
+ }
44
+
45
+ // The memo keys on part identity and an explicit revision, neither of which can
46
+ // see live session state read inside a rail's render closure: a model switch, a
47
+ // new context percentage or an extension status change leaves the prepared lines
48
+ // intact. A rail that paints such state declares a digest of it, so the memo can
49
+ // notice by itself; a throwing digest degrades that rail to invalidation-only
50
+ // rather than taking the whole sidebar down with it.
51
+ function railDigest(rail: SidebarRail): string | undefined {
52
+ try {
53
+ return rail.digest?.();
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
60
+ if (!tui.terminal) return () => {};
61
+ const host = tui as Host;
62
+ const state = sidebarState(tui);
63
+ const cache = sidebarCache(tui);
64
+ const cleanups: Array<() => void> = [];
65
+ const roots = new Set<LayoutRoot>();
66
+ let stopped = false;
67
+ let failed = false;
68
+ let railLines: string[] = [];
69
+ let prepared: PreparedRail | undefined;
70
+ state.active = false;
71
+ state.ownsHost = () => !stopped && host.mode === "fullscreen" && !!host.layoutRoot && roots.has(host.layoutRoot);
72
+ const rail: Component = {
73
+ render: () => railLines,
74
+ invalidate() {
75
+ invalidateSidebar(tui);
76
+ for (const part of state.parts.values()) part.invalidate();
77
+ },
78
+ };
79
+ const scroll = new ScrollView(rail, {
80
+ follow: "none",
81
+ primary: false,
82
+ overscroll: "contain",
83
+ scrollbar: "always",
84
+ scrollbarTrackStyle: (text) => theme.fg("border", text),
85
+ scrollbarThumbStyle: (text) => theme.fg("accent", text),
86
+ });
87
+ const nativeMouse = scroll.handleMouse.bind(scroll);
88
+ const dispatchPartMouse = (event: TuiMouseEvent) => {
89
+ const current = prepared;
90
+ if (!current || stopped || failed || !current.active || current.revision !== cache.revision ||
91
+ host.mode !== "fullscreen" || tui.terminal.columns !== current.width || host.layoutRoot !== current.root ||
92
+ scroll.getContentWidth(event.width) !== current.contentWidth || event.x < RAIL_PADDING ||
93
+ event.x >= current.contentWidth || event.y < 0 || event.y >= event.height) return undefined;
94
+ const contentY = scroll.scrollTop + event.y;
95
+ const hit = current.hits.find((candidate) => contentY >= candidate.startY && contentY < candidate.startY + candidate.height);
96
+ if (!hit || state.parts.get(hit.key) !== hit.component || event.x >= RAIL_PADDING + hit.width) return undefined;
97
+ return hit.component.handleMouse?.({
98
+ ...event,
99
+ x: event.x - RAIL_PADDING,
100
+ y: contentY - hit.startY,
101
+ width: hit.width,
102
+ height: hit.height,
103
+ });
104
+ };
105
+ scroll.handleMouse = (event) => {
106
+ if (event.type === "wheel") {
107
+ // Consume even at the boundary or over blank rail space: Pi 0.85.1
108
+ // can send unconsumed delta to the primary transcript despite containment.
109
+ scroll.scrollBy(event.wheelDelta ?? 0);
110
+ return {
111
+ handled: true,
112
+ render: true,
113
+ target: { component: scroll, originX: event.screenX - event.x, originY: event.screenY - event.y, width: event.width, height: event.height },
114
+ };
115
+ }
116
+ return dispatchPartMouse(event) ?? nativeMouse(event);
117
+ };
118
+ const prepare = (width: number, root: LayoutRoot): boolean => {
119
+ state.active = false;
120
+ if (stopped || failed || host.mode !== "fullscreen" || width < SIDEBAR_BREAKPOINT) {
121
+ prepared = undefined;
122
+ return false;
123
+ }
124
+ const parts = [...state.parts.entries()];
125
+ const digests = parts.map(([, rail]) => railDigest(rail));
126
+ const unchanged = prepared?.revision === cache.revision &&
127
+ prepared.width === width && prepared.mode === host.mode && prepared.root === root && prepared.theme === theme &&
128
+ prepared.parts.length === parts.length && prepared.parts.every(([key, part], index) => parts[index]?.[0] === key && parts[index]?.[1] === part) &&
129
+ prepared.digests.length === digests.length && prepared.digests.every((digest, index) => digest === digests[index]);
130
+ if (unchanged) {
131
+ railLines = prepared.lines;
132
+ state.active = prepared.active;
133
+ return prepared.active;
134
+ }
135
+ try {
136
+ const contentWidth = scroll.getContentWidth(RAIL_WIDTH);
137
+ const sections = ["footer", "changes", "agents", "todo"].map((key) => {
138
+ 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();
141
+ return { key, component, lines };
142
+ }).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);
144
+ const hits: RailHit[] = [];
145
+ railLines = [];
146
+ if (sections.length && branding.length) {
147
+ railLines.push(...branding.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
148
+ }
149
+ for (const section of sections) {
150
+ if (railLines.length > 0) railLines.push("");
151
+ const startY = railLines.length;
152
+ railLines.push(...section.lines.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
153
+ hits.push({ key: section.key, component: section.component, startY, height: section.lines.length, width: contentWidth - RAIL_PADDING * 2 });
154
+ }
155
+ // Height is owned by the native ScrollView, never by the transcript.
156
+ 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 };
158
+ state.active = active;
159
+ return active;
160
+ } catch {
161
+ failed = true;
162
+ return false;
163
+ }
164
+ };
165
+ const attach = () => {
166
+ if (stopped || failed) return;
167
+ if (host.mode !== "fullscreen") { state.active = false; return; }
168
+ try {
169
+ const root = host.layoutRoot;
170
+ if (!root || typeof root[NODE] !== "function") { state.active = false; return; }
171
+ if (roots.has(root)) return;
172
+ const original = root[NODE]!;
173
+ const descriptor = Object.getOwnPropertyDescriptor(root, NODE);
174
+ const left = { render: (width: number) => root.render(width), invalidate() {}, [NODE]: () => original.call(root) };
175
+ const replacement = () => {
176
+ if (!prepare(tui.terminal.columns, root)) return original.call(root);
177
+ const current = prepared!;
178
+ if (current.presentation?.scrollTop === scroll.scrollTop) return current.presentation.output;
179
+ return (current.presentation = {
180
+ scrollTop: scroll.scrollTop,
181
+ output: { type: "hstack", gap: GAP, align: "stretch", entries: [
182
+ { component: left, basis: 0, grow: 1, shrink: 1, minSize: 1 },
183
+ { component: scroll, basis: RAIL_WIDTH, grow: 0, shrink: 0, minSize: RAIL_WIDTH },
184
+ ] },
185
+ }).output;
186
+ };
187
+ root[NODE] = replacement;
188
+ roots.add(root);
189
+ tui.requestRender();
190
+ cleanups.push(() => {
191
+ if (root[NODE] !== replacement) return;
192
+ if (descriptor) Object.defineProperty(root, NODE, descriptor);
193
+ else Reflect.deleteProperty(root, NODE);
194
+ });
195
+ } catch {
196
+ failed = true;
197
+ state.active = false;
198
+ }
199
+ };
200
+ attach();
201
+ // Pi replaces renderers without a session event. Rebind only that transition;
202
+ // resize and scroll remain owned by Pi's native layout/render loop.
203
+ const timer = setInterval(attach, 100);
204
+ timer.unref();
205
+ return () => {
206
+ stopped = true;
207
+ state.active = false;
208
+ clearInterval(timer);
209
+ scroll.hideTransientScrollbar();
210
+ for (const cleanup of cleanups.reverse()) cleanup();
211
+ tui.requestRender();
212
+ };
213
+ }