gentle-pi 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { TuiAltScreen, visibleWidth, type Terminal, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { CHANGE_STATUS, changesModel, type ChangedFile } from "../lib/shell-changes.ts";
|
|
5
|
+
import { WorktreeChangesView, ChangesView, colorDiff, type ChangesViewDeps } from "../lib/shell-changes-view.ts";
|
|
6
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// The changes overlay: files on the left, the selected file's diff on the
|
|
9
|
+
// right, keys at the bottom. Rendering is pure; git access is injected.
|
|
10
|
+
|
|
11
|
+
const plainTheme = {
|
|
12
|
+
fg(_color: string, text: string) {
|
|
13
|
+
return text;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const taggedTheme = {
|
|
18
|
+
fg(color: string, text: string) {
|
|
19
|
+
return `<${color}>${text}</${color}>`;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function file(path: string, added: number, deleted: number, status: ChangedFile["status"] = CHANGE_STATUS.MODIFIED): ChangedFile {
|
|
24
|
+
return { path, added, deleted, status };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DIFF_A = ["diff --git a/lib/a.ts b/lib/a.ts", "index 1..2 100644", "--- a/lib/a.ts", "+++ b/lib/a.ts", "@@ -1,2 +1,3 @@", " const a = 1;", "-const b = 2;", "+const b = 3;", "+const c = 4;"].join("\n");
|
|
28
|
+
|
|
29
|
+
function view(overrides: Partial<ChangesViewDeps> = {}, files = [file("lib/a.ts", 2, 1), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]) {
|
|
30
|
+
const calls: string[] = [];
|
|
31
|
+
const events: string[] = [];
|
|
32
|
+
const deps: ChangesViewDeps = {
|
|
33
|
+
theme: plainTheme,
|
|
34
|
+
rows: 12,
|
|
35
|
+
async loadDiff(target) {
|
|
36
|
+
calls.push(target.path);
|
|
37
|
+
return target.path === "lib/a.ts" ? DIFF_A : "";
|
|
38
|
+
},
|
|
39
|
+
onOpen(target) {
|
|
40
|
+
events.push(`open:${target.path}`);
|
|
41
|
+
},
|
|
42
|
+
onClose() {
|
|
43
|
+
events.push("close");
|
|
44
|
+
},
|
|
45
|
+
requestRender() {
|
|
46
|
+
events.push("render");
|
|
47
|
+
},
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
return { view: new ChangesView(changesModel(files), deps), calls, events };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function settle(): Promise<void> {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mouse(type: TuiMouseEvent["type"], x: number, y: number, wheelDelta?: number, height = 12): TuiMouseEvent {
|
|
58
|
+
return { type, button: type === "wheel" ? "none" : "left", x, y, screenX: x, screenY: y, width: 80, height, shift: false, alt: false, ctrl: false, wheelDelta };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mouseButton(type: TuiMouseEvent["type"], button: TuiMouseEvent["button"], x: number, y: number, height: number): TuiMouseEvent {
|
|
62
|
+
return { type, button, x, y, screenX: x, screenY: y, width: 80, height, shift: false, alt: false, ctrl: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("worktree accordion keeps groups and nested files beside a framed lazy diff", async () => {
|
|
66
|
+
const trees = ["/main", "/linked"].map((root) => ({ root, branch: root === "/main" ? "main" : undefined, model: changesModel([file("same.ts", 1, 0)]) }));
|
|
67
|
+
const loaded: string[] = [];
|
|
68
|
+
const opened: string[] = [];
|
|
69
|
+
let closed = 0;
|
|
70
|
+
const component = new WorktreeChangesView(trees, {
|
|
71
|
+
theme: plainTheme, rows: 10,
|
|
72
|
+
loadDiff: async (root, target) => { loaded.push(root); return `+${root}:${target.path}`; },
|
|
73
|
+
onOpen: (root, target) => { opened.push(`${root}:${target.path}`); },
|
|
74
|
+
onClose: () => { closed++; }, requestRender() {}, onRefresh() {},
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(loaded, [], "list must not eagerly load diffs");
|
|
77
|
+
assert.match(component.render(100).join("\n"), /detached · linked/);
|
|
78
|
+
component.handleInput("\r");
|
|
79
|
+
assert.deepEqual(loaded, [], "expanding a header must not select a file");
|
|
80
|
+
component.handleInput("j");
|
|
81
|
+
await settle();
|
|
82
|
+
const lines = component.render(100);
|
|
83
|
+
assert.equal(lines.length, 10);
|
|
84
|
+
assert.match(lines[0], /^╭─ ✎ Changes/);
|
|
85
|
+
assert.match(lines[1], /^│ ▾ main · main +│ \+\/main:same.ts +│$/);
|
|
86
|
+
assert.match(lines[2], /^│ ▸ M same.ts +\+1 -0 +│/);
|
|
87
|
+
assert.match(lines[3], /^│ ▸ detached · linked +│/);
|
|
88
|
+
assert.match(lines[9], /^╰─+╯$/);
|
|
89
|
+
for (const line of lines) assert.equal(visibleWidth(line), 100);
|
|
90
|
+
assert.match(component.render(100).join("\n"), /\+\/main:same.ts/);
|
|
91
|
+
component.handleInput("o");
|
|
92
|
+
component.handleInput("j");
|
|
93
|
+
assert.doesNotMatch(component.render(100).join("\n"), /\+\/main:same.ts/, "header selection clears unrelated preview");
|
|
94
|
+
assert.match(component.render(100)[2], /^│ M same.ts +\+1 -0 +│/, "unselected children retain their fixed marker and indentation");
|
|
95
|
+
component.handleInput(" ");
|
|
96
|
+
component.handleInput("j");
|
|
97
|
+
await settle();
|
|
98
|
+
assert.match(component.render(100).join("\n"), /▾ main · main/, "multiple groups remain expanded");
|
|
99
|
+
assert.match(component.render(100).join("\n"), /\+\/linked:same.ts/);
|
|
100
|
+
assert.doesNotMatch(component.render(100).join("\n"), /\+\/main:same.ts/);
|
|
101
|
+
component.handleInput("\r");
|
|
102
|
+
assert.deepEqual(opened, ["/main:same.ts", "/linked:same.ts"]);
|
|
103
|
+
assert.deepEqual(loaded, ["/main", "/linked"]);
|
|
104
|
+
component.update([trees[0]]);
|
|
105
|
+
assert.match(component.render(100).join("\n"), /main · main/);
|
|
106
|
+
component.handleInput("\x1b");
|
|
107
|
+
assert.equal(closed, 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("worktree accordion selects a clicked file without opening its editor", () => {
|
|
111
|
+
const opened: string[] = [];
|
|
112
|
+
const component = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }], {
|
|
113
|
+
theme: plainTheme, rows: 8, loadDiff: async () => "+preview",
|
|
114
|
+
onOpen: (_root, target) => { opened.push(target.path); }, onClose() {}, onRefresh() {}, requestRender() {},
|
|
115
|
+
});
|
|
116
|
+
component.handleInput("\r");
|
|
117
|
+
component.render(80);
|
|
118
|
+
const result = component.handleMouse({ type: "click", button: "left", x: 3, y: 3, screenX: 3, screenY: 3, width: 80, height: 8, shift: false, alt: false, ctrl: false });
|
|
119
|
+
assert.deepEqual(result, { handled: true, render: true });
|
|
120
|
+
assert.match(component.render(80)[3], /▸ M b\.ts/);
|
|
121
|
+
assert.deepEqual(opened, []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("worktree accordion receives native fullscreen press and release as a file click", async () => {
|
|
125
|
+
let onInput: ((data: string) => void) | undefined;
|
|
126
|
+
const terminal: Terminal = {
|
|
127
|
+
start(input) { 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() {},
|
|
128
|
+
};
|
|
129
|
+
const opened: string[] = [];
|
|
130
|
+
const component = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }], {
|
|
131
|
+
theme: plainTheme, rows: 8, loadDiff: async () => "+preview",
|
|
132
|
+
onOpen: (_root, target) => { opened.push(target.path); }, onClose() {}, onRefresh() {}, requestRender() {},
|
|
133
|
+
});
|
|
134
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
|
|
135
|
+
tui.setLayoutRoot(component);
|
|
136
|
+
tui.start();
|
|
137
|
+
component.handleInput("\r");
|
|
138
|
+
tui.renderNow(true);
|
|
139
|
+
try {
|
|
140
|
+
onInput?.("\x1b[<0;4;4M");
|
|
141
|
+
onInput?.("\x1b[<0;4;4m");
|
|
142
|
+
assert.match(component.render(80)[3], /▸ M b\.ts/);
|
|
143
|
+
assert.deepEqual(opened, []);
|
|
144
|
+
} finally {
|
|
145
|
+
tui.stop();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("worktree pointer regions scroll independently and expire on refresh or dispose", async () => {
|
|
150
|
+
const files = Array.from({ length: 8 }, (_, index) => file(`file-${index}.ts`, 1, 0));
|
|
151
|
+
const long = Array.from({ length: 20 }, (_, index) => `+line ${index}`).join("\n");
|
|
152
|
+
const trees = [{ root: "/main", branch: "main", model: changesModel(files) }];
|
|
153
|
+
const component = new WorktreeChangesView(trees, {
|
|
154
|
+
theme: plainTheme, rows: 8, loadDiff: async () => `@@ -0,0 +1,20 @@\n${long}`,
|
|
155
|
+
onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
156
|
+
});
|
|
157
|
+
component.handleInput("\r");
|
|
158
|
+
component.handleInput("j");
|
|
159
|
+
await settle();
|
|
160
|
+
component.render(80);
|
|
161
|
+
assert.deepEqual(component.handleMouse(mouse("move", 3, 2, undefined, 8)), undefined, "hover must not select");
|
|
162
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 3, 1, 2, 8)), { handled: true, render: true });
|
|
163
|
+
assert.match(component.render(80)[1], /M file-1\.ts/);
|
|
164
|
+
component.update(trees);
|
|
165
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 3, 1, 1, 8)), { handled: true, render: false }, "refresh invalidates stale pointer geometry");
|
|
166
|
+
assert.match(component.render(80)[1], /M file-1\.ts/, "unchanged polling keeps the manual list viewport");
|
|
167
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 50, 1, 2, 8)), { handled: true, render: true });
|
|
168
|
+
assert.doesNotMatch(component.render(80)[1], /@@/);
|
|
169
|
+
component.dispose();
|
|
170
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 1, undefined, 8)), { handled: true, render: false });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("file-list pointer capture is limited to an active left gesture in both views", () => {
|
|
174
|
+
const { view: standalone } = view();
|
|
175
|
+
const accordion = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0)]) }], {
|
|
176
|
+
theme: plainTheme, rows: 8, loadDiff: async () => "+preview", onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
177
|
+
});
|
|
178
|
+
accordion.handleInput("\r");
|
|
179
|
+
standalone.render(80);
|
|
180
|
+
accordion.render(80);
|
|
181
|
+
for (const [component, y, height] of [[standalone, 1, 12], [accordion, 2, 8]] as const) {
|
|
182
|
+
for (const button of ["right", "middle"] as const) {
|
|
183
|
+
assert.equal(component.handleMouse(mouseButton("press", button, 3, y, height)), undefined, `${button} press must reach pi-tui fallback`);
|
|
184
|
+
assert.equal(component.handleMouse(mouseButton("release", button, 3, y, height)), undefined, `${button} release must reach pi-tui fallback`);
|
|
185
|
+
}
|
|
186
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, height)), { handled: true, capture: true, render: false });
|
|
187
|
+
assert.deepEqual(component.handleMouse(mouseButton("release", "none", 3, y, height)), { handled: true, render: false });
|
|
188
|
+
}
|
|
189
|
+
standalone.handleMouse(mouseButton("press", "left", 3, 1, 12));
|
|
190
|
+
standalone.update(changesModel([file("a.ts", 1, 0)]));
|
|
191
|
+
standalone.render(80);
|
|
192
|
+
assert.equal(standalone.handleMouse(mouseButton("release", "none", 3, 1, 12)), undefined, "update clears a captured left gesture");
|
|
193
|
+
accordion.handleMouse(mouseButton("press", "left", 3, 2, 8));
|
|
194
|
+
accordion.invalidate();
|
|
195
|
+
accordion.render(80);
|
|
196
|
+
assert.equal(accordion.handleMouse(mouseButton("release", "none", 3, 2, 8)), undefined, "invalidate clears a captured left gesture");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an outside release clears an active left gesture in both views", () => {
|
|
200
|
+
const { view: standalone } = view();
|
|
201
|
+
const accordion = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0)]) }], {
|
|
202
|
+
theme: plainTheme, rows: 8, loadDiff: async () => "+preview", onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
203
|
+
});
|
|
204
|
+
accordion.handleInput("\r");
|
|
205
|
+
standalone.render(80);
|
|
206
|
+
accordion.render(80);
|
|
207
|
+
for (const [component, y, height] of [[standalone, 1, 12], [accordion, 2, 8]] as const) {
|
|
208
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, height)), { handled: true, capture: true, render: false });
|
|
209
|
+
assert.deepEqual(component.handleMouse(mouseButton("release", "none", 50, y, height)), { handled: true, render: false }, "captured release outside the file pane clears state");
|
|
210
|
+
assert.equal(component.handleMouse(mouseButton("release", "none", 3, y, height)), undefined, "a later unrelated release must not consume stale state");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a stale-layout release clears an active left gesture in both views", () => {
|
|
215
|
+
let standaloneRows = 12;
|
|
216
|
+
let accordionRows = 8;
|
|
217
|
+
const { view: standalone } = view({ rows: () => standaloneRows });
|
|
218
|
+
const accordion = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0)]) }], {
|
|
219
|
+
theme: plainTheme, rows: () => accordionRows, loadDiff: async () => "+preview", onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
220
|
+
});
|
|
221
|
+
accordion.handleInput("\r");
|
|
222
|
+
standalone.render(80);
|
|
223
|
+
accordion.render(80);
|
|
224
|
+
for (const [component, y, beforeHeight, afterHeight, resize] of [
|
|
225
|
+
[standalone, 1, 12, 8, () => { standaloneRows = 8; }],
|
|
226
|
+
[accordion, 2, 8, 6, () => { accordionRows = 6; }],
|
|
227
|
+
] as const) {
|
|
228
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, beforeHeight)), { handled: true, capture: true, render: false });
|
|
229
|
+
resize();
|
|
230
|
+
for (const button of ["right", "middle"] as const) assert.equal(component.handleMouse(mouseButton("release", button, 3, y, afterHeight)), undefined, `${button} release must still reach pi-tui with stale geometry`);
|
|
231
|
+
assert.deepEqual(component.handleMouse(mouseButton("release", "none", 3, y, afterHeight)), { handled: true, render: false }, "the captured release is accepted despite stale geometry");
|
|
232
|
+
component.render(80);
|
|
233
|
+
assert.equal(component.handleMouse(mouseButton("release", "none", 3, y, afterHeight)), undefined, "a later unrelated release must not consume stale state");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("non-left gestures pass through with current, stale, and missing layouts in both views", () => {
|
|
238
|
+
let standaloneRows = 12;
|
|
239
|
+
let accordionRows = 8;
|
|
240
|
+
const { view: standalone } = view({ rows: () => standaloneRows });
|
|
241
|
+
const accordion = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0)]) }], {
|
|
242
|
+
theme: plainTheme, rows: () => accordionRows, loadDiff: async () => "+preview", onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
243
|
+
});
|
|
244
|
+
accordion.handleInput("\r");
|
|
245
|
+
standalone.render(80);
|
|
246
|
+
accordion.render(80);
|
|
247
|
+
for (const [component, y, currentHeight, staleHeight, resize] of [
|
|
248
|
+
[standalone, 1, 12, 8, () => { standaloneRows = 8; }],
|
|
249
|
+
[accordion, 2, 8, 6, () => { accordionRows = 6; }],
|
|
250
|
+
] as const) {
|
|
251
|
+
resize();
|
|
252
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, staleHeight)), { handled: true, render: false }, "stale geometry does not capture a left press");
|
|
253
|
+
for (const button of ["right", "middle"] as const) {
|
|
254
|
+
assert.equal(component.handleMouse(mouseButton("press", button, 3, y, staleHeight)), undefined, `${button} press must reach pi-tui with stale geometry`);
|
|
255
|
+
assert.equal(component.handleMouse(mouseButton("release", button, 3, y, staleHeight)), undefined, `${button} release must reach pi-tui with stale geometry`);
|
|
256
|
+
}
|
|
257
|
+
component.render(80);
|
|
258
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, staleHeight)), { handled: true, capture: true, render: false }, "current geometry captures a left press");
|
|
259
|
+
assert.deepEqual(component.handleMouse(mouseButton("release", "none", 3, y, staleHeight)), { handled: true, render: false }, "current geometry clears the active left release");
|
|
260
|
+
for (const button of ["right", "middle"] as const) {
|
|
261
|
+
assert.equal(component.handleMouse(mouseButton("press", button, 3, y, staleHeight)), undefined, `${button} press must reach pi-tui with current geometry`);
|
|
262
|
+
assert.equal(component.handleMouse(mouseButton("release", button, 3, y, staleHeight)), undefined, `${button} release must reach pi-tui with current geometry`);
|
|
263
|
+
}
|
|
264
|
+
component.invalidate();
|
|
265
|
+
assert.deepEqual(component.handleMouse(mouseButton("press", "left", 3, y, currentHeight)), { handled: true, render: false }, "missing geometry does not capture a left press");
|
|
266
|
+
for (const button of ["right", "middle"] as const) {
|
|
267
|
+
assert.equal(component.handleMouse(mouseButton("press", button, 3, y, currentHeight)), undefined, `${button} press must reach pi-tui without geometry`);
|
|
268
|
+
assert.equal(component.handleMouse(mouseButton("release", button, 3, y, currentHeight)), undefined, `${button} release must reach pi-tui without geometry`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("right press reaches the native Windows paste fallback when eligible", () => {
|
|
274
|
+
let onInput: ((data: string) => void) | undefined;
|
|
275
|
+
let pasted = 0;
|
|
276
|
+
const terminal: Terminal = {
|
|
277
|
+
start(input) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {},
|
|
278
|
+
};
|
|
279
|
+
const { view: component } = view();
|
|
280
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true, onRightClickPaste: () => { pasted++; } });
|
|
281
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
282
|
+
const termProgram = process.env.TERM_PROGRAM;
|
|
283
|
+
Object.defineProperty(process, "platform", { ...platform, value: "win32" });
|
|
284
|
+
delete process.env.TERM_PROGRAM;
|
|
285
|
+
tui.setLayoutRoot(component);
|
|
286
|
+
tui.start();
|
|
287
|
+
tui.renderNow(true);
|
|
288
|
+
try {
|
|
289
|
+
onInput?.("\x1b[<2;4;2M");
|
|
290
|
+
assert.equal(pasted, 1);
|
|
291
|
+
} finally {
|
|
292
|
+
tui.stop();
|
|
293
|
+
Object.defineProperty(process, "platform", platform);
|
|
294
|
+
if (termProgram === undefined) delete process.env.TERM_PROGRAM;
|
|
295
|
+
else process.env.TERM_PROGRAM = termProgram;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("worktree list keeps selection visible, preserves root across reorder and refreshes from either level", async () => {
|
|
300
|
+
const trees = Array.from({ length: 15 }, (_, index) => ({ root: `/tree-${index}`, branch: `branch-${index}`, model: changesModel([file("a.ts", 1, 0)]) }));
|
|
301
|
+
let refreshed = 0;
|
|
302
|
+
const loaded: string[] = [];
|
|
303
|
+
const component = new WorktreeChangesView(trees, {
|
|
304
|
+
theme: plainTheme, rows: 8, loadDiff: async (root) => { loaded.push(root); return ""; },
|
|
305
|
+
onOpen() {}, onClose() {}, requestRender() {}, onRefresh() { refreshed++; },
|
|
306
|
+
});
|
|
307
|
+
for (let index = 0; index < 14; index++) component.handleInput("j");
|
|
308
|
+
assert.match(component.render(100).join("\n"), /▸ ▸ branch-14/);
|
|
309
|
+
component.update([...trees].reverse());
|
|
310
|
+
component.handleInput("r");
|
|
311
|
+
component.handleInput("\r");
|
|
312
|
+
component.handleInput("j");
|
|
313
|
+
component.handleInput("r");
|
|
314
|
+
await settle();
|
|
315
|
+
assert.deepEqual(loaded, ["/tree-14"]);
|
|
316
|
+
assert.equal(refreshed, 2);
|
|
317
|
+
for (const line of component.render(30)) assert.ok(visibleWidth(line) <= 30);
|
|
318
|
+
component.update([]);
|
|
319
|
+
assert.match(component.render(100).join("\n"), /No dirty worktrees/);
|
|
320
|
+
component.handleInput("\r");
|
|
321
|
+
assert.equal(loaded.length, 1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("worktree labels remove terminal controls without changing diff or editor roots", async () => {
|
|
325
|
+
const root = "/repo\nline\tcolumn\r\x07\x1b[31mred\x1b[0m\x1b]0;injected title\x07";
|
|
326
|
+
const routed: string[] = [];
|
|
327
|
+
const component = new WorktreeChangesView([{ root, branch: "main", model: changesModel([file("a.ts", 1, 0)]) }], {
|
|
328
|
+
theme: plainTheme, rows: 8,
|
|
329
|
+
loadDiff: async (actualRoot) => { routed.push(actualRoot); return "+safe"; },
|
|
330
|
+
onOpen: (actualRoot) => { routed.push(actualRoot); },
|
|
331
|
+
onClose() {}, onRefresh() {}, requestRender() {},
|
|
332
|
+
});
|
|
333
|
+
const assertSafe = (lines: string[]) => {
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
assert.doesNotMatch(line, /[\x00-\x1f\x7f-\x9f]/);
|
|
336
|
+
assert.ok(visibleWidth(line) <= 100);
|
|
337
|
+
}
|
|
338
|
+
assert.match(lines.join("\n"), /main · repo line columnred/);
|
|
339
|
+
assert.doesNotMatch(lines.join("\n"), /injected title/);
|
|
340
|
+
};
|
|
341
|
+
assertSafe(component.render(100));
|
|
342
|
+
component.handleInput("\r");
|
|
343
|
+
component.handleInput("j");
|
|
344
|
+
await settle();
|
|
345
|
+
assertSafe(component.render(100));
|
|
346
|
+
component.handleInput("o");
|
|
347
|
+
assert.deepEqual(routed, [root, root], "display sanitization must not alter raw root identity");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("accordion refresh preserves expanded roots and selected file; left returns to parent then collapses", async () => {
|
|
351
|
+
const trees = ["/parent/one", "/parent/two"].map((root) => ({ root, branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }));
|
|
352
|
+
const opened: string[] = [];
|
|
353
|
+
const component = new WorktreeChangesView(trees, {
|
|
354
|
+
theme: plainTheme, rows: 12, loadDiff: async () => "+preview",
|
|
355
|
+
onOpen: (root, target) => { opened.push(`${root}/${target.path}`); },
|
|
356
|
+
onClose() {}, onRefresh() {}, requestRender() {},
|
|
357
|
+
});
|
|
358
|
+
component.handleInput("\x1b[C");
|
|
359
|
+
component.handleInput("j");
|
|
360
|
+
component.handleInput("j");
|
|
361
|
+
component.update([trees[1], trees[0]]);
|
|
362
|
+
component.handleInput("o");
|
|
363
|
+
assert.deepEqual(opened, ["/parent/one/b.ts"]);
|
|
364
|
+
assert.match(component.render(100).join("\n"), /▾ main · one/);
|
|
365
|
+
component.handleInput("\x1b[D");
|
|
366
|
+
assert.match(component.render(100).join("\n"), /▸ ▾ main · one/);
|
|
367
|
+
component.handleInput("\x1b[D");
|
|
368
|
+
assert.doesNotMatch(component.render(100).join("\n"), /b.ts/);
|
|
369
|
+
component.handleInput("\r");
|
|
370
|
+
component.handleInput("j");
|
|
371
|
+
component.update([{ ...trees[0], model: changesModel([file("b.ts", 1, 0)]) }]);
|
|
372
|
+
assert.match(component.render(100).join("\n"), /▸ ▾ main · one/, "removed file falls back to its parent header");
|
|
373
|
+
component.handleInput("o");
|
|
374
|
+
assert.equal(opened.length, 1, "header must not open a file");
|
|
375
|
+
component.update([]);
|
|
376
|
+
await settle();
|
|
377
|
+
assert.match(component.render(100).join("\n"), /No dirty worktrees/);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("accordion selection scrolls flattened rows and diff scrolling does not open the editor", async () => {
|
|
381
|
+
let opened = 0;
|
|
382
|
+
const component = new WorktreeChangesView([{ root: "/long/root", branch: "main", model: changesModel(Array.from({ length: 20 }, (_, index) => file(`file-${String(index).padStart(2, "0")}.ts`, 1, 0))) }], {
|
|
383
|
+
theme: plainTheme, rows: 8,
|
|
384
|
+
loadDiff: async () => Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n"),
|
|
385
|
+
onOpen() { opened++; }, onClose() {}, onRefresh() {}, requestRender() {},
|
|
386
|
+
});
|
|
387
|
+
component.handleInput(" ");
|
|
388
|
+
for (let index = 0; index < 20; index++) component.handleInput("j");
|
|
389
|
+
await settle();
|
|
390
|
+
assert.match(component.render(100).join("\n"), /▸ M file-19.ts/);
|
|
391
|
+
component.handleInput("\x0a");
|
|
392
|
+
assert.match(component.render(100)[1], /\+line 5/);
|
|
393
|
+
component.handleInput("\x0b");
|
|
394
|
+
assert.match(component.render(100)[1], /\+line 0/);
|
|
395
|
+
assert.equal(opened, 0);
|
|
396
|
+
for (const width of [1, 8, 20, 40, 100]) {
|
|
397
|
+
for (const line of component.render(width)) assert.ok(visibleWidth(line) <= width);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const statusCases = [
|
|
402
|
+
[CHANGE_STATUS.MODIFIED, "M", 2, 1],
|
|
403
|
+
[CHANGE_STATUS.ADDED, "A", 3, 0],
|
|
404
|
+
[CHANGE_STATUS.DELETED, "D", 0, 4],
|
|
405
|
+
[CHANGE_STATUS.RENAMED, "R", 0, 0],
|
|
406
|
+
[CHANGE_STATUS.UNTRACKED, "??", 5, 0],
|
|
407
|
+
] as const;
|
|
408
|
+
|
|
409
|
+
for (const [status, code, added, deleted] of statusCases) {
|
|
410
|
+
test(`both file lists render ${status} with colored signed counts`, () => {
|
|
411
|
+
const theme = {
|
|
412
|
+
fg(role: string, text: string) {
|
|
413
|
+
const color = role === "success" ? 32 : role === "error" ? 31 : 36;
|
|
414
|
+
return `\x1b[${color}m${text}\x1b[39m`;
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
const target = file("a.ts", added, deleted, status);
|
|
418
|
+
const accordion = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([target]) }], {
|
|
419
|
+
theme, rows: 10, loadDiff: async () => "", onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
|
|
420
|
+
});
|
|
421
|
+
accordion.handleInput("\r");
|
|
422
|
+
const standalone = view({ theme }, [target]).view;
|
|
423
|
+
for (const selected of [false, true]) {
|
|
424
|
+
if (selected) accordion.handleInput("j");
|
|
425
|
+
for (const [component, row] of [[accordion, 2], [standalone, 1]] as const) {
|
|
426
|
+
const line = component.render(100)[row];
|
|
427
|
+
assert.ok(stripAnsi(line).includes(`${code} a.ts +${added} -${deleted}`));
|
|
428
|
+
assert.ok(line.includes(`\x1b[32m+${added}\x1b[39m`), "addition uses success color");
|
|
429
|
+
assert.ok(line.includes(`\x1b[31m-${deleted}\x1b[39m`), "deletion uses error color");
|
|
430
|
+
for (const width of [8, 20, 40, 100]) {
|
|
431
|
+
for (const rendered of component.render(width)) assert.ok(visibleWidth(rendered) <= width);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
test("colorDiff drops git headers and colors hunks, additions, and removals by role", () => {
|
|
439
|
+
const lines = colorDiff(DIFF_A, taggedTheme);
|
|
440
|
+
assert.deepEqual(lines, [
|
|
441
|
+
"<customMessageLabel>@@ -1,2 +1,3 @@</customMessageLabel>",
|
|
442
|
+
"<toolDiffContext> const a = 1;</toolDiffContext>",
|
|
443
|
+
"<toolDiffRemoved>-const b = 2;</toolDiffRemoved>",
|
|
444
|
+
"<toolDiffAdded>+const b = 3;</toolDiffAdded>",
|
|
445
|
+
"<toolDiffAdded>+const c = 4;</toolDiffAdded>",
|
|
446
|
+
]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("ChangesView renders a framed two-pane layout at the requested size", async () => {
|
|
450
|
+
const { view: component } = view();
|
|
451
|
+
await settle();
|
|
452
|
+
const lines = component.render(80);
|
|
453
|
+
assert.equal(lines.length, 12);
|
|
454
|
+
for (const line of lines) assert.equal(visibleWidth(line), 80, `"${stripAnsi(line)}" is not 80 wide`);
|
|
455
|
+
const plain = lines.map(stripAnsi);
|
|
456
|
+
assert.match(plain[0], /^╭─ ✎ Changes · 2 files · \+12 −1 ─+╮$/);
|
|
457
|
+
assert.match(plain[1], /^│ ▸ M lib\/a\.ts +\+2 -1 +│ @@ -1,2 \+1,3 @@ +│$/);
|
|
458
|
+
assert.match(plain[2], /^│ A lib\/b\.ts +\+10 -0 +│ const a = 1; +│$/);
|
|
459
|
+
assert.match(plain[11], /^╰─+╯$/);
|
|
460
|
+
assert.match(plain[10], /j\/k file .* o open in editor .* esc close/);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("ChangesView loads the selected diff lazily and moves with j/k and arrows", async () => {
|
|
464
|
+
const { view: component, calls, events } = view();
|
|
465
|
+
await settle();
|
|
466
|
+
assert.deepEqual(calls, ["lib/a.ts"]);
|
|
467
|
+
component.handleInput("j");
|
|
468
|
+
await settle();
|
|
469
|
+
assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"]);
|
|
470
|
+
assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ A lib\/b\.ts/);
|
|
471
|
+
component.handleInput("\x1b[A");
|
|
472
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ M lib\/a\.ts/);
|
|
473
|
+
component.handleInput("k");
|
|
474
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ M lib\/a\.ts/);
|
|
475
|
+
assert.ok(events.filter((event) => event === "render").length >= 2);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("ChangesView scrolls the diff pane and shows an empty state for files without a diff", async () => {
|
|
479
|
+
const long = Array.from({ length: 40 }, (_, index) => `+line ${index}`).join("\n");
|
|
480
|
+
const { view: component } = view({ async loadDiff() { return `@@ -0,0 +1,40 @@\n${long}`; } });
|
|
481
|
+
await settle();
|
|
482
|
+
component.handleInput("\x1b[6~");
|
|
483
|
+
const plain = component.render(80).map(stripAnsi);
|
|
484
|
+
assert.doesNotMatch(plain[1], /@@/);
|
|
485
|
+
assert.match(plain[1], /\+line \d+/);
|
|
486
|
+
component.handleInput("\x0b");
|
|
487
|
+
assert.match(stripAnsi(component.render(80)[1]), /@@/, "ctrl+k scrolls back to the top");
|
|
488
|
+
component.handleInput("\x0a");
|
|
489
|
+
assert.doesNotMatch(stripAnsi(component.render(80)[1]), /@@/, "ctrl+j scrolls a page down");
|
|
490
|
+
|
|
491
|
+
const empty = view({ async loadDiff() { return ""; } });
|
|
492
|
+
await settle();
|
|
493
|
+
assert.match(stripAnsi(empty.view.render(80)[1]), /no diff for this file/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("ChangesView opens the selected file and closes on escape or q", async () => {
|
|
497
|
+
const { view: component, events } = view();
|
|
498
|
+
await settle();
|
|
499
|
+
component.handleInput("o");
|
|
500
|
+
assert.ok(events.includes("open:lib/a.ts"));
|
|
501
|
+
component.handleInput("\x1b");
|
|
502
|
+
component.handleInput("q");
|
|
503
|
+
assert.equal(events.filter((event) => event === "close").length, 2);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("ChangesView click selects a file without opening it", async () => {
|
|
507
|
+
const { view: component, events } = view();
|
|
508
|
+
await settle();
|
|
509
|
+
component.render(80);
|
|
510
|
+
component.handleMouse(mouse("press", 3, 2));
|
|
511
|
+
component.handleMouse(mouse("release", 3, 2));
|
|
512
|
+
component.handleMouse(mouse("click", 3, 2));
|
|
513
|
+
await settle();
|
|
514
|
+
assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ A lib\/b\.ts/);
|
|
515
|
+
assert.deepEqual(events.filter((event) => event.startsWith("open:")), []);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("ChangesView receives native fullscreen press and release as a click", async () => {
|
|
519
|
+
let onInput: ((data: string) => void) | undefined;
|
|
520
|
+
const terminal: Terminal = {
|
|
521
|
+
start(input) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {},
|
|
522
|
+
};
|
|
523
|
+
const { view: component, events } = view();
|
|
524
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
|
|
525
|
+
tui.setLayoutRoot(component);
|
|
526
|
+
tui.start();
|
|
527
|
+
tui.renderNow(true);
|
|
528
|
+
try {
|
|
529
|
+
onInput?.("\x1b[<0;4;3M");
|
|
530
|
+
onInput?.("\x1b[<0;4;3m");
|
|
531
|
+
assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ A lib\/b\.ts/);
|
|
532
|
+
assert.deepEqual(events.filter((event) => event.startsWith("open:")), []);
|
|
533
|
+
} finally {
|
|
534
|
+
tui.stop();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("ChangesView rebuilds pointer geometry when its overlay height changes", async () => {
|
|
539
|
+
let rows = 12;
|
|
540
|
+
const { view: component } = view({ rows: () => rows });
|
|
541
|
+
await settle();
|
|
542
|
+
component.render(80);
|
|
543
|
+
rows = 8;
|
|
544
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 2, undefined, 8)), { handled: true, render: false });
|
|
545
|
+
component.render(80);
|
|
546
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 2, undefined, 8)), { handled: true, render: true });
|
|
547
|
+
assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ A lib\/b\.ts/);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("ChangesView scrolls the file list and diff independently, ignores hover, and clears stale pointer layouts", async () => {
|
|
551
|
+
const files = Array.from({ length: 8 }, (_, index) => file(`lib/${index}.ts`, 1, 0));
|
|
552
|
+
const long = Array.from({ length: 20 }, (_, index) => `+line ${index}`).join("\n");
|
|
553
|
+
const { view: component, events } = view({ rows: 8, async loadDiff() { return `@@ -0,0 +1,20 @@\n${long}`; } }, files);
|
|
554
|
+
await settle();
|
|
555
|
+
component.render(80);
|
|
556
|
+
component.handleMouse(mouse("move", 3, 2, undefined, 8));
|
|
557
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ M lib\/0\.ts/);
|
|
558
|
+
assert.deepEqual(events.filter((event) => event.startsWith("open:")), []);
|
|
559
|
+
|
|
560
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 3, 1, 2, 8)), { handled: true, render: true });
|
|
561
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ M lib\/2\.ts/);
|
|
562
|
+
component.update(changesModel(files));
|
|
563
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ M lib\/2\.ts/, "an unchanged live refresh preserves the independently scrolled file viewport");
|
|
564
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 50, 1, 2, 8)), { handled: true, render: true });
|
|
565
|
+
assert.doesNotMatch(stripAnsi(component.render(80)[1]), /@@/);
|
|
566
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 50, 1, -100, 8)), { handled: true, render: true });
|
|
567
|
+
assert.deepEqual(component.handleMouse(mouse("wheel", 50, 1, -1, 8)), { handled: true, render: false });
|
|
568
|
+
|
|
569
|
+
component.update(changesModel([file("lib/new.ts", 1, 0)]));
|
|
570
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 1, undefined, 8)), { handled: true, render: false });
|
|
571
|
+
component.render(80);
|
|
572
|
+
component.dispose();
|
|
573
|
+
assert.deepEqual(component.handleMouse(mouse("click", 3, 1, undefined, 8)), { handled: true, render: false });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("ChangesView keeps keyboard selection visible after file-list scrolling", async () => {
|
|
577
|
+
const files = Array.from({ length: 8 }, (_, index) => file(`lib/${index}.ts`, 1, 0));
|
|
578
|
+
const { view: component } = view({ rows: 8 }, files);
|
|
579
|
+
await settle();
|
|
580
|
+
component.render(80);
|
|
581
|
+
component.handleMouse(mouse("wheel", 3, 1, 2, 8));
|
|
582
|
+
component.handleInput("j");
|
|
583
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ ▸ M lib\/1\.ts/);
|
|
584
|
+
component.update(changesModel(files.slice(0, 3)));
|
|
585
|
+
assert.match(stripAnsi(component.render(80)[1]), /^│ M lib\/0\.ts/);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("ChangesView.update keeps the selected file, reloads moved diffs, and survives an empty tree", async () => {
|
|
589
|
+
const { view: component, calls } = view();
|
|
590
|
+
await settle();
|
|
591
|
+
component.handleInput("j");
|
|
592
|
+
await settle();
|
|
593
|
+
assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"]);
|
|
594
|
+
|
|
595
|
+
component.update(changesModel([file("lib/a.ts", 5, 1), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED), file("lib/c.ts", 1, 0)]));
|
|
596
|
+
await settle();
|
|
597
|
+
assert.match(stripAnsi(component.render(80)[2]), /^│ ▸ A lib\/b\.ts/);
|
|
598
|
+
assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts"], "unchanged selected file must not reload");
|
|
599
|
+
component.handleInput("k");
|
|
600
|
+
await settle();
|
|
601
|
+
assert.deepEqual(calls, ["lib/a.ts", "lib/b.ts", "lib/a.ts"], "moved counts must reload the diff");
|
|
602
|
+
|
|
603
|
+
component.update(changesModel([]));
|
|
604
|
+
const plain = component.render(80).map(stripAnsi);
|
|
605
|
+
assert.match(plain[0], /0 files · \+0 −0/);
|
|
606
|
+
assert.match(plain[1], /working tree is clean/);
|
|
607
|
+
component.handleInput("j");
|
|
608
|
+
component.handleInput("o");
|
|
609
|
+
});
|