gentle-pi 4.2.0 → 4.4.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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -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 +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- 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/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -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/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- 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-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -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 +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- 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/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- 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 +485 -7
- 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 +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- 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/destructive-command-guard.test.ts +85 -1
- 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 +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- 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-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- 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 +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- 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 +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -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/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- 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 +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -8,7 +8,8 @@ const source = readFileSync(join(root, "extensions/gentle-agents.ts"), "utf8");
|
|
|
8
8
|
const match = source.match(/name: "orchestrator_session_id",\s*label: "[^"]+",\s*description: ("(?:[^"\\]|\\.)*")/);
|
|
9
9
|
assert.ok(match, "Registered session tool must expose a public description");
|
|
10
10
|
const description: string = JSON.parse(match[1]);
|
|
11
|
-
|
|
11
|
+
// S11 T2: the session subject rules moved from the delegation card to its reference.
|
|
12
|
+
const detail = readFileSync(join(root, "assets/orchestrator-delegation-reference.md"), "utf8")
|
|
12
13
|
.split("## Session subject and display identity\n")[1].split("### Publish and find classified work")[0];
|
|
13
14
|
const docs = readFileSync(join(root, "docs/gentle-agents-activity.md"), "utf8")
|
|
14
15
|
.split("### Declare a recognizable subject\n")[1].split("### Publish curated state")[0];
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
|
|
9
|
+
import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, nativeRootSpelling, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
|
|
10
10
|
import { sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
|
|
11
11
|
|
|
12
12
|
// All Git and session writes belong to unique fixtures, never the live clone.
|
|
@@ -306,3 +306,15 @@ test("memoized identity still observes removal, nested repositories and expiry",
|
|
|
306
306
|
counted(join(f.dir, "empty"), f.dir);
|
|
307
307
|
assert.equal(calls, 4, "a missing repository is never memoized, so bootstrap is observed at once");
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test("Windows root spelling accepts forward slashes as the native separator only (gentle-shell#2314)", () => {
|
|
311
|
+
// Git prints C:/repo while realpath returns C:\\repo; both name the same root.
|
|
312
|
+
assert.equal(nativeRootSpelling("C:/path/to/sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
|
|
313
|
+
assert.equal(nativeRootSpelling("C:\\path\\to\\sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
|
|
314
|
+
// Only separators change: dot segments and trailing separators stay non-canonical.
|
|
315
|
+
assert.equal(nativeRootSpelling("C:/repo/.", "win32"), "C:\\repo\\.");
|
|
316
|
+
assert.equal(nativeRootSpelling("C:/repo/", "win32"), "C:\\repo\\");
|
|
317
|
+
// POSIX spelling is untouched; a backslash is an ordinary filename character there.
|
|
318
|
+
assert.equal(nativeRootSpelling("/srv/repo", "linux"), "/srv/repo");
|
|
319
|
+
assert.equal(nativeRootSpelling("/srv/re\\po", "darwin"), "/srv/re\\po");
|
|
320
|
+
});
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -219,6 +219,13 @@ test("renderShellBar renders one line with the segments in order", () => {
|
|
|
219
219
|
);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
test("renderShellBar paints the context gauge with the given gauge roles", () => {
|
|
223
|
+
const [line] = renderShellBar(model(), taggedTheme, 400, undefined, { ok: "gaugeOk", empty: "gaugeEmpty" });
|
|
224
|
+
assert.match(line, /<gaugeOk>▰▰▰▰<\/gaugeOk><gaugeEmpty>▱▱▱▱<\/gaugeEmpty>/);
|
|
225
|
+
const [fallback] = renderShellBar(model(), taggedTheme, 400);
|
|
226
|
+
assert.match(fallback, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
|
|
227
|
+
});
|
|
228
|
+
|
|
222
229
|
test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
|
|
223
230
|
const [line] = renderShellBar(model(), taggedTheme, 400);
|
|
224
231
|
assert.match(line, /<accent>✿ gentle shell<\/accent>/);
|
|
@@ -348,7 +355,7 @@ test("renderShellSidebarBar paints the Status card frame with border and the tit
|
|
|
348
355
|
});
|
|
349
356
|
|
|
350
357
|
test("sidebar unifies project, captured changes and integrations in one frame", () => {
|
|
351
|
-
const data = model({ changes: { files: 2, added: 7, deleted: 3
|
|
358
|
+
const data = model({ changes: { files: 2, added: 7, deleted: 3 }, statuses: ["MCP connected"] });
|
|
352
359
|
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
353
360
|
const text = lines.join("\n");
|
|
354
361
|
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
@@ -359,7 +366,6 @@ test("sidebar unifies project, captured changes and integrations in one frame",
|
|
|
359
366
|
previous = index;
|
|
360
367
|
}
|
|
361
368
|
assert.match(text, /2 files.*\+7.*−3/);
|
|
362
|
-
assert.match(text, /capture warning/);
|
|
363
369
|
assert.match(text, /\/gentle:changes/);
|
|
364
370
|
assert.match(text, /main/);
|
|
365
371
|
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
@@ -849,6 +855,19 @@ test("unified below-input float includes optional Changes and sanitized statuses
|
|
|
849
855
|
assert.equal(renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, changes), undefined);
|
|
850
856
|
});
|
|
851
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
|
+
|
|
852
871
|
test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) => {
|
|
853
872
|
const theme = withBackground(taggedTheme);
|
|
854
873
|
const data = model({ usage: USAGE_TWO_WINDOWS, statuses: ["mcp ok"] });
|
|
@@ -4,6 +4,7 @@ import { TuiAltScreen, visibleWidth, type Terminal, type TuiMouseEvent } from "@
|
|
|
4
4
|
import { CHANGE_STATUS, DIFF_TOO_LARGE, changesModel, type ChangedFile } from "../lib/shell-changes.ts";
|
|
5
5
|
import { WorktreeChangesView, ChangesView, colorDiff, type ChangesViewDeps } from "../lib/shell-changes-view.ts";
|
|
6
6
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
7
8
|
|
|
8
9
|
// The changes overlay: files on the left, the selected file's diff on the
|
|
9
10
|
// right, keys at the bottom. Rendering is pure; git access is injected.
|
|
@@ -458,7 +459,7 @@ const statusCases = [
|
|
|
458
459
|
[CHANGE_STATUS.ADDED, "A", 3, 0],
|
|
459
460
|
[CHANGE_STATUS.DELETED, "D", 0, 4],
|
|
460
461
|
[CHANGE_STATUS.RENAMED, "R", 0, 0],
|
|
461
|
-
[CHANGE_STATUS.UNTRACKED, "
|
|
462
|
+
[CHANGE_STATUS.UNTRACKED, "U", 5, 0],
|
|
462
463
|
] as const;
|
|
463
464
|
|
|
464
465
|
for (const [status, code, added, deleted] of statusCases) {
|
|
@@ -757,3 +758,138 @@ test("ChangesView.update keeps the selected file, reloads moved diffs, and survi
|
|
|
757
758
|
component.handleInput("j");
|
|
758
759
|
component.handleInput("o");
|
|
759
760
|
});
|
|
761
|
+
|
|
762
|
+
// Diff-only mode (F): the file column and its separator leave so the diff
|
|
763
|
+
// spans the whole inner width; j/k still walk files, esc returns first.
|
|
764
|
+
function diffOnlyView(overrides: { rows?: number; loadDiff?: (root: string, target: ChangedFile) => Promise<string> } = {}) {
|
|
765
|
+
const trees = [
|
|
766
|
+
{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) },
|
|
767
|
+
{ root: "/linked", branch: "agent", model: changesModel([file("c.ts", 1, 0)]) },
|
|
768
|
+
];
|
|
769
|
+
const events: string[] = [];
|
|
770
|
+
const component = new WorktreeChangesView(trees, {
|
|
771
|
+
theme: plainTheme, rows: overrides.rows ?? 10,
|
|
772
|
+
loadDiff: overrides.loadDiff ?? (async (root, target) => `+${root}:${target.path} ${"x".repeat(80)}`),
|
|
773
|
+
onOpen: (root, target) => { events.push(`open:${root}:${target.path}`); },
|
|
774
|
+
onClose: () => { events.push("close"); },
|
|
775
|
+
onRefresh: () => { events.push("refresh"); },
|
|
776
|
+
requestRender() {},
|
|
777
|
+
});
|
|
778
|
+
return { component, events };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
test("F hides the file list and its separator so the diff spans the full inner width", async () => {
|
|
782
|
+
const { component } = diffOnlyView();
|
|
783
|
+
component.handleInput("\r");
|
|
784
|
+
component.handleInput("j");
|
|
785
|
+
await settle();
|
|
786
|
+
const normal = component.render(100);
|
|
787
|
+
assert.match(normal[1]!, /^│ +▾ main · main +│ M a\.ts +│$/, "the normal view keeps the two-pane layout");
|
|
788
|
+
assert.match(stripAnsi(normal[2]!), /^│ ▸ M a\.ts .*│ \+\/main:a\.ts x+…│$/, "the narrow diff pane clips a long line");
|
|
789
|
+
assert.match(normal[8]!, /F diff only/);
|
|
790
|
+
component.handleInput("F");
|
|
791
|
+
await settle();
|
|
792
|
+
const wide = component.render(100);
|
|
793
|
+
assert.equal(wide.length, 10);
|
|
794
|
+
for (const line of wide) assert.equal(visibleWidth(line), 100);
|
|
795
|
+
assert.match(wide[1]!, /^│ M a\.ts +│$/, "the path header starts at the frame edge");
|
|
796
|
+
assert.equal(wide[2]!, `│ ${`+/main:a.ts ${"x".repeat(80)}`.padEnd(96)} │`, "the diff uses the full inner width");
|
|
797
|
+
assert.doesNotMatch(wide.slice(1, 8).join("\n"), /main · main|│.*│.*│/, "no list column and no separator remain");
|
|
798
|
+
assert.match(wide[8]!, /F files/);
|
|
799
|
+
component.handleInput("F");
|
|
800
|
+
assert.match(component.render(100)[1]!, /▾ main · main/, "F toggles back to the file list");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("j/k in diff-only mode walk files across worktrees and the path header follows", async () => {
|
|
804
|
+
const { component } = diffOnlyView();
|
|
805
|
+
component.handleInput("F");
|
|
806
|
+
await settle();
|
|
807
|
+
assert.match(component.render(100)[1]!, /^│ M a\.ts +│$/, "entering on a group header jumps to its first file");
|
|
808
|
+
component.handleInput("j");
|
|
809
|
+
await settle();
|
|
810
|
+
assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
|
|
811
|
+
component.handleInput("j");
|
|
812
|
+
await settle();
|
|
813
|
+
assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "j crosses into the next worktree's files");
|
|
814
|
+
assert.match(component.render(100)[2]!, /\+\/linked:c\.ts/);
|
|
815
|
+
component.handleInput("j");
|
|
816
|
+
assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "the last file stays selected");
|
|
817
|
+
component.handleInput("k");
|
|
818
|
+
await settle();
|
|
819
|
+
assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
|
|
820
|
+
component.handleInput("j");
|
|
821
|
+
component.handleInput("\x1b");
|
|
822
|
+
assert.match(component.render(100).join("\n"), /▸ M c\.ts/, "the files view shows the file chosen in diff-only mode");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("esc leaves diff-only mode before closing; q, o and r keep working there", async () => {
|
|
826
|
+
const { component, events } = diffOnlyView();
|
|
827
|
+
component.handleInput("F");
|
|
828
|
+
component.handleInput("\x1b");
|
|
829
|
+
assert.deepEqual(events, [], "the first esc only returns to the file list");
|
|
830
|
+
assert.match(component.render(100)[1]!, /main · main/);
|
|
831
|
+
component.handleInput("\x1b");
|
|
832
|
+
assert.deepEqual(events, ["close"]);
|
|
833
|
+
component.handleInput("F");
|
|
834
|
+
component.handleInput("o");
|
|
835
|
+
component.handleInput("r");
|
|
836
|
+
component.handleInput("q");
|
|
837
|
+
assert.deepEqual(events, ["close", "open:/main:a.ts", "refresh", "close"]);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("diff-only mode has no list mouse zone; the wheel scrolls the widened diff", async () => {
|
|
841
|
+
const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
|
|
842
|
+
const { component } = diffOnlyView({ rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}` });
|
|
843
|
+
component.handleInput("F");
|
|
844
|
+
await settle();
|
|
845
|
+
component.render(80);
|
|
846
|
+
assert.equal(component.handleMouse(mouseButton("move", "none", 3, 2, 8)), undefined, "no hover target");
|
|
847
|
+
assert.equal(component.handleMouse(mouseButton("click", "left", 3, 2, 8)), undefined, "no click target");
|
|
848
|
+
assert.match(component.render(80)[1]!, /^│ M a\.ts +│$/, "a click where the list was does not select");
|
|
849
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 3, 2, 3, 8)), { handled: true, render: true });
|
|
850
|
+
assert.doesNotMatch(component.render(80)[2]!, /@@/, "the left edge now scrolls the diff");
|
|
851
|
+
component.handleInput("\x1b");
|
|
852
|
+
component.render(80);
|
|
853
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 3, undefined, 8)), { handled: true, render: true }, "leaving diff-only restores the list zone");
|
|
854
|
+
assert.match(component.render(80)[3]!, /▸ M b\.ts/);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("the native fullscreen wrapper keeps keyboard, hover, click and wheel on the changes view", async () => {
|
|
858
|
+
let onInput: ((data: string) => void) | undefined;
|
|
859
|
+
const terminal: Terminal = {
|
|
860
|
+
start(input: Parameters<Terminal["start"]>[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 8; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {},
|
|
861
|
+
};
|
|
862
|
+
const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
|
|
863
|
+
let renders = 0;
|
|
864
|
+
const trees = [{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }];
|
|
865
|
+
const component = new WorktreeChangesView(trees, {
|
|
866
|
+
theme: { fg: (color: string, text: string) => color === "warning" ? `\x1b[33m${text}\x1b[39m` : text }, rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}`,
|
|
867
|
+
onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
868
|
+
});
|
|
869
|
+
const interaction = createNativeFullscreenInteraction({ keyboardTarget: component, requestRender: () => { renders++; } });
|
|
870
|
+
interaction.addChild(component);
|
|
871
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
|
|
872
|
+
tui.setLayoutRoot(interaction);
|
|
873
|
+
tui.start();
|
|
874
|
+
interaction.handleInput("\r");
|
|
875
|
+
assert.ok(renders > 0, "keys reach the view and request a render");
|
|
876
|
+
tui.renderNow(true);
|
|
877
|
+
try {
|
|
878
|
+
onInput?.("\x1b[<35;4;4M");
|
|
879
|
+
assert.match(interaction.render(80)[3]!, /\x1b\[33mM b\.ts/, "hover paints the row under the pointer");
|
|
880
|
+
onInput?.("\x1b[<0;4;4M");
|
|
881
|
+
onInput?.("\x1b[<0;4;4m");
|
|
882
|
+
assert.match(stripAnsi(interaction.render(80)[3]!), /▸ M b\.ts/, "a click selects the file");
|
|
883
|
+
await settle();
|
|
884
|
+
tui.renderNow(true);
|
|
885
|
+
onInput?.("\x1b[<65;60;3M");
|
|
886
|
+
assert.doesNotMatch(stripAnsi(interaction.render(80)[2]!), /@@/, "the wheel scrolls the diff");
|
|
887
|
+
interaction.handleInput("F");
|
|
888
|
+
tui.renderNow(true);
|
|
889
|
+
onInput?.("\x1b[<0;4;3M");
|
|
890
|
+
onInput?.("\x1b[<0;4;3m");
|
|
891
|
+
assert.match(stripAnsi(interaction.render(80)[1]!), /^│ M b\.ts +│$/, "diff-only mode ignores clicks where the list was");
|
|
892
|
+
} finally {
|
|
893
|
+
tui.stop();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
@@ -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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { paintGauge, renderGauge, type GaugePaint } from "../lib/shell-gauge.ts";
|
|
4
|
+
|
|
5
|
+
// Issue #2222: gauge-specific theme roles fall back to today's roles when a
|
|
6
|
+
// theme does not define them, so existing themes render byte-identically.
|
|
7
|
+
// (Fixtures here are unit-scoped stubs; Pi's theme schema is validated
|
|
8
|
+
// separately by the loader and was checked directly against ThemeJsonSchema.)
|
|
9
|
+
|
|
10
|
+
const taggedTheme = {
|
|
11
|
+
fg(color: string, text: string) {
|
|
12
|
+
return `<${color}>${text}</${color}>`;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("paintGauge falls back to today's roles when no gauge roles are given", () => {
|
|
17
|
+
assert.equal(paintGauge(45, taggedTheme), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
|
|
18
|
+
assert.equal(paintGauge(85, taggedTheme), `<warning>▰▰▰▰▰▰▰</warning><border>▱</border>`);
|
|
19
|
+
assert.equal(paintGauge(96, taggedTheme), `<error>▰▰▰▰▰▰▰▰</error><border></border>`);
|
|
20
|
+
assert.equal(paintGauge(null, taggedTheme), `<dim></dim><border>▱▱▱▱▱▱▱▱</border>`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("paintGauge uses the gauge-specific roles when the theme defines them", () => {
|
|
24
|
+
const roles: GaugePaint = { ok: "gaugeOk", warning: "gaugeWarning", error: "gaugeError", empty: "gaugeEmpty" };
|
|
25
|
+
assert.equal(paintGauge(45, taggedTheme, 8, roles), `<gaugeOk>▰▰▰▰</gaugeOk><gaugeEmpty>▱▱▱▱</gaugeEmpty>`);
|
|
26
|
+
assert.equal(paintGauge(85, taggedTheme, 8, roles), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><gaugeEmpty>▱</gaugeEmpty>`);
|
|
27
|
+
assert.equal(paintGauge(96, taggedTheme, 8, roles), `<gaugeError>▰▰▰▰▰▰▰▰</gaugeError><gaugeEmpty></gaugeEmpty>`);
|
|
28
|
+
// A null percentage stays dim: there is nothing to gauge yet.
|
|
29
|
+
assert.equal(paintGauge(null, taggedTheme, 8, roles), `<dim></dim><gaugeEmpty>▱▱▱▱▱▱▱▱</gaugeEmpty>`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("paintGauge falls back per role, not per block", () => {
|
|
33
|
+
// A theme that defines only gaugeWarning keeps accent for the ok range.
|
|
34
|
+
assert.equal(paintGauge(45, taggedTheme, 8, { warning: "gaugeWarning" }), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
|
|
35
|
+
assert.equal(paintGauge(85, taggedTheme, 8, { warning: "gaugeWarning" }), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><border>▱</border>`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("renderGauge uses theme-defined glyphs when given, defaults otherwise", () => {
|
|
39
|
+
assert.equal(renderGauge(50, 4, { filled: "█", empty: "░" }), "██░░");
|
|
40
|
+
assert.equal(renderGauge(50, 4), "▰▰▱▱");
|
|
41
|
+
assert.equal(renderGauge(50, 4, { filled: "█" }), "██▱▱");
|
|
42
|
+
});
|
|
@@ -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
|
+
});
|