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
package/lib/shell-bar.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
|
|
2
|
+
import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugePaint, type GaugeTone } from "./shell-gauge.ts";
|
|
3
3
|
import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts";
|
|
4
4
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
5
5
|
import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.ts";
|
|
@@ -22,7 +22,7 @@ export interface ShellBarModel {
|
|
|
22
22
|
review?: ReviewSidebarSnapshot;
|
|
23
23
|
jobs?: RunningJobDisplay[];
|
|
24
24
|
profile?: string;
|
|
25
|
-
changes?: { files: number; added: number; deleted: number
|
|
25
|
+
changes?: { files: number; added: number; deleted: number };
|
|
26
26
|
cwd: string;
|
|
27
27
|
branch: string | null;
|
|
28
28
|
dirty: number | undefined;
|
|
@@ -159,21 +159,21 @@ function executionSegment(modelId: string, effort: string | undefined, theme: Sh
|
|
|
159
159
|
: theme.fg(ROLE.MODEL, modelId);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function contextSegment(contextPercent: number | null, theme: ShellBarTheme): string {
|
|
162
|
+
function contextSegment(contextPercent: number | null, theme: ShellBarTheme, paint?: GaugePaint): string {
|
|
163
163
|
const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`;
|
|
164
|
-
return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
|
|
164
|
+
return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme, GAUGE_CELLS, paint)} ${theme.fg(ROLE.VALUE, percentText)}`;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string {
|
|
168
168
|
return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation): string[] {
|
|
171
|
+
function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation, paint?: GaugePaint): string[] {
|
|
172
172
|
const location = locationSegment(model, theme);
|
|
173
173
|
const modelSegment = executionSegment(model.modelId, model.effort, theme);
|
|
174
|
-
const context = contextSegment(model.contextPercent, theme);
|
|
174
|
+
const context = contextSegment(model.contextPercent, theme, paint);
|
|
175
175
|
const cost = costSegment(model.costTotal, model.subscription, theme);
|
|
176
|
-
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
|
|
176
|
+
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId, paint) : undefined;
|
|
177
177
|
const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
|
|
178
178
|
return [
|
|
179
179
|
...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]),
|
|
@@ -240,7 +240,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
240
240
|
changes?.files
|
|
241
241
|
? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}`
|
|
242
242
|
: label("No captured changes"),
|
|
243
|
-
...(changes?.notice ? [theme.fg("warning", sanitizeStatus(changes.notice))] : []),
|
|
244
243
|
label("/gentle:changes"),
|
|
245
244
|
],
|
|
246
245
|
}]),
|
|
@@ -295,11 +294,11 @@ function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showMod
|
|
|
295
294
|
const USAGE_LABEL_ROLE = ROLE.LABEL;
|
|
296
295
|
const USAGE_HINT_ROLE = "dim";
|
|
297
296
|
|
|
298
|
-
function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean): string {
|
|
297
|
+
function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean, paint?: GaugePaint): string {
|
|
299
298
|
const percent = `${Math.round(window.usedPercent)}%`;
|
|
300
299
|
const parts = [
|
|
301
300
|
...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []),
|
|
302
|
-
...(withGauge ? [paintGauge(window.usedPercent, theme)] : []),
|
|
301
|
+
...(withGauge ? [paintGauge(window.usedPercent, theme, GAUGE_CELLS, paint)] : []),
|
|
303
302
|
theme.fg(ROLE.VALUE, percent),
|
|
304
303
|
];
|
|
305
304
|
return parts.join(" ");
|
|
@@ -310,10 +309,10 @@ function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: b
|
|
|
310
309
|
// text only. A provider with no usage data at all has no windows to shape,
|
|
311
310
|
// so all three collapse to the bare "usage" label plus the shortcut hint.
|
|
312
311
|
type UsageStage = "full" | "text" | "primary";
|
|
313
|
-
function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined): string {
|
|
312
|
+
function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined, paint?: GaugePaint): string {
|
|
314
313
|
const label = theme.fg(USAGE_LABEL_ROLE, "usage");
|
|
315
314
|
const shown = stage === "primary" ? windows.slice(0, 1) : windows;
|
|
316
|
-
const body = shown.map((window) => usageWindowText(window, theme, stage === "full")).join(` ${theme.fg(ROLE.LABEL, "·")} `);
|
|
315
|
+
const body = shown.map((window) => usageWindowText(window, theme, stage === "full", paint)).join(` ${theme.fg(ROLE.LABEL, "·")} `);
|
|
317
316
|
const head = body.length > 0 ? `${label} ${body}` : label;
|
|
318
317
|
return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head;
|
|
319
318
|
}
|
|
@@ -338,16 +337,16 @@ function fitHeaderContent(text: string, width: number): string {
|
|
|
338
337
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
339
338
|
}
|
|
340
339
|
|
|
341
|
-
export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
|
|
340
|
+
export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
|
|
342
341
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
343
342
|
let content: ShellHeaderResult | undefined;
|
|
344
343
|
let contentWidth = targetWidth;
|
|
345
344
|
const row = floatHeaderRow(theme, targetWidth, (inner) => {
|
|
346
345
|
contentWidth = inner;
|
|
347
|
-
content = headerContent(model, theme, inner, usageHint, presentation);
|
|
346
|
+
content = headerContent(model, theme, inner, usageHint, presentation, paint);
|
|
348
347
|
return content.text;
|
|
349
348
|
});
|
|
350
|
-
if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation);
|
|
349
|
+
if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation, paint);
|
|
351
350
|
const { usageSpan } = content;
|
|
352
351
|
// The inset splits evenly around the content, so its left share is where
|
|
353
352
|
// the content's columns start.
|
|
@@ -356,8 +355,8 @@ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarThe
|
|
|
356
355
|
}
|
|
357
356
|
|
|
358
357
|
/** Shared geometry for the rail header and the below-editor header widget. */
|
|
359
|
-
export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation): ShellHeaderChrome {
|
|
360
|
-
const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation);
|
|
358
|
+
export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, paint?: GaugePaint): ShellHeaderChrome {
|
|
359
|
+
const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation, paint);
|
|
361
360
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
362
361
|
const painted = floatHeaderPaint(theme, targetWidth, () => "");
|
|
363
362
|
const rule = renderShellHeaderRule(theme, targetWidth);
|
|
@@ -377,12 +376,12 @@ export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: num
|
|
|
377
376
|
return y === chrome.headerRow && usageSpan !== undefined && x >= usageSpan.start && x < usageSpan.end;
|
|
378
377
|
}
|
|
379
378
|
|
|
380
|
-
function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
|
|
379
|
+
function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
|
|
381
380
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
382
381
|
const jobs = model.jobs ? theme.fg(ROLE.STATUS, shellJobsCount(model.jobs)) : undefined;
|
|
383
382
|
const ctxCost = joinSegments([
|
|
384
383
|
...(jobs ? [jobs] : []),
|
|
385
|
-
...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)]),
|
|
384
|
+
...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme, paint), costSegment(model.costTotal, model.subscription, theme)]),
|
|
386
385
|
], theme);
|
|
387
386
|
const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
|
|
388
387
|
const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false)
|
|
@@ -403,7 +402,7 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
|
|
|
403
402
|
{ leftIndex: leftStages.length - 1, usageStage: undefined },
|
|
404
403
|
];
|
|
405
404
|
for (const { leftIndex, usageStage } of attempts) {
|
|
406
|
-
const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
|
|
405
|
+
const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint, paint) : undefined;
|
|
407
406
|
const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
|
|
408
407
|
const left = joinSegments(leftStages[leftIndex]!, theme);
|
|
409
408
|
if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue;
|
|
@@ -422,12 +421,14 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
|
|
|
422
421
|
|
|
423
422
|
// A single owner supplies real captured filenames, not ShellBarModel's totals.
|
|
424
423
|
// Undefined keeps every legacy surface alive for style/width/theme transitions.
|
|
425
|
-
export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): ShellHeaderChrome | undefined {
|
|
424
|
+
export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): ShellHeaderChrome | undefined {
|
|
426
425
|
if (presentation?.headerPlacement !== "below-input") return undefined;
|
|
427
426
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
428
427
|
if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
|
|
429
|
-
const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation);
|
|
430
|
-
|
|
428
|
+
const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation, paint);
|
|
429
|
+
// The row exists only while files are actually tracked; an unborn-only root
|
|
430
|
+
// is silence here (/gentle:changes explains the state on demand).
|
|
431
|
+
const changesRow = Boolean(changes && changes.files.length > 0) && presentation.visibility.changes !== false
|
|
431
432
|
? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
|
|
432
433
|
: undefined;
|
|
433
434
|
const statuses = presentation.statusPlacement === "hidden" ? [] : [
|
|
@@ -456,12 +457,12 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
|
|
|
456
457
|
// header row's own cascade, so context, cost and usage outlive the location
|
|
457
458
|
// on small screens, and keeps extension statuses on a second line instead of
|
|
458
459
|
// dropping them the way the header deliberately does.
|
|
459
|
-
export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): string[] {
|
|
460
|
-
const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes);
|
|
460
|
+
export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): string[] {
|
|
461
|
+
const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes, paint);
|
|
461
462
|
if (grouped) return grouped.rows;
|
|
462
463
|
const headerModel = buildShellHeaderModel(model, true);
|
|
463
464
|
// Neon, missing backgrounds and sub-minimum widths keep the old row count.
|
|
464
|
-
const rows = [headerContent(headerModel, theme, width, usageHint, presentation).text];
|
|
465
|
+
const rows = [headerContent(headerModel, theme, width, usageHint, presentation, paint).text];
|
|
465
466
|
const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status));
|
|
466
467
|
return statuses.length ? [...rows, truncateToWidth(joinSegments(statuses, theme), Math.max(0, Math.floor(width)), "…")] : rows;
|
|
467
468
|
}
|
|
@@ -503,8 +504,8 @@ export function renderShellHeaderRule(theme: ShellBarTheme, width: number): stri
|
|
|
503
504
|
return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(targetWidth));
|
|
504
505
|
}
|
|
505
506
|
|
|
506
|
-
export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] {
|
|
507
|
-
let segments = buildSegments(model, theme, presentation);
|
|
507
|
+
export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation, paint?: GaugePaint): string[] {
|
|
508
|
+
let segments = buildSegments(model, theme, presentation, paint);
|
|
508
509
|
const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
|
|
509
510
|
|
|
510
511
|
let left = joinSegments(segments, theme);
|
|
@@ -67,12 +67,15 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
|
|
|
67
67
|
return lines;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// New files are two distinct states: `A` is tracked and staged, `U` is still
|
|
71
|
+
// untracked. Git's own `??` reads as a broken value in a status column, so the
|
|
72
|
+
// list names the state instead of echoing the plumbing code.
|
|
70
73
|
const FILE_STATUS = {
|
|
71
74
|
[CHANGE_STATUS.MODIFIED]: "M",
|
|
72
75
|
[CHANGE_STATUS.ADDED]: "A",
|
|
73
76
|
[CHANGE_STATUS.DELETED]: "D",
|
|
74
77
|
[CHANGE_STATUS.RENAMED]: "R",
|
|
75
|
-
[CHANGE_STATUS.UNTRACKED]: "
|
|
78
|
+
[CHANGE_STATUS.UNTRACKED]: "U",
|
|
76
79
|
} as const;
|
|
77
80
|
|
|
78
81
|
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
@@ -113,18 +116,41 @@ function fit(text: string, width: number): string {
|
|
|
113
116
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
//
|
|
117
|
-
|
|
119
|
+
// One geometry feeds both rendering and pointer hit-testing, so clicks stay
|
|
120
|
+
// aligned with what is painted. Diff-only drops the list column and its
|
|
121
|
+
// separator; its empty list zone receives no pointer events.
|
|
122
|
+
interface PaneLayout {
|
|
123
|
+
inner: number;
|
|
124
|
+
listWidth: number;
|
|
125
|
+
diffWidth: number;
|
|
126
|
+
listStart: number;
|
|
127
|
+
listEnd: number;
|
|
128
|
+
diffStart: number;
|
|
129
|
+
diffEnd: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function paneLayout(width: number, diffOnly: boolean): PaneLayout {
|
|
118
133
|
const inner = Math.max(8, width - 2);
|
|
134
|
+
if (diffOnly) return { inner, listWidth: 0, diffWidth: inner - 2, listStart: 0, listEnd: 0, diffStart: 2, diffEnd: inner };
|
|
119
135
|
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
120
136
|
const diffWidth = inner - listWidth - 4;
|
|
137
|
+
return { inner, listWidth, diffWidth, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Both standalone files and the worktree accordion use the original frame.
|
|
141
|
+
function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile, diffOnly = false): string[] {
|
|
142
|
+
const { inner, listWidth, diffWidth } = paneLayout(width, diffOnly);
|
|
121
143
|
const rightLines = headerFile ? [pathHeader(headerFile, theme, diffWidth), ...diffLines] : diffLines;
|
|
122
144
|
const titleText = truncateToWidth(title, inner - 4, "…");
|
|
123
145
|
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
|
|
124
146
|
const body: string[] = [];
|
|
125
147
|
for (let row = 0; row < rows; row += 1) {
|
|
126
|
-
const left = fit(leftLine(row), listWidth);
|
|
127
148
|
const right = fit(rightLines[row] ?? "", diffWidth);
|
|
149
|
+
if (diffOnly) {
|
|
150
|
+
body.push(`${theme.fg(ROLE.FRAME, "│")} ${right} ${theme.fg(ROLE.FRAME, "│")}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const left = fit(leftLine(row), listWidth);
|
|
128
154
|
body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
|
|
129
155
|
}
|
|
130
156
|
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
@@ -142,11 +168,9 @@ interface PointerLayout {
|
|
|
142
168
|
diffEnd: number;
|
|
143
169
|
}
|
|
144
170
|
|
|
145
|
-
function pointerLayout(width: number, bodyRows: number): PointerLayout {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
const diffWidth = inner - listWidth - 4;
|
|
149
|
-
return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
|
|
171
|
+
function pointerLayout(width: number, bodyRows: number, diffOnly = false): PointerLayout {
|
|
172
|
+
const { listStart, listEnd, diffStart, diffEnd } = paneLayout(width, diffOnly);
|
|
173
|
+
return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart, listEnd, diffStart, diffEnd };
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
function displayText(text: string): string {
|
|
@@ -186,6 +210,8 @@ export class WorktreeChangesView {
|
|
|
186
210
|
private disposed = false;
|
|
187
211
|
private leftPressActive = false;
|
|
188
212
|
private hoveredIndex: number | undefined;
|
|
213
|
+
// Not persisted: every opening starts with the file list visible.
|
|
214
|
+
private diffOnly = false;
|
|
189
215
|
private readonly expanded = new Set<string>();
|
|
190
216
|
private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
|
|
191
217
|
|
|
@@ -224,13 +250,27 @@ export class WorktreeChangesView {
|
|
|
224
250
|
handleInput(data: string): void {
|
|
225
251
|
if (this.disposed) return;
|
|
226
252
|
if (data === "r") return this.deps.onRefresh();
|
|
227
|
-
if (
|
|
253
|
+
if (data === "q") return this.deps.onClose();
|
|
254
|
+
if (matchesKey(data, Key.escape)) {
|
|
255
|
+
if (!this.diffOnly) return this.deps.onClose();
|
|
256
|
+
return this.setDiffOnly(false);
|
|
257
|
+
}
|
|
258
|
+
if (data === "F") return this.setDiffOnly(!this.diffOnly);
|
|
228
259
|
const rows = this.visibleRows();
|
|
229
260
|
const row = rows[this.selected];
|
|
230
261
|
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
|
|
231
262
|
if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
|
|
232
263
|
return;
|
|
233
264
|
}
|
|
265
|
+
if (this.diffOnly) {
|
|
266
|
+
// Only files have a diff: j/k walk every file, crossing groups.
|
|
267
|
+
if (data === "j" || matchesKey(data, Key.down)) this.stepFile(1);
|
|
268
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.stepFile(-1);
|
|
269
|
+
else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) return this.deps.onOpen(row.tree.root, row.file);
|
|
270
|
+
else return;
|
|
271
|
+
this.deps.requestRender();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
234
274
|
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
|
|
235
275
|
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
236
276
|
else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
|
|
@@ -315,9 +355,11 @@ export class WorktreeChangesView {
|
|
|
315
355
|
: paintHoverable(theme, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`, hovered, idleRole);
|
|
316
356
|
return `${marker} ${text}`;
|
|
317
357
|
};
|
|
318
|
-
const keys = theme.fg(ROLE.KEY_TEXT,
|
|
319
|
-
|
|
320
|
-
|
|
358
|
+
const keys = theme.fg(ROLE.KEY_TEXT, this.diffOnly
|
|
359
|
+
? "j/k file ctrl+j/k scroll o open r refresh F files esc files"
|
|
360
|
+
: "j/k select enter toggle/open ← fold ctrl+j/k scroll r refresh F diff only esc close");
|
|
361
|
+
this.pointerLayout = pointerLayout(width, height, this.diffOnly);
|
|
362
|
+
return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file, this.diffOnly);
|
|
321
363
|
}
|
|
322
364
|
|
|
323
365
|
invalidate(): void {
|
|
@@ -341,6 +383,31 @@ export class WorktreeChangesView {
|
|
|
341
383
|
return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
|
|
342
384
|
}
|
|
343
385
|
|
|
386
|
+
private setDiffOnly(on: boolean): void {
|
|
387
|
+
this.diffOnly = on;
|
|
388
|
+
this.hoveredIndex = undefined;
|
|
389
|
+
this.pointerLayout = undefined;
|
|
390
|
+
// A group header has no diff; diff-only starts on the next file.
|
|
391
|
+
if (on && !this.visibleRows()[this.selected]?.file) this.stepFile(1);
|
|
392
|
+
this.deps.requestRender();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Move to the next file in the fully expanded order, expanding its group
|
|
396
|
+
// so the file list shows the same selection after diff-only mode.
|
|
397
|
+
private stepFile(direction: 1 | -1): void {
|
|
398
|
+
const all: WorktreeRow[] = this.trees.flatMap((tree) => [{ tree }, ...tree.model.files.map((file) => ({ tree, file }))]);
|
|
399
|
+
const current = this.visibleRows()[this.selected];
|
|
400
|
+
let index = current ? all.findIndex((row) => rowKey(row) === rowKey(current)) : -1;
|
|
401
|
+
do index += direction;
|
|
402
|
+
while (index >= 0 && index < all.length && !all[index]!.file);
|
|
403
|
+
const target = all[index];
|
|
404
|
+
if (!target) return;
|
|
405
|
+
this.expanded.add(target.tree.root);
|
|
406
|
+
this.selected = this.visibleRows().findIndex((row) => rowKey(row) === rowKey(target));
|
|
407
|
+
this.ensureSelectedVisible();
|
|
408
|
+
this.ensurePreview();
|
|
409
|
+
}
|
|
410
|
+
|
|
344
411
|
private scrollList(delta: number, rows: number): boolean {
|
|
345
412
|
const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
|
|
346
413
|
if (next === this.listOffset) return false;
|
package/lib/shell-changes.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ChangesModel {
|
|
|
27
27
|
files: ChangedFile[];
|
|
28
28
|
added: number;
|
|
29
29
|
deleted: number;
|
|
30
|
+
/** Stored once per model build; the footer memo reads it every keystroke. */
|
|
31
|
+
digest?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface ChangesSnapshot {
|
|
@@ -63,7 +65,7 @@ const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
|
|
|
63
65
|
const RENAME_ARROW = " => ";
|
|
64
66
|
|
|
65
67
|
export function emptyChanges(): ChangesModel {
|
|
66
|
-
return { files: [], added: 0, deleted: 0 };
|
|
68
|
+
return { files: [], added: 0, deleted: 0, digest: EMPTY_CHANGES_DIGEST };
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function renamedPath(path: string): string {
|
|
@@ -124,9 +126,42 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
|
|
|
124
126
|
files,
|
|
125
127
|
added: files.reduce((total, file) => total + file.added, 0),
|
|
126
128
|
deleted: files.reduce((total, file) => total + file.deleted, 0),
|
|
129
|
+
digest: changesDigest({ files, added: 0, deleted: 0 }),
|
|
127
130
|
};
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
// The footer Status memo asks "did any change move?" on every keystroke, so
|
|
134
|
+
// the answer must be O(1) to read: the model stores this digest once per
|
|
135
|
+
// build (changesModel/emptyChanges) and consumers read the stored field.
|
|
136
|
+
// It is a 64-bit FNV-1a roll over every per-file tuple the renderer consumes
|
|
137
|
+
// (path, status, counts, diffRevision, countsUnavailable), so counts alone
|
|
138
|
+
// cannot miss a same-count edit. A hash collision could only skip one
|
|
139
|
+
// refresh until the next change moves the digest again -- it never renders a
|
|
140
|
+
// wrong picture -- but it is a probabilistic signal, not an exact one.
|
|
141
|
+
function fnv1a(hash: number, text: string): number {
|
|
142
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
143
|
+
hash ^= text.charCodeAt(index);
|
|
144
|
+
hash = Math.imul(hash, 0x01000193);
|
|
145
|
+
}
|
|
146
|
+
return hash >>> 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
150
|
+
const SECOND_SEED = 0x811c9dc5 ^ 0x9e3779b9;
|
|
151
|
+
|
|
152
|
+
export function changesDigest(model: ChangesModel): string {
|
|
153
|
+
let first = FNV_OFFSET_BASIS;
|
|
154
|
+
let second = SECOND_SEED;
|
|
155
|
+
for (const file of model.files) {
|
|
156
|
+
const tuple = `\u0000${file.path}\u0000${file.status}\u0000${file.added}\u0000${file.deleted}\u0000${file.diffRevision ?? ""}\u0000${file.countsUnavailable ?? ""}`;
|
|
157
|
+
first = fnv1a(first, tuple);
|
|
158
|
+
second = fnv1a(second, tuple);
|
|
159
|
+
}
|
|
160
|
+
return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const EMPTY_CHANGES_DIGEST = changesDigest({ files: [], added: 0, deleted: 0 });
|
|
164
|
+
|
|
130
165
|
export function changesSummary(model: ChangesModel): string {
|
|
131
166
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
132
167
|
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
@@ -134,19 +169,39 @@ export function changesSummary(model: ChangesModel): string {
|
|
|
134
169
|
|
|
135
170
|
// One line: summary, the files joined by dots, and the command pushed to
|
|
136
171
|
// the right edge. The file list goes first when the terminal is narrow.
|
|
172
|
+
// The idle widget exists only while files are tracked. An unborn root (no
|
|
173
|
+
// commits yet) is silence here: /gentle:changes explains the state on demand,
|
|
174
|
+
// where the message has room and context.
|
|
175
|
+
//
|
|
176
|
+
// Both surfaces that paint it (the sidebar part and the below-input header
|
|
177
|
+
// row) re-render every frame, so the paint is memoized per digest, width and
|
|
178
|
+
// theme identity -- one stored frame, like CodemodeCard's cachedFrame. The
|
|
179
|
+
// digest already rolls up every per-file detail the paint consumes (path,
|
|
180
|
+
// status, counts, diffRevision, countsUnavailable), so a digest hit skips the
|
|
181
|
+
// per-file join entirely; a digest-less (hand-built) model never caches, and
|
|
182
|
+
// a new theme object repaints.
|
|
183
|
+
type WidgetFrame = { digest: string; width: number; theme: ChangesTheme; lines: string[] };
|
|
184
|
+
let widgetFrame: WidgetFrame | undefined;
|
|
185
|
+
|
|
137
186
|
export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
|
|
138
187
|
if (model.files.length === 0) return [];
|
|
188
|
+
if (model.digest !== undefined && widgetFrame?.digest === model.digest && widgetFrame.width === width && widgetFrame.theme === theme) return widgetFrame.lines;
|
|
139
189
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
140
190
|
const dot = theme.fg("muted", "·");
|
|
141
191
|
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
142
192
|
const hint = theme.fg("dim", CHANGES_COMMAND);
|
|
143
193
|
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
|
|
144
194
|
const left = `${head} ${dot} ${list}`;
|
|
195
|
+
let lines: string[];
|
|
145
196
|
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
146
|
-
if (gap >= 2)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
if (gap >= 2) lines = [`${left}${" ".repeat(gap)}${hint}`];
|
|
198
|
+
else {
|
|
199
|
+
const headGap = width - visibleWidth(head) - visibleWidth(hint);
|
|
200
|
+
lines = headGap >= 2 ? [`${head}${" ".repeat(headGap)}${hint}`] : [truncateToWidth(head, width, "…")];
|
|
201
|
+
}
|
|
202
|
+
if (model.digest === undefined) return lines;
|
|
203
|
+
widgetFrame = { digest: model.digest, width, theme, lines };
|
|
204
|
+
return lines;
|
|
150
205
|
}
|
|
151
206
|
|
|
152
207
|
export interface GitResult {
|
|
@@ -199,12 +254,24 @@ export async function foreignRootBranch(git: GitRunner): Promise<string | undefi
|
|
|
199
254
|
return verified.code === 0 ? branch : "no commits yet";
|
|
200
255
|
}
|
|
201
256
|
|
|
257
|
+
// A root whose HEAD is unborn (no commits yet) has no baseline: `git diff
|
|
258
|
+
// HEAD` is meaningless and `git status` reports every file as untracked.
|
|
259
|
+
// Worktree-holder folders -- repositories that exist only to parent linked
|
|
260
|
+
// worktrees -- look exactly like this, and scanning them floods Changes with
|
|
261
|
+
// thousands of phantom untracked files (the whole holder's contents). One
|
|
262
|
+
// cheap verify answers "is there anything to diff against" before a scan.
|
|
263
|
+
const HEAD_EXISTS_ARGS = ["rev-parse", "--verify", "-q", "HEAD"];
|
|
264
|
+
|
|
202
265
|
// /gentle:changes: one group per registered root holding its working tree
|
|
203
266
|
// against HEAD. A root whose Git fails (timed out, rejected) has no group but
|
|
204
267
|
// is listed in failedRoots: a failed scan is never reported as a clean root.
|
|
268
|
+
// A root with no commits has no group either, listed in unbornRoots: there is
|
|
269
|
+
// nothing to track yet, and the bar says so instead of scanning the holder.
|
|
205
270
|
export class WorktreeChangesTracker {
|
|
206
271
|
worktrees: WorktreeChanges[] = [];
|
|
207
272
|
failedRoots: string[] = [];
|
|
273
|
+
/** Registered roots whose HEAD is unborn: skipped, never scanned. */
|
|
274
|
+
unbornRoots: string[] = [];
|
|
208
275
|
scannedRootCount = 0;
|
|
209
276
|
private inFlight: Promise<ChangesModel> | undefined;
|
|
210
277
|
private readonly discover: GitRunner;
|
|
@@ -266,9 +333,17 @@ export class WorktreeChangesTracker {
|
|
|
266
333
|
const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
|
|
267
334
|
// Roots scan in parallel so one slow root does not delay the others.
|
|
268
335
|
const failed: string[] = [];
|
|
336
|
+
const unborn: string[] = [];
|
|
269
337
|
const scanned = await Promise.all([...roots].map(async (root): Promise<WorktreeChanges | undefined> => {
|
|
270
338
|
const known = metadata.get(root);
|
|
271
339
|
try {
|
|
340
|
+
// Only an answered "no" means unborn: a rejected verify cannot tell
|
|
341
|
+
// unborn from broken Git, so the scan below still decides.
|
|
342
|
+
const head = await this.gitForRoot(root)(HEAD_EXISTS_ARGS).catch(() => undefined);
|
|
343
|
+
if (head && head.code !== 0) {
|
|
344
|
+
unborn.push(root);
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
272
347
|
const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root), this.revisionForRoot(root));
|
|
273
348
|
await tracker.start();
|
|
274
349
|
if (!tracker.statusRead) {
|
|
@@ -301,6 +376,7 @@ export class WorktreeChangesTracker {
|
|
|
301
376
|
if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
|
|
302
377
|
this.worktrees = trees;
|
|
303
378
|
this.failedRoots = failed;
|
|
379
|
+
this.unbornRoots = unborn;
|
|
304
380
|
this.scannedRootCount = roots.size;
|
|
305
381
|
return this.model;
|
|
306
382
|
}
|
package/lib/shell-gauge.ts
CHANGED
|
@@ -20,10 +20,38 @@ const GAUGE_EMPTY_ROLE = "border";
|
|
|
20
20
|
const WARNING_THRESHOLD = 80;
|
|
21
21
|
const ERROR_THRESHOLD = 95;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Gauge-specific theme roles (issue #2222). Every field is optional and falls
|
|
24
|
+
// back to the shared role the gauge painted with before gauge roles existed,
|
|
25
|
+
// so themes without a gauge block render byte-identically to today.
|
|
26
|
+
export interface GaugePaint {
|
|
27
|
+
/** Filled cells below the warning threshold; falls back to `accent`. */
|
|
28
|
+
ok?: string;
|
|
29
|
+
/** Filled cells from 80%; falls back to `warning`. */
|
|
30
|
+
warning?: string;
|
|
31
|
+
/** Filled cells from 95%; falls back to `error`. */
|
|
32
|
+
error?: string;
|
|
33
|
+
/** Empty cells; falls back to `border`. */
|
|
34
|
+
empty?: string;
|
|
35
|
+
/** Filled-cell glyph; falls back to `▰`. */
|
|
36
|
+
filled?: string;
|
|
37
|
+
/** Empty-cell glyph; falls back to `▱`. */
|
|
38
|
+
emptyGlyph?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GaugeGlyphs {
|
|
42
|
+
filled: string;
|
|
43
|
+
empty: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function gaugeGlyphs(glyphs?: Partial<GaugeGlyphs>): GaugeGlyphs {
|
|
47
|
+
return { filled: glyphs?.filled ?? GAUGE_FILLED, empty: glyphs?.empty ?? GAUGE_EMPTY };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS, glyphs?: Partial<GaugeGlyphs>): string {
|
|
24
51
|
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
|
25
52
|
const filled = Math.round((clamped / 100) * cells);
|
|
26
|
-
|
|
53
|
+
const [filledGlyph, emptyGlyph] = [glyphs?.filled ?? GAUGE_FILLED, glyphs?.empty ?? GAUGE_EMPTY];
|
|
54
|
+
return filledGlyph.repeat(filled) + emptyGlyph.repeat(cells - filled);
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
export function gaugeTone(percent: number | null): GaugeTone {
|
|
@@ -33,8 +61,23 @@ export function gaugeTone(percent: number | null): GaugeTone {
|
|
|
33
61
|
return GAUGE_TONE.ACCENT;
|
|
34
62
|
}
|
|
35
63
|
|
|
36
|
-
export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
64
|
+
export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS, paint?: GaugePaint): string {
|
|
65
|
+
const glyphs = gaugeGlyphs(paint && { filled: paint.filled, empty: paint.emptyGlyph });
|
|
66
|
+
const gauge = renderGauge(percent, cells, glyphs);
|
|
67
|
+
const filled = stripTrailing(gauge, glyphs.empty);
|
|
68
|
+
// Token names go through theme.fg; raw ANSI prefixes (resolved from direct
|
|
69
|
+
// colors by gauge-theme.ts) paint directly, so the gauges alone recolor.
|
|
70
|
+
const isAnsi = (role: string | undefined): role is string => typeof role === "string" && role.startsWith("\x1b[");
|
|
71
|
+
const paintFg = (role: string | undefined, fallback: string, text: string): string => (isAnsi(role) ? `${role}${text}\x1b[39m` : theme.fg(role ?? fallback, text));
|
|
72
|
+
const tone = gaugeTone(percent);
|
|
73
|
+
const filledRole = tone === GAUGE_TONE.ACCENT ? paint?.ok : tone === GAUGE_TONE.WARNING ? paint?.warning : tone === GAUGE_TONE.ERROR ? paint?.error : undefined;
|
|
74
|
+
return paintFg(filledRole, tone, filled) + paintFg(paint?.empty, GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strips the maximal run of trailing empty glyphs without building a RegExp
|
|
78
|
+
// from theme-authored strings, which could contain regex metacharacters.
|
|
79
|
+
function stripTrailing(gauge: string, empty: string): string {
|
|
80
|
+
let end = gauge.length;
|
|
81
|
+
while (end >= empty.length && end > 0 && gauge.endsWith(empty, end)) end -= empty.length;
|
|
82
|
+
return gauge.slice(0, end);
|
|
40
83
|
}
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -23,10 +23,10 @@ const PETAL_TONE = {
|
|
|
23
23
|
|
|
24
24
|
export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
export const
|
|
26
|
+
// Render-driven animation (gentle-shell#994): the petal advances inside the
|
|
27
|
+
// prompt's own frames while streaming, so the only timer left is a watchdog
|
|
28
|
+
// that repaints silent working phases (long tool runs with no output).
|
|
29
|
+
export const SHELL_PULSE_WATCHDOG_MS = 1000;
|
|
30
30
|
export const SHELL_SCANNER_STEPS = 15;
|
|
31
31
|
const SCANNER_ENTRY_OFFSET = 3;
|
|
32
32
|
|