gentle-pi 3.2.0 → 3.3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- 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 +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- 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/command-palette.test.ts +1 -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 +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -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 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -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 +224 -6
- 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 +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
package/tests/shell-bar.test.ts
CHANGED
|
@@ -2,11 +2,13 @@ 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,
|
|
10
12
|
renderShellSidebarBar,
|
|
11
13
|
shellEnabled,
|
|
12
14
|
type ShellBarModel,
|
|
@@ -81,13 +83,13 @@ test("renderShellBar renders one line with the segments in order", () => {
|
|
|
81
83
|
assert.equal(rest.length, 0);
|
|
82
84
|
assert.equal(
|
|
83
85
|
line,
|
|
84
|
-
"✿ gentle
|
|
86
|
+
"✿ gentle shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub",
|
|
85
87
|
);
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
|
|
89
91
|
const [line] = renderShellBar(model(), taggedTheme, 400);
|
|
90
|
-
assert.match(line, /<accent>✿ gentle
|
|
92
|
+
assert.match(line, /<accent>✿ gentle shell<\/accent>/);
|
|
91
93
|
assert.match(line, /<text>gpt-5\.5<\/text>/);
|
|
92
94
|
assert.match(line, /<syntaxFunction>medium<\/syntaxFunction>/);
|
|
93
95
|
assert.match(line, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
|
|
@@ -120,6 +122,36 @@ test("renderShellBar adds the subscription windows after the cost when usage is
|
|
|
120
122
|
assert.match(line, /\$9\.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%$/);
|
|
121
123
|
});
|
|
122
124
|
|
|
125
|
+
test("renderShellBar meters the model the session is using inside a multi-model provider", () => {
|
|
126
|
+
const usage = {
|
|
127
|
+
provider: "nan",
|
|
128
|
+
plan: undefined,
|
|
129
|
+
fetchedAt: 0,
|
|
130
|
+
limits: [
|
|
131
|
+
{ name: "deepseek-v4-flash", limitReached: false, windows: [{ label: "", usedPercent: 18, windowSeconds: 0, resetAt: null, used: 545_000_000, budget: 3_000_000_000 }] },
|
|
132
|
+
{ name: "glm5.3-flash", limitReached: false, windows: [{ label: "", usedPercent: 10, windowSeconds: 0, resetAt: null, used: 200_000_000, budget: 2_000_000_000 }] },
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
const [glm] = renderShellBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 200);
|
|
136
|
+
assert.match(glm, /glm5\.3-flash ▰▱▱▱▱▱▱▱ 10%$/);
|
|
137
|
+
assert.doesNotMatch(glm, /deepseek-v4-flash ▰/);
|
|
138
|
+
const [other] = renderShellBar(model({ modelId: "deepseek-v4-flash", usage }), plainTheme, 200);
|
|
139
|
+
assert.match(other, /deepseek-v4-flash ▰▱▱▱▱▱▱▱ 18%$/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("renderShellBar keeps its own zero-window contract independent of the sidebar", () => {
|
|
143
|
+
const usage = {
|
|
144
|
+
provider: "openai-codex",
|
|
145
|
+
plan: "pro",
|
|
146
|
+
fetchedAt: 0,
|
|
147
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
148
|
+
{ label: "5h", usedPercent: 0, windowSeconds: 18_000, resetAt: null },
|
|
149
|
+
{ label: "week", usedPercent: 0, windowSeconds: 604_800, resetAt: null },
|
|
150
|
+
] }],
|
|
151
|
+
};
|
|
152
|
+
assert.match(renderShellBar(model({ usage }), plainTheme, 200)[0], /codex 5h ▱▱▱▱▱▱▱▱ 0% · week 0%$/);
|
|
153
|
+
});
|
|
154
|
+
|
|
123
155
|
test("renderShellBar shows an unknown context as a question mark after compaction", () => {
|
|
124
156
|
const [line] = renderShellBar(model({ contextPercent: null }), plainTheme, 160);
|
|
125
157
|
assert.match(line, /ctx ▱▱▱▱▱▱▱▱ \?%/);
|
|
@@ -162,7 +194,7 @@ test("renderShellBar drops the session name, then trailing segments, before trun
|
|
|
162
194
|
|
|
163
195
|
const [atFifty] = renderShellBar(wide, plainTheme, 50);
|
|
164
196
|
assert.ok(visibleWidth(atFifty) <= 50, `line overflowed: ${visibleWidth(atFifty)}`);
|
|
165
|
-
assert.match(atFifty, /^✿ gentle
|
|
197
|
+
assert.match(atFifty, /^✿ gentle shell/);
|
|
166
198
|
});
|
|
167
199
|
|
|
168
200
|
test("shellEnabled stays off inside a Gentle Agents child", () => {
|
|
@@ -176,13 +208,20 @@ test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
|
|
|
176
208
|
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
|
|
177
209
|
});
|
|
178
210
|
|
|
179
|
-
test("
|
|
211
|
+
test("renderShellSidebarBar paints the Status card frame with border and the title with accent", () => {
|
|
212
|
+
const lines = renderShellSidebarBar(model(), taggedTheme, 46);
|
|
213
|
+
assert.match(lines[0], /^<border>╭<\/border>/);
|
|
214
|
+
assert.match(lines[0], /<accent>✿ Status<\/accent>/);
|
|
215
|
+
assert.match(lines[lines.length - 1], /^<border>╰<\/border>/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("sidebar unifies project, captured changes and integrations in one frame", () => {
|
|
180
219
|
const data = model({ changes: { files: 2, added: 7, deleted: 3, notice: "capture warning" }, statuses: ["MCP connected"] });
|
|
181
220
|
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
182
221
|
const text = lines.join("\n");
|
|
183
222
|
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
184
223
|
let previous = -1;
|
|
185
|
-
for (const heading of ["Status", "Project", "Changes", "
|
|
224
|
+
for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
|
|
186
225
|
const index = text.indexOf(heading);
|
|
187
226
|
assert.ok(index > previous, heading);
|
|
188
227
|
previous = index;
|
|
@@ -190,7 +229,7 @@ test("sidebar unifies project, captured changes, usage and integrations in one f
|
|
|
190
229
|
assert.match(text, /2 files.*\+7.*−3/);
|
|
191
230
|
assert.match(text, /capture warning/);
|
|
192
231
|
assert.match(text, /\/gentle:changes/);
|
|
193
|
-
assert.match(text, /
|
|
232
|
+
assert.match(text, /main/);
|
|
194
233
|
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
195
234
|
const empty = renderShellSidebarBar(model(), plainTheme, 46).join("\n");
|
|
196
235
|
assert.match(empty, /No captured changes/);
|
|
@@ -209,3 +248,182 @@ test("sidebar profile wraps long names without changing the compact bar", () =>
|
|
|
209
248
|
}
|
|
210
249
|
assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120));
|
|
211
250
|
});
|
|
251
|
+
|
|
252
|
+
test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping Project, Changes and Integrations", () => {
|
|
253
|
+
const usage = {
|
|
254
|
+
provider: "openai-codex",
|
|
255
|
+
plan: "pro",
|
|
256
|
+
fetchedAt: 0,
|
|
257
|
+
limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null }] }],
|
|
258
|
+
};
|
|
259
|
+
const data = model({ profile: "team", sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] });
|
|
260
|
+
const text = renderShellSidebarBar(data, plainTheme, 60).join("\n");
|
|
261
|
+
assert.doesNotMatch(text, /Usage/);
|
|
262
|
+
assert.doesNotMatch(text, /Model/);
|
|
263
|
+
assert.doesNotMatch(text, /Effort/);
|
|
264
|
+
assert.doesNotMatch(text, /Context/);
|
|
265
|
+
assert.doesNotMatch(text, /Cost/);
|
|
266
|
+
assert.doesNotMatch(text, /\$9\.49/);
|
|
267
|
+
assert.doesNotMatch(text, /codex 5h/);
|
|
268
|
+
for (const heading of ["Status", "Project", "Changes", "Integrations"]) assert.match(text, new RegExp(heading));
|
|
269
|
+
assert.match(text, /Branch.*main/);
|
|
270
|
+
assert.match(text, /Session.*session/);
|
|
271
|
+
assert.match(text, /Profile.*team/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// The live header row above the fullscreen rail: identity on the left (brand,
|
|
275
|
+
// location, model · effort · profile), the per-frame counters right-aligned
|
|
276
|
+
// (context gauge, cost). Never the working/thinking state or extension
|
|
277
|
+
// statuses — those stay in the prompt and the Status card.
|
|
278
|
+
|
|
279
|
+
test("buildShellHeaderModel keeps only the header's fields from the bar model", () => {
|
|
280
|
+
const header = buildShellHeaderModel(model({ profile: "team", statuses: ["MCP: 3 servers"] }));
|
|
281
|
+
assert.deepEqual(header, {
|
|
282
|
+
cwd: "~/work/gentle-pi",
|
|
283
|
+
branch: "main",
|
|
284
|
+
dirty: undefined,
|
|
285
|
+
modelId: "gpt-5.5",
|
|
286
|
+
effort: "medium",
|
|
287
|
+
profile: "team",
|
|
288
|
+
contextPercent: 45,
|
|
289
|
+
costTotal: 9.49,
|
|
290
|
+
subscription: true,
|
|
291
|
+
usage: undefined,
|
|
292
|
+
});
|
|
293
|
+
assert.ok(!("statuses" in header), "extension statuses never reach the header");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("renderShellHeaderBar draws the brand, identity, and right-aligned counters (plus the standing usage segment) in one line", () => {
|
|
297
|
+
const header = buildShellHeaderModel(model({ profile: "team" }));
|
|
298
|
+
const { text: line } = renderShellHeaderBar(header, plainTheme, 120);
|
|
299
|
+
const left = "✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team";
|
|
300
|
+
const right = "ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ usage";
|
|
301
|
+
assert.equal(line, left + " ".repeat(120 - visibleWidth(left) - visibleWidth(right)) + right);
|
|
302
|
+
assert.equal(visibleWidth(line), 120);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("renderShellHeaderBar never shows working state or extension statuses", () => {
|
|
306
|
+
const header = buildShellHeaderModel(model({ statuses: ["MCP: 3 servers", "working…"] }));
|
|
307
|
+
const { text: line } = renderShellHeaderBar(header, plainTheme, 120);
|
|
308
|
+
assert.doesNotMatch(line, /MCP: 3 servers/);
|
|
309
|
+
assert.doesNotMatch(line, /working/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("renderShellHeaderBar colors the brand bold and by role", () => {
|
|
313
|
+
const bolding = { fg: (color: string, text: string) => `<${color}>${text}</${color}>`, bold: (text: string) => `**${text}**` };
|
|
314
|
+
const header = buildShellHeaderModel(model());
|
|
315
|
+
const { text: line } = renderShellHeaderBar(header, bolding, 120);
|
|
316
|
+
assert.match(line, /<accent>\*\*✿ Gentle Shell\*\*<\/accent>/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("renderShellHeaderBar drops the profile, then the effort, then the whole location before the right group", () => {
|
|
320
|
+
const withProfile = buildShellHeaderModel(model({ profile: "team" }));
|
|
321
|
+
const { text: wide } = renderShellHeaderBar(withProfile, plainTheme, 120);
|
|
322
|
+
assert.match(wide, /gpt-5\.5 · medium · team/);
|
|
323
|
+
assert.match(wide, /~\/work\/gentle-pi main/);
|
|
324
|
+
assert.match(wide, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub ⟡ usage$/);
|
|
325
|
+
|
|
326
|
+
// 100 cols: the profile no longer fits, but effort and location still do.
|
|
327
|
+
const { text: noProfile } = renderShellHeaderBar(withProfile, plainTheme, 100);
|
|
328
|
+
assert.doesNotMatch(noProfile, /team/);
|
|
329
|
+
assert.match(noProfile, /gpt-5\.5 · medium/);
|
|
330
|
+
assert.match(noProfile, /~\/work\/gentle-pi main/);
|
|
331
|
+
assert.equal(visibleWidth(noProfile), 100);
|
|
332
|
+
|
|
333
|
+
// 90 cols: effort goes too, only the bare model id remains next to location.
|
|
334
|
+
const { text: noEffort } = renderShellHeaderBar(withProfile, plainTheme, 90);
|
|
335
|
+
assert.doesNotMatch(noEffort, /medium/);
|
|
336
|
+
assert.doesNotMatch(noEffort, /team/);
|
|
337
|
+
assert.match(noEffort, /gpt-5\.5/);
|
|
338
|
+
assert.match(noEffort, /~\/work\/gentle-pi main/);
|
|
339
|
+
assert.equal(visibleWidth(noEffort), 90);
|
|
340
|
+
|
|
341
|
+
// 82 cols: the whole location segment goes; brand and model survive with the counters.
|
|
342
|
+
const { text: noLocation } = renderShellHeaderBar(withProfile, plainTheme, 82);
|
|
343
|
+
assert.doesNotMatch(noLocation, /~\/work\/gentle-pi/);
|
|
344
|
+
assert.match(noLocation, /gpt-5\.5/);
|
|
345
|
+
assert.match(noLocation, /✿ Gentle Shell/);
|
|
346
|
+
assert.match(noLocation, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub ⟡ usage$/);
|
|
347
|
+
assert.equal(visibleWidth(noLocation), 82);
|
|
348
|
+
|
|
349
|
+
// 60 cols: even the standing usage segment is gone now; brand+model and ctx/cost survive.
|
|
350
|
+
const { text: noUsage } = renderShellHeaderBar(withProfile, plainTheme, 60);
|
|
351
|
+
assert.doesNotMatch(noUsage, /~\/work\/gentle-pi/);
|
|
352
|
+
assert.doesNotMatch(noUsage, /usage/);
|
|
353
|
+
assert.match(noUsage, /gpt-5\.5/);
|
|
354
|
+
assert.match(noUsage, /✿ Gentle Shell/);
|
|
355
|
+
assert.match(noUsage, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub$/);
|
|
356
|
+
assert.equal(visibleWidth(noUsage), 60);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("renderShellHeaderBar returns an empty string only once the brand itself cannot fit", () => {
|
|
360
|
+
assert.equal(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 3).text, "");
|
|
361
|
+
assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 40).text, /✿ Gentle Shell/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// T8: the usage segment. It rides after cost in the right group, shows every
|
|
365
|
+
// window of the active provider's main limit, and degrades (gauges, then
|
|
366
|
+
// secondary windows, then the whole segment) before ctx/cost is ever touched.
|
|
367
|
+
|
|
368
|
+
const USAGE_TWO_WINDOWS = {
|
|
369
|
+
provider: "openai-codex",
|
|
370
|
+
plan: "pro",
|
|
371
|
+
fetchedAt: 0,
|
|
372
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
373
|
+
{ label: "5h", usedPercent: 26, windowSeconds: 18_000, resetAt: null },
|
|
374
|
+
{ label: "week", usedPercent: 12, windowSeconds: 604_800, resetAt: null },
|
|
375
|
+
] }],
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
test("renderShellHeaderBar shows every usage window with its gauge and the shortcut hint, after cost", () => {
|
|
379
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
380
|
+
const { text, usageSpan } = renderShellHeaderBar(header, plainTheme, 140, "alt+u");
|
|
381
|
+
assert.match(text, /\$9\.49 sub ⟡ usage 5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12% · alt\+u$/);
|
|
382
|
+
assert.ok(usageSpan);
|
|
383
|
+
assert.equal(text.slice(usageSpan.start, usageSpan.end), "usage 5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12% · alt+u");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("renderShellHeaderBar shows 'usage · <shortcut>' with no data, and drops the hint when the shortcut is disabled", () => {
|
|
387
|
+
const withHint = renderShellHeaderBar(buildShellHeaderModel(model({ usage: undefined })), plainTheme, 140, "alt+u");
|
|
388
|
+
assert.match(withHint.text, /usage · alt\+u$/);
|
|
389
|
+
const noHint = renderShellHeaderBar(buildShellHeaderModel(model({ usage: undefined })), plainTheme, 140, undefined);
|
|
390
|
+
assert.match(noHint.text, /usage$/);
|
|
391
|
+
assert.doesNotMatch(noHint.text, /alt\+u/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("renderShellHeaderBar degrades the usage segment (gauges, then secondary windows, then the whole segment) before touching ctx/cost", () => {
|
|
395
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
396
|
+
const full = renderShellHeaderBar(header, plainTheme, 140, "alt+u").text;
|
|
397
|
+
assert.match(full, /5h ▰▰▱▱▱▱▱▱ 26% · week ▰▱▱▱▱▱▱▱ 12%/);
|
|
398
|
+
|
|
399
|
+
// 100 cols: the gauges no longer fit, but both windows still show as text
|
|
400
|
+
// (a positive match on the bare "5h 26% · week 12%" run rules out any
|
|
401
|
+
// gauge glyph sneaking in between them; ctx's own gauge is unrelated).
|
|
402
|
+
const noGauges = renderShellHeaderBar(header, plainTheme, 100, "alt+u").text;
|
|
403
|
+
assert.match(noGauges, /usage 5h 26% · week 12% · alt\+u$/);
|
|
404
|
+
assert.match(noGauges, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub/, "ctx/cost are untouched while usage still degrades");
|
|
405
|
+
assert.equal(visibleWidth(noGauges), 100);
|
|
406
|
+
|
|
407
|
+
// 80 cols: only the first window remains.
|
|
408
|
+
const primaryOnly = renderShellHeaderBar(header, plainTheme, 80, "alt+u").text;
|
|
409
|
+
assert.match(primaryOnly, /usage 5h 26% · alt\+u$/);
|
|
410
|
+
assert.doesNotMatch(primaryOnly, /week/);
|
|
411
|
+
assert.match(primaryOnly, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub/);
|
|
412
|
+
assert.equal(visibleWidth(primaryOnly), 80);
|
|
413
|
+
|
|
414
|
+
// 70 cols: the whole usage segment is gone, ctx/cost remain intact.
|
|
415
|
+
const noUsage = renderShellHeaderBar(header, plainTheme, 70, "alt+u").text;
|
|
416
|
+
assert.doesNotMatch(noUsage, /usage/);
|
|
417
|
+
assert.match(noUsage, /ctx ▰▰▰▰▱▱▱▱ 45% ⟡ \$9\.49 sub$/);
|
|
418
|
+
assert.equal(visibleWidth(noUsage), 70);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("renderShellHeaderBar's usage span always points at the usage text, not ctx/cost", () => {
|
|
422
|
+
const header = buildShellHeaderModel(model({ usage: USAGE_TWO_WINDOWS }));
|
|
423
|
+
for (const width of [140, 100, 90]) {
|
|
424
|
+
const { text, usageSpan } = renderShellHeaderBar(header, plainTheme, width, "alt+u");
|
|
425
|
+
if (!usageSpan) continue;
|
|
426
|
+
assert.match(text.slice(usageSpan.start, usageSpan.end), /^usage/);
|
|
427
|
+
assert.equal(usageSpan.end, visibleWidth(text), "the usage segment always ends at the right edge");
|
|
428
|
+
}
|
|
429
|
+
});
|
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();
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
CHANGE_STATUS,
|
|
11
11
|
ChangesTracker,
|
|
12
12
|
WorktreeChangesTracker,
|
|
13
|
+
RootBranchLabels,
|
|
14
|
+
UNKNOWN_BRANCH,
|
|
15
|
+
foreignRootBranch,
|
|
13
16
|
parseWorktrees,
|
|
14
17
|
changesSummary,
|
|
15
18
|
emptyChanges,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
changesModel,
|
|
20
23
|
snapshotChanges,
|
|
21
24
|
type ChangedFile,
|
|
25
|
+
type GitResult,
|
|
22
26
|
} from "../lib/shell-changes.ts";
|
|
23
27
|
|
|
24
28
|
// The changes view shows the working tree against HEAD, new files included,
|
|
@@ -72,6 +76,16 @@ test("parsePorcelain reads NUL-separated status entries including renames and un
|
|
|
72
76
|
);
|
|
73
77
|
});
|
|
74
78
|
|
|
79
|
+
test("parsePorcelain drops embedded-repository markers, never a real file", () => {
|
|
80
|
+
// Git reports a nested .git directory (an embedded/foreign repository, the
|
|
81
|
+
// live evidence's ~/work/NaN-builders inside ~/work) as one directory-shaped
|
|
82
|
+
// "?? path/" line, never expanded into files, even with --untracked-files=all.
|
|
83
|
+
// Treating that line as a changed file creates a phantom entry for whatever
|
|
84
|
+
// ancestor repository happens to contain the nested one.
|
|
85
|
+
const raw = ["?? NaN-builders/", "?? notes.md", " M lib/a.ts"].join("\0") + "\0";
|
|
86
|
+
assert.deepEqual([...parsePorcelain(raw).entries()], [["notes.md", CHANGE_STATUS.UNTRACKED], ["lib/a.ts", CHANGE_STATUS.MODIFIED]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
75
89
|
test("snapshotChanges merges status with counts and keeps untracked files without counts", () => {
|
|
76
90
|
const files = snapshotChanges({
|
|
77
91
|
numstat: "4\t2\tlib/a.ts\n",
|
|
@@ -265,6 +279,114 @@ test("worktree porcelain preserves unusual directory names and detached fallback
|
|
|
265
279
|
assert.deepEqual(parseWorktrees("worktree /a\nbranch strange\0HEAD abc\0detached\0\0"), [{ root: "/a\nbranch strange" }]);
|
|
266
280
|
});
|
|
267
281
|
|
|
282
|
+
// A GitRunner that cannot even ask (root removed, git missing, the runner
|
|
283
|
+
// itself rejecting) must reject foreignRootBranch too -- never resolve to the
|
|
284
|
+
// same undefined a genuinely detached HEAD produces. Swallowing the two into
|
|
285
|
+
// one shape is exactly the bug: the caller has no way left to tell "asked and
|
|
286
|
+
// there is no branch" from "could not ask at all".
|
|
287
|
+
test("foreignRootBranch propagates a GitRunner failure instead of reporting detached", async () => {
|
|
288
|
+
const failing = async (): Promise<GitResult> => { throw new Error("git unavailable"); };
|
|
289
|
+
await assert.rejects(() => foreignRootBranch(failing), /git unavailable/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("foreignRootBranch still reports a genuine detached HEAD as undefined, not a failure", async () => {
|
|
293
|
+
const detached = async (args: string[]): Promise<GitResult> => (args[0] === "symbolic-ref" ? { stdout: "", code: 1 } : { stdout: "deadbeef\n", code: 0 });
|
|
294
|
+
assert.equal(await foreignRootBranch(detached), undefined);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// C3 (odd/tasks/usage-click-and-changes-attribution.md): a root registered by
|
|
298
|
+
// the session but absent from a single `git worktree list` scan -- because it
|
|
299
|
+
// belongs to a different Git clone entirely, e.g. an inner repository nested
|
|
300
|
+
// inside an outer one -- must not default to "detached". It must be asked
|
|
301
|
+
// directly, and "no commits yet" must read differently from a real branch.
|
|
302
|
+
test("a root missing from worktree list resolves its own branch instead of defaulting to detached", async (t) => {
|
|
303
|
+
const scenarios: Array<{ name: string; symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number }; expected: string | undefined }> = [
|
|
304
|
+
{ name: "a normal branch on a foreign clone", symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: "feature" },
|
|
305
|
+
{ name: "an unborn branch (no commits yet)", symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 }, expected: "no commits yet" },
|
|
306
|
+
{ name: "a genuinely detached HEAD", symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: undefined },
|
|
307
|
+
];
|
|
308
|
+
for (const scenario of scenarios) {
|
|
309
|
+
await t.test(scenario.name, async () => {
|
|
310
|
+
const calls: string[][] = [];
|
|
311
|
+
const tracker = new WorktreeChangesTracker(
|
|
312
|
+
async () => ({ stdout: "", code: 0 }), // /foreign never appears in this clone's own worktree list
|
|
313
|
+
() => async (args) => {
|
|
314
|
+
calls.push(args);
|
|
315
|
+
if (args[0] === "status") return { stdout: "?? changed.ts\0", code: 0 };
|
|
316
|
+
if (args[0] === "symbolic-ref") return scenario.symbolic;
|
|
317
|
+
if (args[0] === "rev-parse") return scenario.verify;
|
|
318
|
+
return { stdout: "", code: 0 };
|
|
319
|
+
},
|
|
320
|
+
undefined,
|
|
321
|
+
() => ["/foreign"],
|
|
322
|
+
);
|
|
323
|
+
await tracker.start();
|
|
324
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", scenario.expected]]);
|
|
325
|
+
assert.ok(calls.some((args) => args[0] === "symbolic-ref"), "a root absent from worktree list must be asked directly");
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// The direct per-root ask (for a root missing from worktree list) can fail on
|
|
331
|
+
// its own -- root gone, git unavailable -- independently of the change scan
|
|
332
|
+
// that already found real files. That failure must label the root
|
|
333
|
+
// UNKNOWN_BRANCH, distinct from "detached", and must never drop the
|
|
334
|
+
// already-captured changes.
|
|
335
|
+
test("a root missing from worktree list keeps its changes and reads UNKNOWN_BRANCH when the direct ask fails", async () => {
|
|
336
|
+
const tracker = new WorktreeChangesTracker(
|
|
337
|
+
async () => ({ stdout: "", code: 0 }),
|
|
338
|
+
() => async (args) => {
|
|
339
|
+
if (args[0] === "status") return { stdout: "?? changed.ts\0", code: 0 };
|
|
340
|
+
if (args[0] === "diff") return { stdout: "", code: 0 };
|
|
341
|
+
throw new Error("git unavailable"); // symbolic-ref / rev-parse: the direct branch ask
|
|
342
|
+
},
|
|
343
|
+
undefined,
|
|
344
|
+
() => ["/foreign"],
|
|
345
|
+
);
|
|
346
|
+
await tracker.start();
|
|
347
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", UNKNOWN_BRANCH]]);
|
|
348
|
+
assert.equal(tracker.worktrees[0]!.model.files.length, 1, "a failed branch lookup must not drop the root's real changes");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// A root already described by the same clone's own worktree list is trusted
|
|
352
|
+
// as-is: a real "detached" marker there is already 100% accurate, so no
|
|
353
|
+
// redundant per-root call is needed (and none happens).
|
|
354
|
+
test("a root already described by worktree list is trusted without a redundant lookup", async () => {
|
|
355
|
+
const calls: string[][] = [];
|
|
356
|
+
const tracker = new WorktreeChangesTracker(
|
|
357
|
+
async () => ({ stdout: "worktree /known\0detached\0\0", code: 0 }),
|
|
358
|
+
() => async (args) => {
|
|
359
|
+
calls.push(args);
|
|
360
|
+
return { stdout: args[0] === "status" ? "?? changed.ts\0" : "", code: 0 };
|
|
361
|
+
},
|
|
362
|
+
undefined,
|
|
363
|
+
() => ["/known"],
|
|
364
|
+
);
|
|
365
|
+
await tracker.start();
|
|
366
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/known", undefined]]);
|
|
367
|
+
assert.ok(!calls.some((args) => args[0] === "symbolic-ref"), "a known worktree-list entry must not trigger a fallback call");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// The live evidence: an outer repo (~/work, no commits) whose ONLY change is
|
|
371
|
+
// the embedded inner repository (~/work/NaN-builders) itself must never show
|
|
372
|
+
// up as a phantom tree once the embedded-repo marker is filtered out of its
|
|
373
|
+
// own file list -- it never had a real file to show in the first place.
|
|
374
|
+
test("an outer ancestor whose only change is the embedded inner repository is never a phantom tree", async () => {
|
|
375
|
+
const tracker = new WorktreeChangesTracker(
|
|
376
|
+
async () => ({ stdout: "", code: 0 }), // neither root belongs to the other's clone
|
|
377
|
+
(root) => async (args) => {
|
|
378
|
+
if (args[0] !== "status") return { stdout: "", code: 0 };
|
|
379
|
+
// The outer root's only "change" is the nested repo directory marker;
|
|
380
|
+
// the inner root has a real file of its own.
|
|
381
|
+
return { stdout: root === "/outer" ? "?? NaN-builders/\0" : "?? jpg-png-converter.md\0", code: 0 };
|
|
382
|
+
},
|
|
383
|
+
undefined,
|
|
384
|
+
() => ["/outer", "/outer/NaN-builders"],
|
|
385
|
+
);
|
|
386
|
+
await tracker.start();
|
|
387
|
+
assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/outer/NaN-builders"]);
|
|
388
|
+
});
|
|
389
|
+
|
|
268
390
|
test("registered roots remain scannable during metadata discovery failure", async () => {
|
|
269
391
|
let code = 128;
|
|
270
392
|
const tracker = new WorktreeChangesTracker(async () => ({ stdout: "worktree /repo\0\0", code }), () => async (args) => ({ stdout: args[0] === "status" ? "?? new.ts\0" : "", code: 0 }), undefined, () => ["/repo"]);
|
|
@@ -356,3 +478,58 @@ test("ChangesTracker counts the lines of untracked files, which git numstat leav
|
|
|
356
478
|
assert.equal(model.added, 3);
|
|
357
479
|
assert.deepEqual(counted, ["notes.md", "empty.md"]);
|
|
358
480
|
});
|
|
481
|
+
|
|
482
|
+
// The session-evidence tracker behind /gentle:changes knows roots, never
|
|
483
|
+
// branches. The overlay decorates its trees with each root's own HEAD state,
|
|
484
|
+
// resolved once per root and reported back so the view can repaint; until git
|
|
485
|
+
// answers the tree shows no branch (the view's "detached" fallback), and a
|
|
486
|
+
// genuinely detached HEAD stays that way.
|
|
487
|
+
test("RootBranchLabels decorates session trees with each root's branch once git answers", async () => {
|
|
488
|
+
const calls = new Map<string, string[][]>();
|
|
489
|
+
const answers: Record<string, { symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number } }> = {
|
|
490
|
+
"/repo": { symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 } },
|
|
491
|
+
"/fresh": { symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 } },
|
|
492
|
+
"/pinned": { symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 } },
|
|
493
|
+
};
|
|
494
|
+
let changed = 0;
|
|
495
|
+
const labels = new RootBranchLabels((root) => async (args) => {
|
|
496
|
+
calls.set(root, [...(calls.get(root) ?? []), args]);
|
|
497
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
498
|
+
return args[0] === "symbolic-ref" ? answers[root]!.symbolic : answers[root]!.verify;
|
|
499
|
+
}, () => changed++);
|
|
500
|
+
const trees = Object.keys(answers).map((root) => ({ root, model: changesModel([]) }));
|
|
501
|
+
const first = labels.decorate(trees);
|
|
502
|
+
assert.deepEqual(first.map((tree) => tree.branch), [undefined, undefined, undefined], "nothing is labelled before git answers");
|
|
503
|
+
await labels.settled();
|
|
504
|
+
assert.equal(changed, 3, "each resolved root asks the view to repaint once");
|
|
505
|
+
const second = labels.decorate(trees);
|
|
506
|
+
assert.deepEqual(second.map((tree) => tree.branch), ["feature", "no commits yet", undefined]);
|
|
507
|
+
labels.decorate(trees);
|
|
508
|
+
await labels.settled();
|
|
509
|
+
assert.deepEqual([...calls.values()].map((list) => list.length), [2, 2, 1], "one resolution per root, never per decorate call");
|
|
510
|
+
assert.equal(changed, 3);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// A root that could not even be asked (removed, git unavailable, the runner
|
|
514
|
+
// itself rejecting) must read UNKNOWN_BRANCH -- never the same undefined a
|
|
515
|
+
// genuine detached HEAD produces, and never silently stay unlabelled forever.
|
|
516
|
+
test("RootBranchLabels labels a root UNKNOWN_BRANCH when it could not be asked, distinct from a real detached HEAD", async () => {
|
|
517
|
+
let changed = 0;
|
|
518
|
+
const labels = new RootBranchLabels((root) => async () => {
|
|
519
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
520
|
+
if (root === "/gone") throw new Error("root removed");
|
|
521
|
+
return { stdout: "", code: 1 };
|
|
522
|
+
}, () => changed++);
|
|
523
|
+
const trees = [
|
|
524
|
+
{ root: "/gone", model: changesModel([]) },
|
|
525
|
+
{ root: "/detached", model: changesModel([]) },
|
|
526
|
+
];
|
|
527
|
+
labels.decorate(trees);
|
|
528
|
+
await labels.settled();
|
|
529
|
+
assert.deepEqual(
|
|
530
|
+
labels.decorate(trees).map((tree) => tree.branch),
|
|
531
|
+
[UNKNOWN_BRANCH, undefined],
|
|
532
|
+
"a failed ask reads UNKNOWN_BRANCH; a real detached HEAD still reads undefined (the view's detached fallback)",
|
|
533
|
+
);
|
|
534
|
+
assert.equal(changed, 2, "a failed ask still resolves and asks the view to repaint, instead of hanging forever");
|
|
535
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { HOVER_ROLE, paintHoverable } from "../lib/shell-hover.ts";
|
|
4
|
+
|
|
5
|
+
const theme = {
|
|
6
|
+
fg(role: string, text: string) {
|
|
7
|
+
return `<${role}>${text}</${role}>`;
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
test("paintHoverable paints the shared hover role only while hovered", () => {
|
|
12
|
+
assert.equal(paintHoverable(theme, "refresh", true), `<${HOVER_ROLE}>refresh</${HOVER_ROLE}>`);
|
|
13
|
+
assert.equal(paintHoverable(theme, "refresh", false), "refresh");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("paintHoverable falls back to an idle role when given one", () => {
|
|
17
|
+
assert.equal(paintHoverable(theme, "refresh", false, "dim"), "<dim>refresh</dim>");
|
|
18
|
+
assert.equal(paintHoverable(theme, "refresh", true, "dim"), `<${HOVER_ROLE}>refresh</${HOVER_ROLE}>`, "hover always wins over the idle role");
|
|
19
|
+
});
|
|
@@ -128,6 +128,26 @@ test("framePromptLines keeps the editor scroll indicators inside the frame", ()
|
|
|
128
128
|
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("framePromptLines shows an explicit escHint on the bottom rule, overriding the editor's own scroll indicator", () => {
|
|
132
|
+
const width = 40;
|
|
133
|
+
const inner = width - 2;
|
|
134
|
+
const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
|
|
135
|
+
const lines = framePromptLines(
|
|
136
|
+
["─".repeat(inner), ` x${" ".repeat(inner - 2)}`, bottom],
|
|
137
|
+
width,
|
|
138
|
+
options({ fg: (_c, t) => t, escHint: "esc again to cancel" }),
|
|
139
|
+
);
|
|
140
|
+
assert.match(stripAnsi(lines[2]), /^╰─ esc again to cancel ─+╯$/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("framePromptLines falls back to the scroll indicator when no escHint is set", () => {
|
|
144
|
+
const width = 40;
|
|
145
|
+
const inner = width - 2;
|
|
146
|
+
const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
|
|
147
|
+
const lines = framePromptLines(["─".repeat(inner), ` x${" ".repeat(inner - 2)}`, bottom], width, options({ fg: (_c, t) => t }));
|
|
148
|
+
assert.match(stripAnsi(lines[2]), /^╰─ ↓ 3 more ─+╯$/);
|
|
149
|
+
});
|
|
150
|
+
|
|
131
151
|
test("withPromptHint places a dim hint after the cursor on an empty editor line", () => {
|
|
132
152
|
const inner = 38;
|
|
133
153
|
const line = ` ${CURSOR}${" ".repeat(inner - 2)}`;
|