gentle-pi 3.2.1 → 3.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -36,3 +36,30 @@ test("reload and new evidence refresh Changes without Git or live file reads",as
|
|
|
36
36
|
assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
|
|
37
37
|
await f.fire("session_shutdown");
|
|
38
38
|
});
|
|
39
|
+
test("the changes overlay labels each session tree with its root's branch instead of detached",async()=>{
|
|
40
|
+
const evidence=(root:string,id:string)=>({type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id,root,path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}});
|
|
41
|
+
const entries=[evidence("/repo","child:1"),evidence("/other","child:2")];
|
|
42
|
+
const handlers=new Map<string,Function[]>();
|
|
43
|
+
const commands=new Map<string,any>();
|
|
44
|
+
const gitRoots:string[]=[];
|
|
45
|
+
const pi:any={on:(key,fn)=>handlers.set(key,[...(handlers.get(key)??[]),fn]),events:{on:()=>()=>{},emit(){}},appendEntry(){},registerTool(){},registerShortcut(){},registerMessageRenderer(){},registerCommand:(key,registration)=>commands.set(key,registration)};
|
|
46
|
+
let view:any; let renders=0;
|
|
47
|
+
const ctx:any={hasUI:true,cwd:"/repo",sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
|
|
48
|
+
ui:{setFooter(){},getEditorComponent:()=>({}),setWorkingVisible(){},setWidget(){},notify(){},
|
|
49
|
+
custom:async(factory:any)=>{view=factory({terminal:{rows:40,columns:120},requestRender:()=>renders++},{fg:(_c:string,t:string)=>t,bold:(t:string)=>t},{},()=>{});return new Promise(()=>{});}}};
|
|
50
|
+
shell(pi,{},{resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
|
|
51
|
+
gitRunner:(root:string)=>async(args:string[])=>{gitRoots.push(root);await new Promise(r=>setImmediate(r));
|
|
52
|
+
if(args[0]==="symbolic-ref")return root==="/repo"?{stdout:"feature\n",code:0}:{stdout:"",code:1};
|
|
53
|
+
return {stdout:"deadbeef\n",code:0};}});
|
|
54
|
+
for(const fn of handlers.get("session_start")??[])await fn({},ctx);
|
|
55
|
+
void commands.get("gentle:changes").handler("",ctx);
|
|
56
|
+
await new Promise(r=>setImmediate(r));
|
|
57
|
+
assert.ok(view,"the overlay opened");
|
|
58
|
+
for(let i=0;i<6;i++)await new Promise(r=>setImmediate(r));
|
|
59
|
+
const rendered=view.render(120).join("\n");
|
|
60
|
+
assert.match(rendered,/feature · repo/,"the repo tree carries its branch");
|
|
61
|
+
assert.match(rendered,/detached · other/,"a genuinely detached root still reads detached");
|
|
62
|
+
assert.deepEqual([...new Set(gitRoots)].sort(),["/other","/repo"],"git is asked once per root, only while the overlay is open");
|
|
63
|
+
assert.ok(renders>0,"a resolved label asks the overlay to repaint");
|
|
64
|
+
for(const fn of handlers.get("session_shutdown")??[])await fn({},ctx);
|
|
65
|
+
});
|
|
@@ -133,3 +133,44 @@ test("only standard path-bearing calls have registration candidates; shell and p
|
|
|
133
133
|
for (const name of ["bash", "powershell", "custom", "subagent_run"]) assert.equal(toolWorktreePath(name, { path: "/linked", command: "cd /linked", task: "/linked" }), undefined);
|
|
134
134
|
assert.equal(toolWorktreePath("read", { path: 42 }), undefined);
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// C2 (odd/tasks/usage-click-and-changes-attribution.md): a repo nested inside
|
|
138
|
+
// another repo (the live session's ~/work/NaN-builders inside ~/work) must
|
|
139
|
+
// resolve to the INNER repo, never the outer one, because Git itself walks
|
|
140
|
+
// up from the file's own directory and stops at the first .git it finds.
|
|
141
|
+
function nestedFixture(t: test.TestContext) {
|
|
142
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "session-worktrees-nested-")));
|
|
143
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
144
|
+
const outer = join(dir, "work");
|
|
145
|
+
const inner = join(outer, "NaN-builders");
|
|
146
|
+
const empty = join(dir, "empty");
|
|
147
|
+
mkdirSync(empty);
|
|
148
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
149
|
+
Object.assign(env, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1" });
|
|
150
|
+
writeFileSync(join(empty, "config"), "");
|
|
151
|
+
const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
152
|
+
// Outer repo: no commits, matching the live evidence's `~/work` exactly.
|
|
153
|
+
git(dir, ["init", "--initial-branch=main", `--template=${empty}`, outer]);
|
|
154
|
+
// Inner repo: its own .git, a real branch and a commit.
|
|
155
|
+
git(dir, ["init", "--initial-branch=feature", `--template=${empty}`, inner]);
|
|
156
|
+
git(inner, ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "Fixture"]);
|
|
157
|
+
mkdirSync(join(inner, "odd", "tasks"), { recursive: true });
|
|
158
|
+
writeFileSync(join(inner, "odd", "tasks", "jpg-png-converter.md"), "converted\n");
|
|
159
|
+
return { dir, outer, inner };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("nearest-repository resolution: an inner repo's files never resolve to an outer ancestor repo", (t) => {
|
|
163
|
+
const f = nestedFixture(t);
|
|
164
|
+
const forFile = resolveSessionWorktree(join("NaN-builders", "odd", "tasks", "jpg-png-converter.md"), f.outer);
|
|
165
|
+
assert.equal(forFile?.root, f.inner, "the file's own nearest repository must win, not the outer ~/work repo");
|
|
166
|
+
// The inner repo's own root and files resolve to itself too, whether
|
|
167
|
+
// addressed from the outer cwd or the inner cwd directly.
|
|
168
|
+
const forRoot = resolveSessionWorktree("NaN-builders", f.outer);
|
|
169
|
+
assert.equal(forRoot?.root, f.inner);
|
|
170
|
+
const fromInnerCwd = resolveSessionWorktree(join("odd", "tasks", "jpg-png-converter.md"), f.inner);
|
|
171
|
+
assert.equal(fromInnerCwd?.root, f.inner);
|
|
172
|
+
// The outer repo is still resolvable for its own files.
|
|
173
|
+
const outerFile = join(f.outer, "README.md");
|
|
174
|
+
writeFileSync(outerFile, "outer\n");
|
|
175
|
+
assert.equal(resolveSessionWorktree("README.md", f.outer)?.root, f.outer);
|
|
176
|
+
});
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -2,17 +2,19 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import {
|
|
5
|
+
buildShellHeaderModel,
|
|
5
6
|
formatCost,
|
|
6
7
|
formatTokens,
|
|
7
8
|
gaugeTone,
|
|
8
9
|
renderGauge,
|
|
9
10
|
renderShellBar,
|
|
11
|
+
renderShellHeaderBar,
|
|
12
|
+
renderShellHeaderRule,
|
|
10
13
|
renderShellSidebarBar,
|
|
11
14
|
shellEnabled,
|
|
12
15
|
type ShellBarModel,
|
|
13
16
|
type ShellBarTheme,
|
|
14
17
|
} from "../lib/shell-bar.ts";
|
|
15
|
-
import { parseNanQuota } from "../lib/shell-usage.ts";
|
|
16
18
|
|
|
17
19
|
// The Gentle Shell bar replaces pi's three-line footer with one line of
|
|
18
20
|
// segments. Rendering is pure so it can be verified without a TUI.
|
|
@@ -53,28 +55,6 @@ function model(overrides: Partial<ShellBarModel> = {}): ShellBarModel {
|
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
// The grouped NaN fixture the panel test uses, so both surfaces are asserted
|
|
57
|
-
// against the same payload, the same order and the same percentages.
|
|
58
|
-
const GROUPED_NAN_QUOTA = {
|
|
59
|
-
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
60
|
-
models: [
|
|
61
|
-
{ model: "glm5.3-flash", cap: 2_000_000_000, tokensUsed: 200_000_000 },
|
|
62
|
-
{ model: "glm5.3", cap: 3_000_000_000, tokensUsed: 0, periodEnd: "2026-10-17T05:53:20.000Z" },
|
|
63
|
-
{ model: "glm5.2", cap: 3_000_000_000, tokensUsed: 0, periodEnd: "2026-10-17T05:53:20.000Z" },
|
|
64
|
-
{ model: "deepseek-v4-flash", cap: 3_000_000_000, tokensUsed: 300_000_000 },
|
|
65
|
-
],
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// The sidebar prints the card body between its borders; the Usage rows are the
|
|
69
|
-
// metered block between the Usage heading and Integrations, minus the context
|
|
70
|
-
// meter, which is not a subscription allowance.
|
|
71
|
-
function sidebarUsageRows(lines: string[]): string[] {
|
|
72
|
-
const body = lines.filter((line) => line.startsWith("│ ")).map((line) => line.slice(2, -2).trim());
|
|
73
|
-
const start = body.indexOf("Usage");
|
|
74
|
-
const end = body.indexOf("Integrations");
|
|
75
|
-
return body.slice(start + 1, end).filter((line) => /[▰▱]/.test(line) && !line.startsWith("Context"));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
58
|
test("renderGauge fills cells proportionally to the percentage", () => {
|
|
79
59
|
assert.equal(renderGauge(45, 8), "▰▰▰▰▱▱▱▱");
|
|
80
60
|
assert.equal(renderGauge(0, 8), "▱▱▱▱▱▱▱▱");
|
|
@@ -158,57 +138,9 @@ test("renderShellBar meters the model the session is using inside a multi-model
|
|
|
158
138
|
assert.doesNotMatch(glm, /deepseek-v4-flash ▰/);
|
|
159
139
|
const [other] = renderShellBar(model({ modelId: "deepseek-v4-flash", usage }), plainTheme, 200);
|
|
160
140
|
assert.match(other, /deepseek-v4-flash ▰▱▱▱▱▱▱▱ 18%$/);
|
|
161
|
-
const sidebar = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60).join("\n");
|
|
162
|
-
assert.match(sidebar, /glm5\.3-flash +▰▱▱▱▱▱▱▱ +10%/, "the sidebar lists the session model's own allowance");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// The sidebar is the surface that never needs opening, so a provider with
|
|
166
|
-
// per-model allowances shows the panel's model rows there too — most consumed
|
|
167
|
-
// family first, its models inside it — and leaves the aggregate totals and the
|
|
168
|
-
// reset dates to the bar and the panel.
|
|
169
|
-
test("sidebar groups the NaN allowances by subscription without totals or resets", () => {
|
|
170
|
-
const usage = parseNanQuota(GROUPED_NAN_QUOTA, 0);
|
|
171
|
-
const lines = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60);
|
|
172
|
-
const rows = sidebarUsageRows(lines);
|
|
173
|
-
assert.deepEqual(rows.map((row) => row.replace(/\s*[▰▱].*$/, "")), ["deepseek-v4-flash", "glm5.3-flash"]);
|
|
174
|
-
assert.deepEqual(rows.map((row) => Number.parseInt(row.match(/(\d+)%$/)![1] ?? "", 10)), [10, 10]);
|
|
175
|
-
assert.equal(rows.some((row) => / 0%$/.test(row)), false, "a window that consumed nothing only spends space");
|
|
176
|
-
assert.equal(lines.join("\n").includes("total"), false, "an aggregate row only costs space");
|
|
177
|
-
assert.equal(lines.join("\n").includes("resets in"), false, "the reset dates belong to the panel");
|
|
178
|
-
for (const width of [24, 46, 60]) {
|
|
179
|
-
assert.ok(renderShellSidebarBar(model({ usage }), plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
180
|
-
}
|
|
181
141
|
});
|
|
182
142
|
|
|
183
|
-
test("
|
|
184
|
-
const usage = parseNanQuota({
|
|
185
|
-
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
186
|
-
models: [{ model: "glm5.3-flash", cap: 2_000_000_000, tokensUsed: 400_000_000, windowHours: 4, windowTokens: 400_000_000, windowTokensUsed: 120_000_000, windowResetsAt: 1_788_620_161 }],
|
|
187
|
-
}, 0);
|
|
188
|
-
const lines = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60);
|
|
189
|
-
const rows = sidebarUsageRows(lines).map((row) => row.replace(/\s+/g, " "));
|
|
190
|
-
// A single allowance is still the panel's rows, not the bar's one-line meter:
|
|
191
|
-
// the rolling window it reports is a row of its own here too, and the reset
|
|
192
|
-
// stays in the panel.
|
|
193
|
-
assert.deepEqual(rows, ["glm5.3-flash ▰▰▱▱▱▱▱▱ 20%", "glm5.3-flash 4h ▰▰▱▱▱▱▱▱ 30%"]);
|
|
194
|
-
assert.equal(lines.join("\n").includes("resets in"), false);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("sidebar keeps one aggregate line for a provider without raw allowances", () => {
|
|
198
|
-
const usage = {
|
|
199
|
-
provider: "openai-codex",
|
|
200
|
-
plan: "pro",
|
|
201
|
-
fetchedAt: 0,
|
|
202
|
-
limits: [{ name: "codex", limitReached: false, windows: [
|
|
203
|
-
{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null },
|
|
204
|
-
{ label: "week", usedPercent: 31, windowSeconds: 604_800, resetAt: null },
|
|
205
|
-
] }],
|
|
206
|
-
};
|
|
207
|
-
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
208
|
-
assert.deepEqual(sidebarUsageRows(lines), ["codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%"]);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("sidebar drops an aggregate allowance that consumed nothing", () => {
|
|
143
|
+
test("renderShellBar keeps its own zero-window contract independent of the sidebar", () => {
|
|
212
144
|
const usage = {
|
|
213
145
|
provider: "openai-codex",
|
|
214
146
|
plan: "pro",
|
|
@@ -218,58 +150,9 @@ test("sidebar drops an aggregate allowance that consumed nothing", () => {
|
|
|
218
150
|
{ label: "week", usedPercent: 0, windowSeconds: 604_800, resetAt: null },
|
|
219
151
|
] }],
|
|
220
152
|
};
|
|
221
|
-
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
222
|
-
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
223
|
-
const text = lines.join("\n");
|
|
224
|
-
assert.match(text, /Usage/);
|
|
225
|
-
assert.match(text, /Context/);
|
|
226
|
-
assert.match(text, /Cost/);
|
|
227
|
-
// The bar keeps its own contract: only the sidebar gives zero rows the boot.
|
|
228
153
|
assert.match(renderShellBar(model({ usage }), plainTheme, 200)[0], /codex 5h ▱▱▱▱▱▱▱▱ 0% · week 0%$/);
|
|
229
154
|
});
|
|
230
155
|
|
|
231
|
-
test("sidebar keeps the Usage group when nothing was consumed", () => {
|
|
232
|
-
const usage = parseNanQuota({
|
|
233
|
-
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
234
|
-
models: [
|
|
235
|
-
{ model: "glm5.3", cap: 3_000_000_000, tokensUsed: 0 },
|
|
236
|
-
{ model: "glm5.2", cap: 3_000_000_000, tokensUsed: 0 },
|
|
237
|
-
],
|
|
238
|
-
}, 0);
|
|
239
|
-
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
240
|
-
const text = lines.join("\n");
|
|
241
|
-
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
242
|
-
assert.match(text, /Usage/);
|
|
243
|
-
assert.match(text, /Context/);
|
|
244
|
-
assert.match(text, /Cost/);
|
|
245
|
-
assert.match(text, /Integrations/);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("sidebar hides exactly the rows that would print 0%, rounding included", () => {
|
|
249
|
-
// The row's own rounding is the only threshold: a fraction below half a
|
|
250
|
-
// percent is a 0% row and leaves; half a percent keeps its row and prints 1%.
|
|
251
|
-
const usage = (usedPercent: number) => ({
|
|
252
|
-
provider: "openai-codex",
|
|
253
|
-
plan: "pro",
|
|
254
|
-
fetchedAt: 0,
|
|
255
|
-
limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent, windowSeconds: 18_000, resetAt: null }] }],
|
|
256
|
-
});
|
|
257
|
-
assert.deepEqual(sidebarUsageRows(renderShellSidebarBar(model({ usage: usage(0.4) }), plainTheme, 60)), []);
|
|
258
|
-
assert.deepEqual(sidebarUsageRows(renderShellSidebarBar(model({ usage: usage(0.5) }), plainTheme, 60)), ["codex 5h ▱▱▱▱▱▱▱▱ 1%"]);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test("sidebar reads a limit without windows as nothing to draw, not as zero consumption", () => {
|
|
262
|
-
const usage = {
|
|
263
|
-
provider: "openai-codex",
|
|
264
|
-
plan: "pro",
|
|
265
|
-
fetchedAt: 0,
|
|
266
|
-
limits: [{ name: "codex", limitReached: false, windows: [] }],
|
|
267
|
-
};
|
|
268
|
-
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
269
|
-
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
270
|
-
assert.match(lines.join("\n"), /Cost/);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
156
|
test("renderShellBar shows an unknown context as a question mark after compaction", () => {
|
|
274
157
|
const [line] = renderShellBar(model({ contextPercent: null }), plainTheme, 160);
|
|
275
158
|
assert.match(line, /ctx ▱▱▱▱▱▱▱▱ \?%/);
|
|
@@ -326,13 +209,20 @@ test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
|
|
|
326
209
|
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
|
|
327
210
|
});
|
|
328
211
|
|
|
329
|
-
test("
|
|
212
|
+
test("renderShellSidebarBar paints the Status card frame with border and the title with accent", () => {
|
|
213
|
+
const lines = renderShellSidebarBar(model(), taggedTheme, 46);
|
|
214
|
+
assert.match(lines[0], /^<border>╭<\/border>/);
|
|
215
|
+
assert.match(lines[0], /<accent>✿ Status<\/accent>/);
|
|
216
|
+
assert.match(lines[lines.length - 1], /^<border>╰<\/border>/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("sidebar unifies project, captured changes and integrations in one frame", () => {
|
|
330
220
|
const data = model({ changes: { files: 2, added: 7, deleted: 3, notice: "capture warning" }, statuses: ["MCP connected"] });
|
|
331
221
|
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
332
222
|
const text = lines.join("\n");
|
|
333
223
|
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
334
224
|
let previous = -1;
|
|
335
|
-
for (const heading of ["Status", "Project", "Changes", "
|
|
225
|
+
for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
|
|
336
226
|
const index = text.indexOf(heading);
|
|
337
227
|
assert.ok(index > previous, heading);
|
|
338
228
|
previous = index;
|
|
@@ -340,7 +230,7 @@ test("sidebar unifies project, captured changes, usage and integrations in one f
|
|
|
340
230
|
assert.match(text, /2 files.*\+7.*−3/);
|
|
341
231
|
assert.match(text, /capture warning/);
|
|
342
232
|
assert.match(text, /\/gentle:changes/);
|
|
343
|
-
assert.match(text, /
|
|
233
|
+
assert.match(text, /main/);
|
|
344
234
|
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
345
235
|
const empty = renderShellSidebarBar(model(), plainTheme, 46).join("\n");
|
|
346
236
|
assert.match(empty, /No captured changes/);
|
|
@@ -359,3 +249,189 @@ test("sidebar profile wraps long names without changing the compact bar", () =>
|
|
|
359
249
|
}
|
|
360
250
|
assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120));
|
|
361
251
|
});
|
|
252
|
+
|
|
253
|
+
test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping Project, Changes and Integrations", () => {
|
|
254
|
+
const usage = {
|
|
255
|
+
provider: "openai-codex",
|
|
256
|
+
plan: "pro",
|
|
257
|
+
fetchedAt: 0,
|
|
258
|
+
limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null }] }],
|
|
259
|
+
};
|
|
260
|
+
const data = model({ profile: "team", sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] });
|
|
261
|
+
const text = renderShellSidebarBar(data, plainTheme, 60).join("\n");
|
|
262
|
+
assert.doesNotMatch(text, /Usage/);
|
|
263
|
+
assert.doesNotMatch(text, /Model/);
|
|
264
|
+
assert.doesNotMatch(text, /Effort/);
|
|
265
|
+
assert.doesNotMatch(text, /Context/);
|
|
266
|
+
assert.doesNotMatch(text, /Cost/);
|
|
267
|
+
assert.doesNotMatch(text, /\$9\.49/);
|
|
268
|
+
assert.doesNotMatch(text, /codex 5h/);
|
|
269
|
+
for (const heading of ["Status", "Project", "Changes", "Integrations"]) assert.match(text, new RegExp(heading));
|
|
270
|
+
assert.match(text, /Branch.*main/);
|
|
271
|
+
assert.match(text, /Session.*session/);
|
|
272
|
+
assert.match(text, /Profile.*team/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// The live header row above the fullscreen rail: identity on the left (brand,
|
|
276
|
+
// location, model · effort · profile), the per-frame counters right-aligned
|
|
277
|
+
// (context gauge, cost). Never the working/thinking state or extension
|
|
278
|
+
// statuses — those stay in the prompt and the Status card.
|
|
279
|
+
|
|
280
|
+
test("buildShellHeaderModel keeps only the header's fields from the bar model", () => {
|
|
281
|
+
const header = buildShellHeaderModel(model({ profile: "team", statuses: ["MCP: 3 servers"] }));
|
|
282
|
+
assert.deepEqual(header, {
|
|
283
|
+
cwd: "~/work/gentle-pi",
|
|
284
|
+
branch: "main",
|
|
285
|
+
dirty: undefined,
|
|
286
|
+
modelId: "gpt-5.5",
|
|
287
|
+
effort: "medium",
|
|
288
|
+
profile: "team",
|
|
289
|
+
contextPercent: 45,
|
|
290
|
+
costTotal: 9.49,
|
|
291
|
+
subscription: true,
|
|
292
|
+
usage: undefined,
|
|
293
|
+
});
|
|
294
|
+
assert.ok(!("statuses" in header), "extension statuses never reach the header");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("renderShellHeaderBar draws the brand, identity, and right-aligned counters (plus the standing usage segment) in one line", () => {
|
|
298
|
+
const header = buildShellHeaderModel(model({ profile: "team" }));
|
|
299
|
+
const { text: line } = renderShellHeaderBar(header, plainTheme, 120);
|
|
300
|
+
const left = "✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team";
|
|
301
|
+
const right = "ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ usage";
|
|
302
|
+
assert.equal(line, left + " ".repeat(120 - visibleWidth(left) - visibleWidth(right)) + right);
|
|
303
|
+
assert.equal(visibleWidth(line), 120);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("renderShellHeaderBar never shows working state or extension statuses", () => {
|
|
307
|
+
const header = buildShellHeaderModel(model({ statuses: ["MCP: 3 servers", "working…"] }));
|
|
308
|
+
const { text: line } = renderShellHeaderBar(header, plainTheme, 120);
|
|
309
|
+
assert.doesNotMatch(line, /MCP: 3 servers/);
|
|
310
|
+
assert.doesNotMatch(line, /working/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("renderShellHeaderBar colors the brand bold and by role", () => {
|
|
314
|
+
const bolding = { fg: (color: string, text: string) => `<${color}>${text}</${color}>`, bold: (text: string) => `**${text}**` };
|
|
315
|
+
const header = buildShellHeaderModel(model());
|
|
316
|
+
const { text: line } = renderShellHeaderBar(header, bolding, 120);
|
|
317
|
+
assert.match(line, /<accent>\*\*✿ Gentle Shell\*\*<\/accent>/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("renderShellHeaderBar drops the profile, then the effort, then the whole location before the right group", () => {
|
|
321
|
+
const withProfile = buildShellHeaderModel(model({ profile: "team" }));
|
|
322
|
+
const { text: wide } = renderShellHeaderBar(withProfile, plainTheme, 120);
|
|
323
|
+
assert.match(wide, /gpt-5\.5 · medium · team/);
|
|
324
|
+
assert.match(wide, /~\/work\/gentle-pi main/);
|
|
325
|
+
assert.match(wide, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub ⟡ usage$/);
|
|
326
|
+
|
|
327
|
+
// 100 cols: the profile no longer fits, but effort and location still do.
|
|
328
|
+
const { text: noProfile } = renderShellHeaderBar(withProfile, plainTheme, 100);
|
|
329
|
+
assert.doesNotMatch(noProfile, /team/);
|
|
330
|
+
assert.match(noProfile, /gpt-5\.5 · medium/);
|
|
331
|
+
assert.match(noProfile, /~\/work\/gentle-pi main/);
|
|
332
|
+
assert.equal(visibleWidth(noProfile), 100);
|
|
333
|
+
|
|
334
|
+
// 90 cols: effort goes too, only the bare model id remains next to location.
|
|
335
|
+
const { text: noEffort } = renderShellHeaderBar(withProfile, plainTheme, 90);
|
|
336
|
+
assert.doesNotMatch(noEffort, /medium/);
|
|
337
|
+
assert.doesNotMatch(noEffort, /team/);
|
|
338
|
+
assert.match(noEffort, /gpt-5\.5/);
|
|
339
|
+
assert.match(noEffort, /~\/work\/gentle-pi main/);
|
|
340
|
+
assert.equal(visibleWidth(noEffort), 90);
|
|
341
|
+
|
|
342
|
+
// 82 cols: the whole location segment goes; brand and model survive with the counters.
|
|
343
|
+
const { text: noLocation } = renderShellHeaderBar(withProfile, plainTheme, 82);
|
|
344
|
+
assert.doesNotMatch(noLocation, /~\/work\/gentle-pi/);
|
|
345
|
+
assert.match(noLocation, /gpt-5\.5/);
|
|
346
|
+
assert.match(noLocation, /✿ Gentle Shell/);
|
|
347
|
+
assert.match(noLocation, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub ⟡ usage$/);
|
|
348
|
+
assert.equal(visibleWidth(noLocation), 82);
|
|
349
|
+
|
|
350
|
+
// 60 cols: even the standing usage segment is gone now; brand+model and ctx/cost survive.
|
|
351
|
+
const { text: noUsage } = renderShellHeaderBar(withProfile, plainTheme, 60);
|
|
352
|
+
assert.doesNotMatch(noUsage, /~\/work\/gentle-pi/);
|
|
353
|
+
assert.doesNotMatch(noUsage, /usage/);
|
|
354
|
+
assert.match(noUsage, /gpt-5\.5/);
|
|
355
|
+
assert.match(noUsage, /✿ Gentle Shell/);
|
|
356
|
+
assert.match(noUsage, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub$/);
|
|
357
|
+
assert.equal(visibleWidth(noUsage), 60);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("renderShellHeaderBar returns an empty string only once the brand itself cannot fit", () => {
|
|
361
|
+
assert.equal(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 3).text, "");
|
|
362
|
+
assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 40).text, /✿ Gentle Shell/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// T8: the usage segment. It rides after cost in the right group, shows every
|
|
366
|
+
// window of the active provider's main limit, and degrades (gauges, then
|
|
367
|
+
// secondary windows, then the whole segment) before ctx/cost is ever touched.
|
|
368
|
+
|
|
369
|
+
const USAGE_TWO_WINDOWS = {
|
|
370
|
+
provider: "openai-codex",
|
|
371
|
+
plan: "pro",
|
|
372
|
+
fetchedAt: 0,
|
|
373
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
374
|
+
{ label: "5h", usedPercent: 26, windowSeconds: 18_000, resetAt: null },
|
|
375
|
+
{ label: "week", usedPercent: 12, windowSeconds: 604_800, resetAt: null },
|
|
376
|
+
] }],
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
test("renderShellHeaderBar shows every usage window with its gauge and the shortcut hint, after cost", () => {
|
|
380
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
381
|
+
const { text, usageSpan } = renderShellHeaderBar(header, plainTheme, 140, "alt+u");
|
|
382
|
+
assert.match(text, /\$9\.49 sub ⟡ usage 5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12% · alt\+u$/);
|
|
383
|
+
assert.ok(usageSpan);
|
|
384
|
+
assert.equal(text.slice(usageSpan.start, usageSpan.end), "usage 5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12% · alt+u");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("renderShellHeaderBar shows 'usage · <shortcut>' with no data, and drops the hint when the shortcut is disabled", () => {
|
|
388
|
+
const withHint = renderShellHeaderBar(buildShellHeaderModel(model({ usage: undefined })), plainTheme, 140, "alt+u");
|
|
389
|
+
assert.match(withHint.text, /usage · alt\+u$/);
|
|
390
|
+
const noHint = renderShellHeaderBar(buildShellHeaderModel(model({ usage: undefined })), plainTheme, 140, undefined);
|
|
391
|
+
assert.match(noHint.text, /usage$/);
|
|
392
|
+
assert.doesNotMatch(noHint.text, /alt\+u/);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("renderShellHeaderBar degrades the usage segment (gauges, then secondary windows, then the whole segment) before touching ctx/cost", () => {
|
|
396
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
397
|
+
const full = renderShellHeaderBar(header, plainTheme, 140, "alt+u").text;
|
|
398
|
+
assert.match(full, /5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12%/);
|
|
399
|
+
|
|
400
|
+
// 100 cols: the gauges no longer fit, but both windows still show as text
|
|
401
|
+
// (a positive match on the bare "5h 26% · week 12%" run rules out any
|
|
402
|
+
// gauge glyph sneaking in between them; ctx's own gauge is unrelated).
|
|
403
|
+
const noGauges = renderShellHeaderBar(header, plainTheme, 100, "alt+u").text;
|
|
404
|
+
assert.match(noGauges, /usage 5h 26% · week 12% · alt\+u$/);
|
|
405
|
+
assert.match(noGauges, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub/, "ctx/cost are untouched while usage still degrades");
|
|
406
|
+
assert.equal(visibleWidth(noGauges), 100);
|
|
407
|
+
|
|
408
|
+
// 80 cols: only the first window remains.
|
|
409
|
+
const primaryOnly = renderShellHeaderBar(header, plainTheme, 80, "alt+u").text;
|
|
410
|
+
assert.match(primaryOnly, /usage 5h 26% · alt\+u$/);
|
|
411
|
+
assert.doesNotMatch(primaryOnly, /week/);
|
|
412
|
+
assert.match(primaryOnly, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub/);
|
|
413
|
+
assert.equal(visibleWidth(primaryOnly), 80);
|
|
414
|
+
|
|
415
|
+
// 70 cols: the whole usage segment is gone, ctx/cost remain intact.
|
|
416
|
+
const noUsage = renderShellHeaderBar(header, plainTheme, 70, "alt+u").text;
|
|
417
|
+
assert.doesNotMatch(noUsage, /usage/);
|
|
418
|
+
assert.match(noUsage, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub$/);
|
|
419
|
+
assert.equal(visibleWidth(noUsage), 70);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("renderShellHeaderBar's usage span always points at the usage text, not ctx/cost", () => {
|
|
423
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
424
|
+
for (const width of [140, 100, 90]) {
|
|
425
|
+
const { text, usageSpan } = renderShellHeaderBar(header, plainTheme, width, "alt+u");
|
|
426
|
+
if (!usageSpan) continue;
|
|
427
|
+
assert.match(text.slice(usageSpan.start, usageSpan.end), /^usage/);
|
|
428
|
+
assert.equal(usageSpan.end, visibleWidth(text), "the usage segment always ends at the right edge");
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("renderShellHeaderRule paints one full-width line in the editor frame color", () => {
|
|
433
|
+
assert.equal(renderShellHeaderRule(taggedTheme, 4), "<border>────</border>", "the rule uses the editor frame's border role");
|
|
434
|
+
assert.equal(renderShellHeaderRule(plainTheme, 12), "─".repeat(12), "the rule spans the full width");
|
|
435
|
+
assert.equal(renderShellHeaderRule(plainTheme, 0), "");
|
|
436
|
+
assert.equal(renderShellHeaderRule(plainTheme, -3), "", "negative widths clamp to an empty rule");
|
|
437
|
+
});
|
package/tests/shell-card.test.ts
CHANGED
|
@@ -54,10 +54,12 @@ test("renderCard collapses to the frame and the first body line with an expand h
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
test("renderCard uses the card tone across the full frame while preserving content roles", () => {
|
|
57
|
+
// INFO paints the rose frame: the rounded border in the plain border role,
|
|
58
|
+
// the title in accent — the same look every sidebar card already used.
|
|
57
59
|
const info = renderCard(card(), taggedTheme, 80, { expanded: true });
|
|
58
|
-
assert.match(info[0], /^<
|
|
59
|
-
assert.match(info[1], /^<
|
|
60
|
-
assert.match(info[info.length - 1], /^<
|
|
60
|
+
assert.match(info[0], /^<border>╭<\/border><border>─ <\/border><accent>✿ Gentle AI<\/accent> <muted>·<\/muted> <muted>review preflight<\/muted><border> ─+<\/border><border>╮<\/border>$/);
|
|
61
|
+
assert.match(info[1], /^<border>│<\/border> <text>.*<border>│<\/border>$/);
|
|
62
|
+
assert.match(info[info.length - 1], /^<border>╰<\/border><border>─+╯<\/border>$/);
|
|
61
63
|
|
|
62
64
|
const warning = renderCard(card({ tone: CARD_TONE.WARNING, subtitle: undefined }), taggedTheme, 80, { expanded: true });
|
|
63
65
|
assert.match(warning[0], /^<warning>╭<\/warning>/);
|
|
@@ -62,6 +62,30 @@ function mouseButton(type: TuiMouseEvent["type"], button: TuiMouseEvent["button"
|
|
|
62
62
|
return { type, button, x, y, screenX: x, screenY: y, width: 80, height, shift: false, alt: false, ctrl: false };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
|
|
66
|
+
// hover role every other clickable surface uses, and it never overrides the
|
|
67
|
+
// already-selected row's own role.
|
|
68
|
+
test("WorktreeChangesView paints the shared hover role over an unselected row, never the selected one", async () => {
|
|
69
|
+
const trees = ["/main", "/linked"].map((root) => ({ root, branch: root === "/main" ? "main" : undefined, model: changesModel([file("same.ts", 1, 0)]) }));
|
|
70
|
+
const component = new WorktreeChangesView(trees, {
|
|
71
|
+
theme: taggedTheme, rows: 10,
|
|
72
|
+
loadDiff: async () => "",
|
|
73
|
+
onOpen: () => {},
|
|
74
|
+
onClose: () => {}, requestRender() {}, onRefresh() {},
|
|
75
|
+
});
|
|
76
|
+
component.render(80);
|
|
77
|
+
// Row y=1 is the selected group header (/main, index 0); row y=2 is /linked's header.
|
|
78
|
+
const hoverOther = component.handleMouse(mouseButton("move", "none", 3, 2, 10));
|
|
79
|
+
assert.deepEqual(hoverOther, { handled: true, render: true });
|
|
80
|
+
assert.match(component.render(80)[2]!, /<warning>[^<]*detached/, "hovering the unselected row paints the shared hover role");
|
|
81
|
+
assert.doesNotMatch(component.render(80)[1]!, /<warning>/, "the selected row keeps its own role");
|
|
82
|
+
|
|
83
|
+
const hoverSelected = component.handleMouse(mouseButton("move", "none", 3, 1, 10));
|
|
84
|
+
assert.deepEqual(hoverSelected, { handled: true, render: true });
|
|
85
|
+
assert.doesNotMatch(component.render(80)[1]!, /<warning>/, "hovering the selected row still never paints the hover role");
|
|
86
|
+
assert.doesNotMatch(component.render(80)[2]!, /<warning>/, "leaving the other row clears its hover");
|
|
87
|
+
});
|
|
88
|
+
|
|
65
89
|
test("worktree accordion keeps groups and nested files beside a framed lazy diff", async () => {
|
|
66
90
|
const trees = ["/main", "/linked"].map((root) => ({ root, branch: root === "/main" ? "main" : undefined, model: changesModel([file("same.ts", 1, 0)]) }));
|
|
67
91
|
const loaded: string[] = [];
|
|
@@ -503,6 +527,29 @@ test("ChangesView opens the selected file and closes on escape or q", async () =
|
|
|
503
527
|
assert.equal(events.filter((event) => event === "close").length, 2);
|
|
504
528
|
});
|
|
505
529
|
|
|
530
|
+
// H1 (odd/tasks/usage-click-and-changes-attribution.md): the same shared
|
|
531
|
+
// hover role every other clickable surface uses, and it never overrides the
|
|
532
|
+
// already-selected row's own role.
|
|
533
|
+
test("ChangesView paints the shared hover role over an unselected file row, never the selected one", async () => {
|
|
534
|
+
const { view: component } = view({ theme: taggedTheme });
|
|
535
|
+
await settle();
|
|
536
|
+
component.render(80);
|
|
537
|
+
// Row y=1 is the selected file (lib/a.ts, index 0); row y=2 is lib/b.ts.
|
|
538
|
+
const hoverOther = component.handleMouse(mouseButton("move", "none", 3, 2, 12));
|
|
539
|
+
assert.deepEqual(hoverOther, { handled: true, render: true });
|
|
540
|
+
assert.match(component.render(80)[2]!, /<warning>[^<]*lib\/b\.ts/, "the hovered, unselected row paints the shared hover role");
|
|
541
|
+
assert.doesNotMatch(component.render(80)[1]!, /<warning>/, "the selected row keeps its own role instead");
|
|
542
|
+
|
|
543
|
+
const hoverSelected = component.handleMouse(mouseButton("move", "none", 3, 1, 12));
|
|
544
|
+
assert.deepEqual(hoverSelected, { handled: true, render: true });
|
|
545
|
+
assert.doesNotMatch(component.render(80)[1]!, /<warning>/, "hovering the selected row still never paints the hover role");
|
|
546
|
+
assert.doesNotMatch(component.render(80)[2]!, /<warning>/, "leaving lib/b.ts clears its hover paint");
|
|
547
|
+
|
|
548
|
+
const left = component.handleMouse(mouseButton("move", "none", 60, 1, 12)); // outside the list pane entirely
|
|
549
|
+
assert.deepEqual(left, { handled: true, render: true });
|
|
550
|
+
assert.doesNotMatch(component.render(80).join("\n"), /<warning>/, "moving off the list clears any hover");
|
|
551
|
+
});
|
|
552
|
+
|
|
506
553
|
test("ChangesView click selects a file without opening it", async () => {
|
|
507
554
|
const { view: component, events } = view();
|
|
508
555
|
await settle();
|