gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
package/tests/shell-bar.test.ts
CHANGED
|
@@ -107,7 +107,7 @@ test("visual visibility hides only selected optional status segments", () => {
|
|
|
107
107
|
assert.doesNotMatch(renderShellHeaderBar(buildShellHeaderModel(data), plainTheme, 160, undefined, settings).text, /gpt-5\.5|ctx|\$9\.49|usage/);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test("Status groups running
|
|
110
|
+
test("Status keeps RDD beside structured groups while running jobs moved to their own card", () => {
|
|
111
111
|
const data = model({ jobs: [
|
|
112
112
|
{ id: "job-2", label: "CI for PR 1860", startedAt: 2 },
|
|
113
113
|
{ id: "job-1", label: "Build runtime", startedAt: 1 },
|
|
@@ -115,12 +115,12 @@ test("Status groups running job descriptions beside RDD without a compact count"
|
|
|
115
115
|
for (const width of [30, 60]) {
|
|
116
116
|
const lines = renderShellSidebarBar(data, plainTheme, width);
|
|
117
117
|
const text = lines.join("\n");
|
|
118
|
-
|
|
118
|
+
// Running jobs live in their own sidebar card; the Status card never
|
|
119
|
+
// prints them, so the two surfaces cannot duplicate one job.
|
|
120
|
+
assert.doesNotMatch(text, /Jobs|CI for PR 1860|Build runtime/);
|
|
119
121
|
assert.match(text, /🌹 RDD/);
|
|
120
|
-
assert.doesNotMatch(text, /2 jobs/);
|
|
121
122
|
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
122
123
|
}
|
|
123
|
-
assert.doesNotMatch(renderShellSidebarBar(model({ jobs: [] }), plainTheme, 60).join("\n"), /Jobs/);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
test("header shows only the running job count when requested and preserves it at mobile widths", () => {
|
|
@@ -355,7 +355,7 @@ test("renderShellSidebarBar paints the Status card frame with border and the tit
|
|
|
355
355
|
});
|
|
356
356
|
|
|
357
357
|
test("sidebar unifies project, captured changes and integrations in one frame", () => {
|
|
358
|
-
const data = model({ changes: { files: 2, added: 7, deleted: 3
|
|
358
|
+
const data = model({ changes: { files: 2, added: 7, deleted: 3 }, statuses: ["MCP connected"] });
|
|
359
359
|
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
360
360
|
const text = lines.join("\n");
|
|
361
361
|
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
@@ -366,7 +366,6 @@ test("sidebar unifies project, captured changes and integrations in one frame",
|
|
|
366
366
|
previous = index;
|
|
367
367
|
}
|
|
368
368
|
assert.match(text, /2 files.*\+7.*−3/);
|
|
369
|
-
assert.match(text, /capture warning/);
|
|
370
369
|
assert.match(text, /\/gentle:changes/);
|
|
371
370
|
assert.match(text, /main/);
|
|
372
371
|
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
@@ -856,6 +855,19 @@ test("unified below-input float includes optional Changes and sanitized statuses
|
|
|
856
855
|
assert.equal(renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, changes), undefined);
|
|
857
856
|
});
|
|
858
857
|
|
|
858
|
+
// The idle Changes row exists only when files are actually tracked; an empty
|
|
859
|
+
// capture — including an unborn-only root, which the tracker never scans — is
|
|
860
|
+
// silence (explained by /gentle:changes on demand), never an instruction row.
|
|
861
|
+
test("the below-input float shows no Changes row without tracked files", (t) => {
|
|
862
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
863
|
+
const theme = withBackground(plainTheme);
|
|
864
|
+
const presentation = { ...DEFAULT_VISUAL_SETTINGS, headerPlacement: "below-input" as const };
|
|
865
|
+
const empty = { files: [], added: 0, deleted: 0 };
|
|
866
|
+
const chrome = renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, empty)!;
|
|
867
|
+
assert.equal(chrome.headerRow, 2, "an empty capture is silence, not a Changes row");
|
|
868
|
+
assert.doesNotMatch(stripAnsi(chrome.rows.join("\n")), /make a commit to start tracking/);
|
|
869
|
+
});
|
|
870
|
+
|
|
859
871
|
test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) => {
|
|
860
872
|
const theme = withBackground(taggedTheme);
|
|
861
873
|
const data = model({ usage: USAGE_TWO_WINDOWS, statuses: ["mcp ok"] });
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
foreignRootBranch,
|
|
16
16
|
parseWorktrees,
|
|
17
17
|
changesSummary,
|
|
18
|
+
changesDigest,
|
|
18
19
|
emptyChanges,
|
|
19
20
|
parseNumstat,
|
|
20
21
|
parsePorcelain,
|
|
@@ -101,6 +102,36 @@ test("changesModel sorts files by path and totals the counts", () => {
|
|
|
101
102
|
assert.equal(model.deleted, 2);
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
// The footer Status memo reads the changes digest on every keystroke, so the
|
|
106
|
+
// model stores it once per build and reading it never walks the file list.
|
|
107
|
+
// The digest hashes every per-file tuple the renderer consumes (path, status,
|
|
108
|
+
// counts, diffRevision, countsUnavailable); a 64-bit collision could only
|
|
109
|
+
// skip one refresh until the next change moves it again -- never render a
|
|
110
|
+
// wrong picture.
|
|
111
|
+
test("the changes digest is stored on the model and stays equal while nothing moves", () => {
|
|
112
|
+
const files = [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)];
|
|
113
|
+
const model = changesModel([...files]);
|
|
114
|
+
assert.equal(Object.hasOwn(model, "digest"), true, "the digest is a stored field, not recomputed per read");
|
|
115
|
+
assert.equal(model.digest, changesDigest(model), "the stored digest matches an independently computed one");
|
|
116
|
+
assert.equal(changesModel([...files]).digest, model.digest, "an unchanged model rebuilds to the same digest");
|
|
117
|
+
assert.equal(emptyChanges().digest, emptyChanges().digest, "the empty model carries a stable digest");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the changes digest moves with every per-file detail the renderer consumes", () => {
|
|
121
|
+
const base = changesModel([file("lib/a.ts", 4, 2)]).digest;
|
|
122
|
+
const digestFor = (files: ChangedFile[]) => changesModel(files).digest!;
|
|
123
|
+
assert.notEqual(digestFor([file("lib/a.ts", 4, 2, CHANGE_STATUS.RENAMED)]), base, "a status change moves the digest even at equal counts");
|
|
124
|
+
assert.notEqual(digestFor([file("lib/a.ts", 5, 2)]), base, "an added-count change moves the digest");
|
|
125
|
+
assert.notEqual(digestFor([file("lib/a.ts", 4, 3)]), base, "a deleted-count change moves the digest");
|
|
126
|
+
assert.notEqual(digestFor([file("lib/b.ts", 4, 2)]), base, "a path change moves the digest");
|
|
127
|
+
const revised = file("lib/a.ts", 4, 2);
|
|
128
|
+
revised.diffRevision = "abc123";
|
|
129
|
+
assert.notEqual(digestFor([revised]), base, "a diffRevision change moves the digest");
|
|
130
|
+
const partial = file("lib/a.ts", 4, 2);
|
|
131
|
+
partial.countsUnavailable = "timeout";
|
|
132
|
+
assert.notEqual(digestFor([partial]), base, "countsUnavailable moves the digest");
|
|
133
|
+
});
|
|
134
|
+
|
|
104
135
|
test("changesSummary and the widget describe the session at a glance", () => {
|
|
105
136
|
const model = changesModel([file("extensions/gentle-shell.ts", 31, 0, CHANGE_STATUS.ADDED), file("lib/shell-bar.ts", 9, 7), file("tests/x.test.ts", 2, 0)]);
|
|
106
137
|
assert.equal(changesSummary(model), "3 files · +42 −7");
|
|
@@ -121,6 +152,14 @@ test("renderChangesWidget colors counts by direction and yields nothing when cle
|
|
|
121
152
|
assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
|
|
122
153
|
});
|
|
123
154
|
|
|
155
|
+
// The unborn state (no commits yet) is silence in the idle bar: the widget
|
|
156
|
+
// renders only tracked files, and /gentle:changes explains the state on demand.
|
|
157
|
+
test("renderChangesWidget stays silent when nothing is tracked", () => {
|
|
158
|
+
assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
|
|
159
|
+
assert.deepEqual(renderChangesWidget(emptyChanges(), taggedTheme, 400), []);
|
|
160
|
+
assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 12), []);
|
|
161
|
+
});
|
|
162
|
+
|
|
124
163
|
test("renderChangesWidget drops the file list before truncating on narrow terminals", () => {
|
|
125
164
|
const model = changesModel([file("a/very/long/path/one.ts", 1, 0), file("a/very/long/path/two.ts", 1, 0)]);
|
|
126
165
|
const [line] = renderChangesWidget(model, plainTheme, 40);
|
|
@@ -132,6 +171,67 @@ test("renderChangesWidget drops the file list before truncating on narrow termin
|
|
|
132
171
|
assert.match(tiny, /^✎ 2 files/);
|
|
133
172
|
});
|
|
134
173
|
|
|
174
|
+
// Repeated frames hit the widget from two surfaces (the sidebar part and the
|
|
175
|
+
// below-input header row), but its paint depends only on what the model digest
|
|
176
|
+
// already covers plus the width and the theme. A counting theme is the seam: a
|
|
177
|
+
// cache hit paints nothing, so theme.fg stays silent instead of walking files.
|
|
178
|
+
function countingTheme() {
|
|
179
|
+
let calls = 0;
|
|
180
|
+
return {
|
|
181
|
+
get fgCalls() {
|
|
182
|
+
return calls;
|
|
183
|
+
},
|
|
184
|
+
fg(_color: string, text: string) {
|
|
185
|
+
calls += 1;
|
|
186
|
+
return text;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
test("renderChangesWidget memoizes a frame per digest, width and theme so an unchanged model repaints nothing", () => {
|
|
192
|
+
const model = changesModel([file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
|
|
193
|
+
const theme = countingTheme();
|
|
194
|
+
const first = renderChangesWidget(model, theme, 120);
|
|
195
|
+
assert.ok(theme.fgCalls > 0, "the first render paints");
|
|
196
|
+
const afterFirst = theme.fgCalls;
|
|
197
|
+
const second = renderChangesWidget(model, theme, 120);
|
|
198
|
+
assert.deepEqual(second, first, "an unchanged model, width and theme return the same rows");
|
|
199
|
+
assert.equal(theme.fgCalls, afterFirst, "a memo hit repaints nothing: no per-file iteration");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("renderChangesWidget re-renders when the digest moves", () => {
|
|
203
|
+
const theme = countingTheme();
|
|
204
|
+
const before = changesModel([file("lib/a.ts", 4, 2)]);
|
|
205
|
+
renderChangesWidget(before, theme, 120);
|
|
206
|
+
const afterFirst = theme.fgCalls;
|
|
207
|
+
const moved = changesModel([file("lib/a.ts", 5, 2)]);
|
|
208
|
+
assert.notEqual(moved.digest, before.digest, "a count change moves the digest");
|
|
209
|
+
const lines = renderChangesWidget(moved, theme, 120);
|
|
210
|
+
assert.ok(theme.fgCalls > afterFirst, "a digest change re-renders");
|
|
211
|
+
assert.match(lines.join("\n"), /\+5/, "the re-render shows the new totals");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("renderChangesWidget re-renders when the width or the theme changes", () => {
|
|
215
|
+
const model = changesModel([file("lib/a.ts", 4, 2)]);
|
|
216
|
+
const theme = countingTheme();
|
|
217
|
+
const wide = renderChangesWidget(model, theme, 400);
|
|
218
|
+
const afterWide = theme.fgCalls;
|
|
219
|
+
renderChangesWidget(model, theme, 40);
|
|
220
|
+
assert.ok(theme.fgCalls > afterWide, "a width change re-renders");
|
|
221
|
+
const otherTheme = countingTheme();
|
|
222
|
+
assert.deepEqual(renderChangesWidget(model, otherTheme, 400), wide, "a new theme object repaints the same wide frame");
|
|
223
|
+
assert.ok(otherTheme.fgCalls > 0, "the new theme did the painting");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("renderChangesWidget never caches a digest-less model", () => {
|
|
227
|
+
const model = { files: [file("lib/a.ts", 4, 2)], added: 4, deleted: 2 };
|
|
228
|
+
const theme = countingTheme();
|
|
229
|
+
renderChangesWidget(model, theme, 120);
|
|
230
|
+
const afterFirst = theme.fgCalls;
|
|
231
|
+
renderChangesWidget(model, theme, 120);
|
|
232
|
+
assert.ok(theme.fgCalls > afterFirst, "a hand-built model without a digest re-renders every call");
|
|
233
|
+
});
|
|
234
|
+
|
|
135
235
|
function fakeGit(numstats: string[], porcelains: string[], code = 0) {
|
|
136
236
|
const calls: string[][] = [];
|
|
137
237
|
let round = 0;
|
|
@@ -336,7 +436,6 @@ test("foreignRootBranch still reports a genuine detached HEAD as undefined, not
|
|
|
336
436
|
test("a root missing from worktree list resolves its own branch instead of defaulting to detached", async (t) => {
|
|
337
437
|
const scenarios: Array<{ name: string; symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number }; expected: string | undefined }> = [
|
|
338
438
|
{ name: "a normal branch on a foreign clone", symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: "feature" },
|
|
339
|
-
{ name: "an unborn branch (no commits yet)", symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 }, expected: "no commits yet" },
|
|
340
439
|
{ name: "a genuinely detached HEAD", symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: undefined },
|
|
341
440
|
];
|
|
342
441
|
for (const scenario of scenarios) {
|
|
@@ -358,9 +457,57 @@ test("a root missing from worktree list resolves its own branch instead of defau
|
|
|
358
457
|
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", scenario.expected]]);
|
|
359
458
|
assert.ok(calls.some((args) => args[0] === "symbolic-ref"), "a root absent from worktree list must be asked directly");
|
|
360
459
|
});
|
|
460
|
+
// An unborn foreign root is a holder before it is a branch question: with
|
|
461
|
+
// no commits there is no baseline to diff against, so it is skipped --
|
|
462
|
+
// neither dirty nor failed -- before any branch lookup happens.
|
|
463
|
+
await t.test("an unborn foreign root is skipped as a holder before the branch ask", async () => {
|
|
464
|
+
const calls: string[][] = [];
|
|
465
|
+
const tracker = new WorktreeChangesTracker(
|
|
466
|
+
async () => ({ stdout: "", code: 0 }),
|
|
467
|
+
() => async (args) => {
|
|
468
|
+
calls.push(args);
|
|
469
|
+
if (args[0] === "rev-parse") return { stdout: "", code: 1 };
|
|
470
|
+
return { stdout: "", code: 0 };
|
|
471
|
+
},
|
|
472
|
+
undefined,
|
|
473
|
+
() => ["/foreign"],
|
|
474
|
+
);
|
|
475
|
+
await tracker.start();
|
|
476
|
+
assert.deepEqual(tracker.worktrees, []);
|
|
477
|
+
assert.deepEqual(tracker.unbornRoots, ["/foreign"]);
|
|
478
|
+
assert.deepEqual(tracker.failedRoots, []);
|
|
479
|
+
assert.ok(!calls.some((args) => args[0] === "symbolic-ref"), "a skipped holder is never asked for a branch");
|
|
480
|
+
});
|
|
361
481
|
}
|
|
362
482
|
});
|
|
363
483
|
|
|
484
|
+
// A worktree-holder repository (no commits, only linked worktrees inside) has
|
|
485
|
+
// no baseline: `git diff HEAD` is meaningless and every file reports
|
|
486
|
+
// untracked, so scanning it floods Changes with thousands of phantom files.
|
|
487
|
+
// It is skipped as neither dirty nor failed, and its first commit starts
|
|
488
|
+
// tracking it on the next scan.
|
|
489
|
+
test("a root with no commits is skipped as a holder, neither dirty nor failed, and recovers on its first commit", async () => {
|
|
490
|
+
let holderHasCommits = false;
|
|
491
|
+
const tracker = new WorktreeChangesTracker(
|
|
492
|
+
async () => ({ stdout: "worktree /real\0branch refs/heads/main\0\0", code: 0 }),
|
|
493
|
+
(root) => async (args) => {
|
|
494
|
+
if (args[0] === "rev-parse") return { stdout: "", code: root === "/holder" && !holderHasCommits ? 1 : 0 };
|
|
495
|
+
return { stdout: args[0] === "status" && (root === "/real" || (root === "/holder" && holderHasCommits)) ? "?? a.ts\0" : "", code: 0 };
|
|
496
|
+
},
|
|
497
|
+
undefined,
|
|
498
|
+
() => ["/holder", "/real"],
|
|
499
|
+
);
|
|
500
|
+
await tracker.start();
|
|
501
|
+
assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/real"], "an unborn root is skipped, not scanned");
|
|
502
|
+
assert.deepEqual(tracker.unbornRoots, ["/holder"]);
|
|
503
|
+
assert.deepEqual(tracker.failedRoots, [], "no commits is not a Git failure");
|
|
504
|
+
assert.equal(tracker.scannedRootCount, 2);
|
|
505
|
+
holderHasCommits = true;
|
|
506
|
+
await tracker.refresh();
|
|
507
|
+
assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/holder", "/real"], "the first commit starts tracking");
|
|
508
|
+
assert.deepEqual(tracker.unbornRoots, []);
|
|
509
|
+
});
|
|
510
|
+
|
|
364
511
|
// The direct per-root ask (for a root missing from worktree list) can fail on
|
|
365
512
|
// its own -- root gone, git unavailable -- independently of the change scan
|
|
366
513
|
// that already found real files. That failure must label the root
|
|
@@ -8,15 +8,15 @@ import {
|
|
|
8
8
|
PROMPT_STATE,
|
|
9
9
|
petalGlyph,
|
|
10
10
|
petalTone,
|
|
11
|
-
|
|
11
|
+
SHELL_PULSE_WATCHDOG_MS,
|
|
12
12
|
SHELL_SCANNER_STEPS,
|
|
13
13
|
withPromptHint,
|
|
14
14
|
type PromptFrameOptions,
|
|
15
15
|
} from "../lib/shell-prompt.ts";
|
|
16
16
|
|
|
17
|
-
test("scanner reflects a symmetric light wave across working text at
|
|
17
|
+
test("scanner reflects a symmetric light wave across working text at the watchdog cadence", () => {
|
|
18
18
|
const fg = (role: string, char: string) => `<${role}>${char}</${role}>`;
|
|
19
|
-
assert.equal(
|
|
19
|
+
assert.equal(SHELL_PULSE_WATCHDOG_MS, 1000);
|
|
20
20
|
assert.equal(SHELL_SCANNER_STEPS, 15);
|
|
21
21
|
for (const word of ["working…", "Thinking…"]) {
|
|
22
22
|
for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { buildShellBarModel, type ShellFooterData } from "../extensions/gentle-shell.ts";
|
|
4
|
+
import { buildShellBarModel, createFooterStatusMemo, type ShellFooterData } from "../extensions/gentle-shell.ts";
|
|
5
|
+
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
6
|
+
import type { RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
7
|
+
import type { ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
|
|
8
|
+
import type { ProviderUsage } from "../lib/shell-usage.ts";
|
|
5
9
|
import { createQuietToolRenderer } from "../extensions/quiet-tools.ts";
|
|
6
10
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
7
11
|
|
|
@@ -127,3 +131,132 @@ test("image renderers keep their live geometry instead of caching opaque compone
|
|
|
127
131
|
{ expanded: true, isPartial: false }, theme as never, {} as never);
|
|
128
132
|
assert.notDeepEqual(card.render(80), card.render(80));
|
|
129
133
|
});
|
|
134
|
+
|
|
135
|
+
function footerMemoFixture() {
|
|
136
|
+
let reads = 0;
|
|
137
|
+
let projections = 0;
|
|
138
|
+
let leaf = "leaf-1";
|
|
139
|
+
const entries = [{ type: "message", message: { role: "assistant", usage: { cost: { total: 1 } } } }];
|
|
140
|
+
// One stable model object, replaced only by setModelId: real hosts keep the
|
|
141
|
+
// model identity stable, and sessionStatsKey compares it by identity.
|
|
142
|
+
let model: { id: string; contextWindow: number } = { id: "model", contextWindow: 1000 };
|
|
143
|
+
const ctx = {
|
|
144
|
+
get model() { return model; },
|
|
145
|
+
getContextUsage() { projections++; return { percent: 10, contextWindow: 1000 }; },
|
|
146
|
+
modelRegistry: { isUsingOAuth: () => false },
|
|
147
|
+
sessionManager: {
|
|
148
|
+
getEntries() { reads++; return [...entries]; },
|
|
149
|
+
getEntryCount: () => entries.length,
|
|
150
|
+
getSessionId: () => "session-1",
|
|
151
|
+
getLeafId: () => leaf,
|
|
152
|
+
getCwd: () => "/repo",
|
|
153
|
+
getSessionName: () => "session",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
let branch: string | null = "main";
|
|
157
|
+
let statuses = new Map<string, string>();
|
|
158
|
+
let usage: ProviderUsage | undefined;
|
|
159
|
+
let added = 0;
|
|
160
|
+
let deleted = 0;
|
|
161
|
+
let unbornRoots = 0;
|
|
162
|
+
let review: ReviewSidebarSnapshot | undefined;
|
|
163
|
+
let jobs: RunningJobDisplay[] | undefined;
|
|
164
|
+
let settings: unknown = { statusPlacement: "auto" };
|
|
165
|
+
let style: unknown = "comfortable";
|
|
166
|
+
const footerData = { getGitBranch: () => branch, getExtensionStatuses: () => statuses } as unknown as ShellFooterData;
|
|
167
|
+
let profile: string | undefined = "team";
|
|
168
|
+
const memo = createFooterStatusMemo({
|
|
169
|
+
pi, ctx: ctx as unknown as ExtensionContext, footerData,
|
|
170
|
+
usage: () => usage,
|
|
171
|
+
profile: () => profile,
|
|
172
|
+
jobs: () => jobs,
|
|
173
|
+
review: () => review,
|
|
174
|
+
tracker: () => ({ model: { files: [{ path: "a.ts", status: CHANGE_STATUS.MODIFIED, added, deleted }], added, deleted }, unbornRoots: Array.from({ length: unbornRoots }, () => "root") }),
|
|
175
|
+
visualSettings: () => settings,
|
|
176
|
+
cardStyle: () => style,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
ctx: ctx as unknown as ExtensionContext & { sessionManager: { getLeafId?: () => string } },
|
|
180
|
+
memo,
|
|
181
|
+
counts: () => [reads, projections],
|
|
182
|
+
setLeaf: (value: string) => { leaf = value; },
|
|
183
|
+
spend: () => { entries[0]!.message.usage.cost.total += 1; },
|
|
184
|
+
setModelId: (value: string) => { model = { ...model, id: value }; },
|
|
185
|
+
setProfile: (value: string | undefined) => { profile = value; },
|
|
186
|
+
setBranch: (value: string | null) => { branch = value; },
|
|
187
|
+
setStatuses: (value: Map<string, string>) => { statuses = value; },
|
|
188
|
+
setUsage: (value: ProviderUsage | undefined) => { usage = value; },
|
|
189
|
+
setChangeCounts: (nextAdded: number, nextDeleted: number) => { added = nextAdded; deleted = nextDeleted; },
|
|
190
|
+
setUnbornRoots: (value: number) => { unbornRoots = value; },
|
|
191
|
+
setReview: (value: ReviewSidebarSnapshot | undefined) => { review = value; },
|
|
192
|
+
setJobs: (value: RunningJobDisplay[] | undefined) => { jobs = value; },
|
|
193
|
+
setSettings: (value: unknown) => { settings = value; },
|
|
194
|
+
setStyle: (value: unknown) => { style = value; },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test("the footer status memo reuses one model while live inputs hold still", () => {
|
|
199
|
+
const f = footerMemoFixture();
|
|
200
|
+
const first = f.memo.model();
|
|
201
|
+
assert.equal(first.costTotal, 1);
|
|
202
|
+
assert.deepEqual(first.changes, { files: 1, added: 0, deleted: 0 });
|
|
203
|
+
for (let frame = 0; frame < 10; frame++) assert.equal(f.memo.model(), first, "stable inputs reuse the built model");
|
|
204
|
+
assert.deepEqual(f.counts(), [1, 1], "warm frames never rescan the session");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the footer status digest key notices every live input the model reads", () => {
|
|
208
|
+
const f = footerMemoFixture();
|
|
209
|
+
const before = f.memo.key();
|
|
210
|
+
assert.equal(f.memo.key(), before, "the key is deterministic while nothing moves");
|
|
211
|
+
const mutations: Array<[string, () => void]> = [
|
|
212
|
+
["branch", () => f.setBranch("feature")],
|
|
213
|
+
["profile", () => f.setProfile("other (local)")],
|
|
214
|
+
["statuses", () => f.setStatuses(new Map([["jobs", "1 running"]]))],
|
|
215
|
+
["usage", () => f.setUsage({ provider: "p", plan: undefined, limits: [], fetchedAt: 1 })],
|
|
216
|
+
["change counts", () => f.setChangeCounts(2, 1)],
|
|
217
|
+
["unborn roots", () => f.setUnbornRoots(1)],
|
|
218
|
+
["review", () => f.setReview({ state: "open", scope: undefined } as unknown as ReviewSidebarSnapshot)],
|
|
219
|
+
["jobs", () => f.setJobs([{ id: "j", label: "l", startedAt: 0 }])],
|
|
220
|
+
["visual settings", () => f.setSettings({ statusPlacement: "bottom" })],
|
|
221
|
+
["card style", () => f.setStyle("compact")],
|
|
222
|
+
["model", () => f.setModelId("new-model")],
|
|
223
|
+
["cwd", () => (f.ctx.sessionManager as { getCwd(): string }).getCwd = () => "/other"],
|
|
224
|
+
["session name", () => (f.ctx.sessionManager as { getSessionName(): string }).getSessionName = () => "renamed"],
|
|
225
|
+
["leaf", () => f.setLeaf("leaf-2")],
|
|
226
|
+
];
|
|
227
|
+
let previous = before;
|
|
228
|
+
for (const [name, mutate] of mutations) {
|
|
229
|
+
mutate();
|
|
230
|
+
const current = f.memo.key();
|
|
231
|
+
assert.notEqual(current, previous, `a moved ${name} changes the digest key`);
|
|
232
|
+
previous = current;
|
|
233
|
+
}
|
|
234
|
+
// A branch or profile move rebuilds the model without rescanning statistics:
|
|
235
|
+
// the model object identity stays stable across those rebuilds.
|
|
236
|
+
const settled = f.memo.model();
|
|
237
|
+
const [reads, projections] = f.counts();
|
|
238
|
+
f.setBranch("release");
|
|
239
|
+
assert.notEqual(f.memo.model(), settled);
|
|
240
|
+
assert.deepEqual(f.counts(), [reads, projections]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a moved leaf rebuilds the footer model with fresh statistics", () => {
|
|
244
|
+
const f = footerMemoFixture();
|
|
245
|
+
const first = f.memo.model();
|
|
246
|
+
f.setLeaf("leaf-2");
|
|
247
|
+
const second = f.memo.model();
|
|
248
|
+
assert.notEqual(second, first);
|
|
249
|
+
assert.deepEqual(f.counts(), [2, 2]);
|
|
250
|
+
assert.equal(f.memo.model(), second, "the fresh model is memoized again");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("hosts without leaf identity stay honest through the digest key's values", () => {
|
|
254
|
+
const f = footerMemoFixture();
|
|
255
|
+
assert.equal(f.memo.key(), f.memo.key(), "the key stays deterministic on uncached hosts");
|
|
256
|
+
Reflect.deleteProperty(f.ctx.sessionManager, "getLeafId");
|
|
257
|
+
const first = f.memo.model();
|
|
258
|
+
f.spend();
|
|
259
|
+
const second = f.memo.model();
|
|
260
|
+
assert.notEqual(second, first, "a changed cost value rebuilds the model without leaf identity");
|
|
261
|
+
assert.equal(second.costTotal, 2);
|
|
262
|
+
});
|
|
@@ -589,6 +589,73 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
|
|
|
589
589
|
assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
|
|
590
590
|
});
|
|
591
591
|
|
|
592
|
+
test("the jobs rail section paints between Status and Agents", (t) => {
|
|
593
|
+
const f = fixture();
|
|
594
|
+
sidebarPart(f.tui, "jobs", { render: () => ["jobs"], invalidate() {} }, { digest: () => "jobs", render: () => ["Jobs card"], invalidate() {} });
|
|
595
|
+
sidebarPart(f.tui, "agents", { render: () => ["agents"], invalidate() {} }, { digest: () => "agents", render: () => ["Agents card"], invalidate() {} });
|
|
596
|
+
sidebarPart(f.tui, "todo", { render: () => ["todo"], invalidate() {} }, { digest: () => "todo", render: () => ["Todo card"], invalidate() {} });
|
|
597
|
+
t.after(installSidebar(f.tui, theme));
|
|
598
|
+
const text = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
|
|
599
|
+
const footer = text.indexOf("Status");
|
|
600
|
+
const jobs = text.indexOf("Jobs card");
|
|
601
|
+
const agents = text.indexOf("Agents card");
|
|
602
|
+
const todo = text.indexOf("Todo card");
|
|
603
|
+
for (const [name, index] of [["Status", footer], ["Jobs card", jobs], ["Agents card", agents], ["Todo card", todo]] as const) {
|
|
604
|
+
assert.ok(index >= 0, `${name} paints in the rail`);
|
|
605
|
+
}
|
|
606
|
+
assert.ok(footer < jobs && jobs < agents && agents < todo, `sections paint in order, got: ${text}`);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("a theme switch repaints rail sections whose digest did not change", (t) => {
|
|
610
|
+
const f = fixture();
|
|
611
|
+
// Pi hands extensions a live theme proxy and announces a switch only through
|
|
612
|
+
// ui.invalidate(), which never reaches the rail inside the layout tree.
|
|
613
|
+
let code = 31;
|
|
614
|
+
const live = { fg: (_color: string, text: string) => `\x1b[${code}m${text}\x1b[39m`, bold: (text: string) => text };
|
|
615
|
+
for (const key of ["footer", "agents", "todo"]) {
|
|
616
|
+
sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
|
|
617
|
+
digest: () => "stable",
|
|
618
|
+
render: () => [live.fg("accent", `${key} card`)],
|
|
619
|
+
invalidate() {},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
t.after(installSidebar(f.tui, live));
|
|
623
|
+
|
|
624
|
+
const before = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
|
|
625
|
+
for (const key of ["footer", "agents", "todo"]) assert.ok(before.includes(`\x1b[31m${key} card`), `${key} paints with the first theme`);
|
|
626
|
+
|
|
627
|
+
code = 32;
|
|
628
|
+
const after = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
|
|
629
|
+
for (const key of ["footer", "agents", "todo"]) {
|
|
630
|
+
assert.ok(after.includes(`\x1b[32m${key} card`), `${key} repaints with the new theme`);
|
|
631
|
+
assert.ok(!after.includes(`\x1b[31m${key} card`), `${key} drops the old theme`);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("structural cache misses skip the whole-rail digest pass", (t) => {
|
|
636
|
+
const f = fixture();
|
|
637
|
+
let digests = 0;
|
|
638
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
|
|
639
|
+
digest: () => { digests++; return "stable"; },
|
|
640
|
+
render: () => [`Todo ${digests}`],
|
|
641
|
+
invalidate() {},
|
|
642
|
+
});
|
|
643
|
+
t.after(installSidebar(f.tui, theme));
|
|
644
|
+
|
|
645
|
+
// Every frame evaluates the digest exactly once: the whole-rail comparison
|
|
646
|
+
// reuses it for the section cache instead of calling each digest twice per
|
|
647
|
+
// prepare (issue #1681).
|
|
648
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
649
|
+
const prepared = digests;
|
|
650
|
+
assert.equal(prepared, 1, "preparing the rail evaluates the digest once");
|
|
651
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
652
|
+
assert.equal(digests, prepared + 1, "a cache hit compares the digest once");
|
|
653
|
+
renderLayoutFrame(f.root, 141, 20, () => {});
|
|
654
|
+
assert.equal(digests, prepared + 2, "a structural miss re-prepares with a single digest pass");
|
|
655
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
656
|
+
assert.equal(digests, prepared + 3, "returning to the cached structure stays on a single digest pass");
|
|
657
|
+
});
|
|
658
|
+
|
|
592
659
|
test("reuses final sidebar presentation until a relevant invalidation", (t) => {
|
|
593
660
|
const f = fixture();
|
|
594
661
|
sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
|
|
@@ -36,7 +36,7 @@ for (const [name, text] of Object.entries({ registeredDescription: background.de
|
|
|
36
36
|
test("inline still means the same parent, and the public background API stays asynchronous", () => {
|
|
37
37
|
assert.ok(core.includes("Small path: inline (parent; `bash_background` policy);"));
|
|
38
38
|
assert.ok(guide.includes("Inline means the same parent, not necessarily synchronous bash"));
|
|
39
|
-
assert.ok(core.includes("run the focused test and the suite inline
|
|
39
|
+
assert.ok(core.includes("run the focused test and the suite inline in one command"));
|
|
40
40
|
assert.match(background.description, /return a job id at once/);
|
|
41
41
|
assert.match(background.description, /notified once, automatically/);
|
|
42
42
|
assert.match(background.description, /do not poll, sleep, or delegate a subagent to wait/);
|
|
@@ -25,7 +25,9 @@ const PREFIXED_NAMES: Record<string, string> = {
|
|
|
25
25
|
"work-unit-commits": "gentle-ai-work-unit-commits",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// gentle-pi-owned skills that gentle-ai does not install keep their own
|
|
29
|
+
// gentle- namespace, so they need no gentle-ai- prefix to avoid a collision.
|
|
30
|
+
const UNPREFIXED_DIRS = ["gentle-ai", "design"];
|
|
29
31
|
|
|
30
32
|
for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
|
|
31
33
|
test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
|
|
@@ -16,7 +16,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import { syncBuiltinESMExports } from "node:module";
|
|
19
|
-
import { join } from "node:path";
|
|
19
|
+
import { join, sep } from "node:path";
|
|
20
20
|
const root = ${JSON.stringify(root)};
|
|
21
21
|
const cwd = join(root, "workspace");
|
|
22
22
|
const home = join(root, "home");
|
|
@@ -52,6 +52,8 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
52
52
|
});
|
|
53
53
|
const ctx = { cwd, hasUI: true, ui: { notify: (message, level) => notices.push({ message, level }) } };
|
|
54
54
|
const waitFor = async (predicate) => {
|
|
55
|
+
// Must stay well below the parent's spawnSync timeout so a missed
|
|
56
|
+
// outcome fails with this assertion instead of an opaque ETIMEDOUT.
|
|
55
57
|
const deadline = Date.now() + 5000;
|
|
56
58
|
while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
57
59
|
assert.ok(predicate(), "timed out waiting for watcher outcome");
|
|
@@ -66,18 +68,28 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
66
68
|
if (code === "ENOENT" && process.platform !== "darwin" && process.platform !== "win32") {
|
|
67
69
|
// Force the transient missing-directory race at Node's real recursive
|
|
68
70
|
// readdir boundary, after watcher creation, not in our refresh callback.
|
|
71
|
+
// Older Node rescans the parent folder; Node 24.22+ resolves new files
|
|
72
|
+
// via lstat and only readdirs a newly added directory, so trigger with
|
|
73
|
+
// a directory and fail whichever readdir lands under assets.
|
|
69
74
|
const nativeReaddir = fs.readdirSync;
|
|
70
75
|
let reachedRescan = false;
|
|
71
76
|
fs.readdirSync = function (path, options) {
|
|
72
|
-
if (path === assets) {
|
|
77
|
+
if (typeof path === "string" && (path === assets || path.startsWith(assets + sep))) {
|
|
73
78
|
fs.readdirSync = nativeReaddir;
|
|
74
79
|
reachedRescan = true;
|
|
75
80
|
throw Object.assign(new Error("ENOENT: transient skill assets disappeared"), { code, syscall: "scandir", path });
|
|
76
81
|
}
|
|
77
82
|
return nativeReaddir(path, options);
|
|
78
83
|
};
|
|
79
|
-
fs.
|
|
80
|
-
|
|
84
|
+
fs.mkdirSync(join(assets, "trigger"));
|
|
85
|
+
// The rescan race needs live recursive-watch events. Where the OS
|
|
86
|
+
// cannot deliver them (an exhausted inotify budget, some WSL2
|
|
87
|
+
// setups), waiting the full waitFor budget is pointless: settle
|
|
88
|
+
// briefly for the natural race, then exercise the public error
|
|
89
|
+
// listener directly, as the manual fallback below always did.
|
|
90
|
+
const raceDeadline = Date.now() + 2000;
|
|
91
|
+
while (!reachedRescan && Date.now() < raceDeadline) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
92
|
+
fs.readdirSync = nativeReaddir;
|
|
81
93
|
// Newer Node versions swallow rescan ENOENT themselves. Still
|
|
82
94
|
// exercise our public error listener without requiring Node's bug.
|
|
83
95
|
if (!notices.some(({ message, level }) => level === "warning" && message.includes(code))) {
|
|
@@ -112,7 +124,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
|
|
|
112
124
|
timeout: 15_000,
|
|
113
125
|
env: { ...process.env, GIT_CEILING_DIRECTORIES: root },
|
|
114
126
|
});
|
|
115
|
-
assert.equal(child.error, undefined, child.error?.message);
|
|
127
|
+
assert.equal(child.error, undefined, `${child.error?.message}\n${child.stderr}`);
|
|
116
128
|
assert.equal(child.status, 0, child.stderr);
|
|
117
129
|
assert.match(child.stdout, new RegExp(`SURVIVED ${code}`));
|
|
118
130
|
});
|