gentle-pi 2.7.0 → 3.0.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 +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const asset = (name: string) => readFileSync(new URL(`../assets/${name}`, import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
test("task truth uses ordinary checkboxes without local owner-marker admission", () => {
|
|
8
|
+
const tasks = asset("agents/sdd-tasks.md"), apply = asset("agents/sdd-apply.md");
|
|
9
|
+
assert.match(tasks, /- \[ \] 1\. Implement and verify the behavior\./);
|
|
10
|
+
assert.doesNotMatch(tasks, /<!-- sdd-owner:/);
|
|
11
|
+
assert.doesNotMatch(apply, /fix-task-ownership-marker|unsupported, duplicate, or non-terminal/);
|
|
12
|
+
assert.match(apply, /Preserve historical ownership comments/);
|
|
13
|
+
assert.match(apply, /native task progress and authorized scope/);
|
|
14
|
+
assert.match(apply, /mark each completed implementation task.*immediately after completion/);
|
|
15
|
+
assert.match(apply, /Before returning, re-read the persisted tasks artifact/);
|
|
16
|
+
assert.match(apply, /never overwrite completed work/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("task planning honors configured TDD rather than test availability", () => {
|
|
20
|
+
const tasks = asset("agents/sdd-tasks.md");
|
|
21
|
+
assert.doesNotMatch(tasks, /If tests exist or strict TDD/);
|
|
22
|
+
assert.match(tasks, /Only when configured strict TDD is active/);
|
|
23
|
+
assert.match(tasks, /Test availability alone does not enable TDD/);
|
|
24
|
+
assert.match(tasks, /400 changed lines/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("verification reports remaining task truth without manufacturing archive authority", () => {
|
|
28
|
+
const verify = asset("agents/sdd-verify.md");
|
|
29
|
+
assert.doesNotMatch(verify, /mark each as a CRITICAL completeness issue and archive blocker|Archive exceptions are limited/);
|
|
30
|
+
assert.match(verify, /Report the exact unchecked lines as remaining work/);
|
|
31
|
+
assert.match(verify, /Do not return a clean `PASS`/);
|
|
32
|
+
assert.match(verify, /Archive admission follows fresh native status and real permissions/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("selected stores preserve partial persistence and cumulative readback truth", () => {
|
|
36
|
+
const apply = asset("agents/sdd-apply.md"), memory = asset("orchestrator-memory.md");
|
|
37
|
+
assert.match(apply, /`openspec`\s*\/\s*`both`: write\/update/);
|
|
38
|
+
assert.match(apply, /`engram`\s*\/\s*`both`: update the/);
|
|
39
|
+
assert.match(apply, /read back each selected backend/);
|
|
40
|
+
assert.match(apply, /not atomic/);
|
|
41
|
+
assert.match(memory, /do not switch the selected store/);
|
|
42
|
+
assert.doesNotMatch(memory, /return artifacts inline and\/or write OpenSpec files/);
|
|
43
|
+
});
|
|
@@ -4,7 +4,7 @@ import { mkdtemp, writeFile, rm, realpath } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
7
|
-
import { SessionChanges, SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
|
|
7
|
+
import { SessionChanges, SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
8
8
|
|
|
9
9
|
async function fixture(run: (f: any) => Promise<void>, child = false) {
|
|
10
10
|
const root = await realpath(await mkdtemp(join(tmpdir(), "change-capture-")));
|
|
@@ -17,7 +17,7 @@ async function fixture(run: (f: any) => Promise<void>, child = false) {
|
|
|
17
17
|
const ctx = { cwd:root, sessionManager: { getSessionId: () => id, getEntries: () => entries } };
|
|
18
18
|
installSessionChangeCapture(pi as never, child ? {GENTLE_PI_AGENTS_CHILD:"1"} : {}, () => ({root,commonDir:root}));
|
|
19
19
|
const fire = (key, event = {}) => handlers.get(key)?.(event, ctx);
|
|
20
|
-
try { await fire("session_start"); await run({root, entries, ctx, fire, switchSession: () => id = "other"}); }
|
|
20
|
+
try { await fire("session_start"); await run({root, entries, ctx, fire, pi, switchSession: () => id = "other"}); }
|
|
21
21
|
finally { await rm(root, {recursive:true,force:true}); }
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -66,3 +66,21 @@ test("child carries bounded evidence in the existing tool-result details transpo
|
|
|
66
66
|
assert.equal(result.details.gentleSessionChange.path,"new");
|
|
67
67
|
assert.deepEqual(entries,[]);
|
|
68
68
|
},true));
|
|
69
|
+
|
|
70
|
+
test("the capture-limit warning is emitted once, not on every later write", async () => fixture(async ({root,fire,pi}) => {
|
|
71
|
+
const events: Array<{notice?: string}> = [];
|
|
72
|
+
pi.events.on(SESSION_CHANGE_EVENT, (data: {notice?: string}) => events.push(data));
|
|
73
|
+
for (let i = 0; i < 80; i++) {
|
|
74
|
+
const path = `f${i}`;
|
|
75
|
+
const content = "x".repeat(60 * 1024) + "\n";
|
|
76
|
+
const event = {toolCallId:`w${i}`,toolName:"write",input:{path,content}};
|
|
77
|
+
await fire("tool_call",event);
|
|
78
|
+
await writeFile(join(root,path),content);
|
|
79
|
+
await fire("tool_result",{...event,isError:false});
|
|
80
|
+
await fire("tool_execution_end",{toolCallId:`w${i}`,toolName:"write",isError:false});
|
|
81
|
+
}
|
|
82
|
+
const withNotice = events.filter(event => event.notice !== undefined);
|
|
83
|
+
assert.equal(withNotice.length,1);
|
|
84
|
+
assert.match(withNotice[0].notice!,/limit reached/);
|
|
85
|
+
assert.equal(events.length,80);
|
|
86
|
+
}));
|
|
@@ -101,3 +101,14 @@ test("capture capacity is explicit instead of growing without limit", () => {
|
|
|
101
101
|
assert.equal(changes.model.files.length,256);
|
|
102
102
|
assert.match(changes.notice!,/limit reached/);
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
test("capture-limit notice is delivered once but stays on the model", () => {
|
|
106
|
+
const changes = new SessionChanges("session");
|
|
107
|
+
for(let i=0;i<257;i++) changes.record(evidence(String(i),"/repo",String(i)+".ts"));
|
|
108
|
+
assert.match(changes.takeNotice()!,/limit reached/);
|
|
109
|
+
assert.equal(changes.takeNotice(),undefined);
|
|
110
|
+
assert.match(changes.notice!,/limit reached/);
|
|
111
|
+
assert.match(changes.model.notice!,/limit reached/);
|
|
112
|
+
assert.equal(new SessionChanges("s").takeNotice(),undefined);
|
|
113
|
+
assert.equal(new SessionChanges("s").model.notice,undefined);
|
|
114
|
+
});
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -176,6 +176,27 @@ test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
|
|
|
176
176
|
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test("sidebar unifies project, captured changes, usage and integrations in one frame", () => {
|
|
180
|
+
const data = model({ changes: { files: 2, added: 7, deleted: 3, notice: "capture warning" }, statuses: ["MCP connected"] });
|
|
181
|
+
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
182
|
+
const text = lines.join("\n");
|
|
183
|
+
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
184
|
+
let previous = -1;
|
|
185
|
+
for (const heading of ["Status", "Project", "Changes", "Usage", "Integrations"]) {
|
|
186
|
+
const index = text.indexOf(heading);
|
|
187
|
+
assert.ok(index > previous, heading);
|
|
188
|
+
previous = index;
|
|
189
|
+
}
|
|
190
|
+
assert.match(text, /2 files.*\+7.*−3/);
|
|
191
|
+
assert.match(text, /capture warning/);
|
|
192
|
+
assert.match(text, /\/gentle:changes/);
|
|
193
|
+
assert.match(text, /gpt-5\.5/);
|
|
194
|
+
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
195
|
+
const empty = renderShellSidebarBar(model(), plainTheme, 46).join("\n");
|
|
196
|
+
assert.match(empty, /No captured changes/);
|
|
197
|
+
assert.match(empty, /Integrations/);
|
|
198
|
+
});
|
|
199
|
+
|
|
179
200
|
test("sidebar profile wraps long names without changing the compact bar", () => {
|
|
180
201
|
const profile = "team-" + "x".repeat(59);
|
|
181
202
|
const base = model();
|
package/tests/shell-card.test.ts
CHANGED
|
@@ -53,11 +53,11 @@ test("renderCard collapses to the frame and the first body line with an expand h
|
|
|
53
53
|
assert.equal(visibleWidth(lines[1]), 60);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test("renderCard
|
|
56
|
+
test("renderCard uses the card tone across the full frame while preserving content roles", () => {
|
|
57
57
|
const info = renderCard(card(), taggedTheme, 80, { expanded: true });
|
|
58
|
-
assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><
|
|
59
|
-
assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<
|
|
60
|
-
assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><
|
|
58
|
+
assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><customMessageLabel>─ <\/customMessageLabel><customMessageLabel>✿ Gentle AI<\/customMessageLabel> <muted>·<\/muted> <muted>review preflight<\/muted><customMessageLabel> ─+<\/customMessageLabel><customMessageLabel>╮<\/customMessageLabel>$/);
|
|
59
|
+
assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<customMessageLabel>│<\/customMessageLabel>$/);
|
|
60
|
+
assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><customMessageLabel>─+╯<\/customMessageLabel>$/);
|
|
61
61
|
|
|
62
62
|
const warning = renderCard(card({ tone: CARD_TONE.WARNING, subtitle: undefined }), taggedTheme, 80, { expanded: true });
|
|
63
63
|
assert.match(warning[0], /^<warning>╭<\/warning>/);
|
|
@@ -65,8 +65,10 @@ test("renderCard keeps the rail and its corners in the tone and the rest of the
|
|
|
65
65
|
assert.match(warning[1], /^<warning>│<\/warning> /);
|
|
66
66
|
assert.match(warning[warning.length - 1], /^<warning>╰<\/warning>/);
|
|
67
67
|
|
|
68
|
-
const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true });
|
|
69
|
-
assert.match(error[0],
|
|
68
|
+
const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true, hint: "ctrl+o to expand" });
|
|
69
|
+
assert.match(error[0], /^<error>╭<\/error><error>─ <\/error><error>✿ Gentle AI<\/error> <muted>·<\/muted> <muted>review preflight<\/muted><error> ─+<\/error> <dim>ctrl\+o to expand<\/dim> <error>╮<\/error>$/);
|
|
70
|
+
assert.match(error[1], /^<error>│<\/error> <text>.*<error>│<\/error>$/);
|
|
71
|
+
assert.match(error[error.length - 1], /^<error>╰<\/error><error>─+╯<\/error>$/);
|
|
70
72
|
assert.equal(CARD_TONE.SUCCESS, "success");
|
|
71
73
|
});
|
|
72
74
|
|
|
@@ -98,6 +98,14 @@ test("changesSummary and the widget describe the session at a glance", () => {
|
|
|
98
98
|
assert.equal(visibleWidth(line), 120, "the command sits on the right edge");
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("changesSummary and the widget surface the capture limit", () => {
|
|
102
|
+
const model = changesModel([file("a.ts", 1, 0)]);
|
|
103
|
+
model.notice = "Session change capture limit reached; additional changes are not displayed.";
|
|
104
|
+
assert.equal(changesSummary(model), "1 file · +1 −0 · capture limit reached");
|
|
105
|
+
assert.match(renderChangesWidget(model, plainTheme, 200)[0], /capture limit reached/);
|
|
106
|
+
assert.doesNotMatch(changesSummary(changesModel([file("a.ts", 1, 0)])), /capture limit reached/);
|
|
107
|
+
});
|
|
108
|
+
|
|
101
109
|
test("renderChangesWidget colors counts by direction and yields nothing when clean", () => {
|
|
102
110
|
const model = changesModel([file("a.ts", 1, 2)]);
|
|
103
111
|
const [line] = renderChangesWidget(model, taggedTheme, 400);
|
|
@@ -4,13 +4,46 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
5
5
|
import {
|
|
6
6
|
framePromptLines,
|
|
7
|
+
scanWorkingText,
|
|
7
8
|
PROMPT_STATE,
|
|
8
9
|
petalGlyph,
|
|
9
10
|
petalTone,
|
|
11
|
+
SHELL_PULSE_MS,
|
|
12
|
+
SHELL_SCANNER_STEPS,
|
|
10
13
|
withPromptHint,
|
|
11
14
|
type PromptFrameOptions,
|
|
12
15
|
} from "../lib/shell-prompt.ts";
|
|
13
16
|
|
|
17
|
+
test("scanner reflects a symmetric light wave across working text at Pi's original cadence", () => {
|
|
18
|
+
const fg = (role: string, char: string) => `<${role}>${char}</${role}>`;
|
|
19
|
+
assert.equal(SHELL_PULSE_MS, 80);
|
|
20
|
+
assert.equal(SHELL_SCANNER_STEPS, 15);
|
|
21
|
+
for (const word of ["working…", "Thinking…"]) {
|
|
22
|
+
for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
|
|
23
|
+
const frame = scanWorkingText(word, tick, fg);
|
|
24
|
+
assert.equal(frame.replace(/<[^>]+>/g, ""), word);
|
|
25
|
+
}
|
|
26
|
+
assert.match(scanWorkingText(word, 0, fg), /^(<muted>.<\/muted>)+$/);
|
|
27
|
+
assert.match(scanWorkingText(word, 3, fg), new RegExp(`^<borderAccent>${word[0]}</borderAccent><accent>${word[1]}</accent><thinkingHigh>${word[2]}</thinkingHigh>`));
|
|
28
|
+
assert.match(scanWorkingText(word, 4, fg), new RegExp(`^<accent>${word[0]}</accent><borderAccent>${word[1]}</borderAccent><accent>${word[2]}</accent>`));
|
|
29
|
+
assert.match(scanWorkingText(word, 5, fg), new RegExp(`^<thinkingHigh>${word[0]}</thinkingHigh><accent>${word[1]}</accent><borderAccent>${word[2]}</borderAccent>`));
|
|
30
|
+
assert.match(scanWorkingText(word, 14, fg), /^(<muted>.<\/muted>)+$/);
|
|
31
|
+
assert.equal(scanWorkingText(word, SHELL_SCANNER_STEPS, fg), scanWorkingText(word, 0, fg));
|
|
32
|
+
}
|
|
33
|
+
for (const width of [1, 8, 20, 40]) {
|
|
34
|
+
for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
|
|
35
|
+
const lines = framePromptLines(editorLines(Math.max(4, width)), width, options({ state: PROMPT_STATE.WORKING, tick, fg: (_role, text) => text }));
|
|
36
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("compact banner pulse rises from ink through fresh color to a bright tip then fades", () => {
|
|
42
|
+
const tones = Array.from({ length: 8 }, (_, tick) => petalTone(PROMPT_STATE.WORKING, tick));
|
|
43
|
+
assert.deepEqual(tones, ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent", "thinkingHigh", "mdQuoteBorder", "mdQuoteBorder"]);
|
|
44
|
+
assert.equal(petalTone(PROMPT_STATE.WORKING, 8), tones[0]);
|
|
45
|
+
});
|
|
46
|
+
|
|
14
47
|
// The Gentle Shell prompt wraps pi's editor output (a top rule, padded content
|
|
15
48
|
// lines, a bottom rule) in a rounded frame with a petal that shows the agent
|
|
16
49
|
// state. Framing is pure: it takes the editor's lines and returns new ones.
|
|
@@ -56,7 +89,7 @@ test("framePromptLines paints the frame with the editor border color and the pet
|
|
|
56
89
|
|
|
57
90
|
test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
|
|
58
91
|
assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
|
|
59
|
-
assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["
|
|
92
|
+
assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent"]);
|
|
60
93
|
assert.equal(petalTone(PROMPT_STATE.QUEUED, 1), "warning");
|
|
61
94
|
});
|
|
62
95
|
|
|
@@ -66,13 +99,14 @@ test("petalGlyph spins through the flowers while working and rests otherwise", (
|
|
|
66
99
|
assert.equal(petalGlyph(PROMPT_STATE.QUEUED, 1), "❀");
|
|
67
100
|
});
|
|
68
101
|
|
|
69
|
-
test("framePromptLines
|
|
102
|
+
test("framePromptLines scans the working word while preserving the frame and queued state", () => {
|
|
70
103
|
const plain = (_color: string, text: string) => text;
|
|
71
|
-
const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick:
|
|
72
|
-
assert.match(working[0], /<
|
|
73
|
-
assert.match(working[0], /<
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3 }));
|
|
105
|
+
assert.match(working[0], /<borderAccent>✾<\/borderAccent>/);
|
|
106
|
+
assert.match(working[0], /<borderAccent>w<\/borderAccent><accent>o<\/accent><thinkingHigh>r<\/thinkingHigh>/);
|
|
107
|
+
assert.equal(working[0].replace(/<[^>]+>/g, "").includes("working…"), true);
|
|
108
|
+
const workingPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain }));
|
|
109
|
+
assert.match(stripAnsi(workingPlain[0]), /^╭─ ✾ working… ─+╮$/);
|
|
76
110
|
assert.equal(visibleWidth(workingPlain[0]), 40);
|
|
77
111
|
|
|
78
112
|
const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED }));
|
|
@@ -5,8 +5,8 @@ import { renderSidebarBanner } from "../lib/shell-sidebar-banner.ts";
|
|
|
5
5
|
|
|
6
6
|
const plain = { fg: (_role: string, text: string) => text, bold: (text: string) => text };
|
|
7
7
|
test("sidebar heading is only the centered literal product label", () => {
|
|
8
|
-
const label = "✿ Gentle
|
|
9
|
-
for (const width of [0, 1, 8, 11, 20, 21, 30, 45, 46, 80]) {
|
|
8
|
+
const label = "✿ Gentle Shell ✿";
|
|
9
|
+
for (const width of [0, 1, 8, 11, 15, 16, 17, 20, 21, 30, 45, 46, 80]) {
|
|
10
10
|
const lines = renderSidebarBanner(plain, width);
|
|
11
11
|
const space = width - visibleWidth(label);
|
|
12
12
|
assert.deepEqual(lines, space >= 0 ? [" ".repeat(Math.floor(space / 2)) + label + " ".repeat(Math.ceil(space / 2))] : []);
|
|
@@ -17,7 +17,7 @@ test("sidebar heading is only the centered literal product label", () => {
|
|
|
17
17
|
test("heading uses the current theme on every render", () => {
|
|
18
18
|
let palette = "dark";
|
|
19
19
|
const theme = { ...plain, fg: (role: string, text: string) => `<${palette}:${role}>${text}` };
|
|
20
|
-
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<dark:accent>✿ <dark:text>Gentle
|
|
20
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<dark:accent>✿ <dark:text>Gentle Shell <dark:accent>✿");
|
|
21
21
|
palette = "light";
|
|
22
|
-
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<light:accent>✿ <light:text>Gentle
|
|
22
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<light:accent>✿ <light:text>Gentle Shell <light:accent>✿");
|
|
23
23
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { ScrollView, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
-
import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
|
|
3
|
+
import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { getScrollViewsAt, renderLayoutFrame, type LayoutBox } from "@earendil-works/pi-tui/dist/layout.js";
|
|
5
5
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
6
6
|
import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
7
7
|
import { renderShellSidebarBar } from "../lib/shell-bar.ts";
|
|
@@ -32,7 +32,7 @@ test("grouped Status preserves structured fields and opaque integration text", (
|
|
|
32
32
|
}, theme, 46);
|
|
33
33
|
const text = lines.join("\n");
|
|
34
34
|
let previous = -1;
|
|
35
|
-
for (const heading of ["Status", "Project", "
|
|
35
|
+
for (const heading of ["Status", "Project", "Changes", "Usage", "Integrations"]) {
|
|
36
36
|
const index = text.indexOf(heading);
|
|
37
37
|
assert.ok(index > previous, heading);
|
|
38
38
|
previous = index;
|
|
@@ -67,13 +67,13 @@ test("only fullscreen at 140 columns activates; shrinking restores bottom paint"
|
|
|
67
67
|
}
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
test("rail orders Status,
|
|
70
|
+
test("rail orders unified Status, agents, TODO without standalone changes", (t) => {
|
|
71
71
|
const f = fixture();
|
|
72
72
|
for (const key of ["todo", "agents", "changes"]) {
|
|
73
73
|
sidebarPart(f.tui, key, { render: () => [key, ""], invalidate() {} });
|
|
74
74
|
}
|
|
75
75
|
t.after(installSidebar(f.tui, theme));
|
|
76
|
-
assert.deepEqual(rail(f).render(50).map((line) => line.trim()), ["✿ Gentle
|
|
76
|
+
assert.deepEqual(rail(f).render(50).map((line) => line.trim()), ["✿ Gentle Shell ✿", "", "Status", "", "agents", "", "todo"]);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test("branding belongs to scroll content before Status, never transcript or narrow bottom", (t) => {
|
|
@@ -81,14 +81,14 @@ test("branding belongs to scroll content before Status, never transcript or narr
|
|
|
81
81
|
t.after(installSidebar(f.tui, theme));
|
|
82
82
|
const scroll = rail(f);
|
|
83
83
|
const lines = scroll.render(50);
|
|
84
|
-
const brandIndex = lines.findIndex((line) => line.includes("✿ Gentle
|
|
84
|
+
const brandIndex = lines.findIndex((line) => line.includes("✿ Gentle Shell ✿"));
|
|
85
85
|
assert.ok(brandIndex >= 0 && brandIndex < lines.findIndex((line) => line.includes("Status")));
|
|
86
86
|
assert.doesNotMatch(lines.join("\n"), /[\u2800-\u28ff]/);
|
|
87
87
|
const heading = lines[brandIndex];
|
|
88
88
|
const usableWidth = scroll.getContentWidth(50) - 2;
|
|
89
|
-
const spare = usableWidth - visibleWidth("✿ Gentle
|
|
89
|
+
const spare = usableWidth - visibleWidth("✿ Gentle Shell ✿");
|
|
90
90
|
const scrollbarWidth = 50 - scroll.getContentWidth(50);
|
|
91
|
-
assert.equal(heading, " ".repeat(1 + Math.floor(spare / 2)) + "✿ Gentle
|
|
91
|
+
assert.equal(heading, " ".repeat(1 + Math.floor(spare / 2)) + "✿ Gentle Shell ✿" + " ".repeat(1 + Math.ceil(spare / 2) + scrollbarWidth));
|
|
92
92
|
assert.deepEqual(f.root.render(), ["transcript"]);
|
|
93
93
|
scroll.updateLayout(lines.length, 2, () => {});
|
|
94
94
|
scroll.scrollBy(9);
|
|
@@ -249,30 +249,30 @@ test("real layout frames reuse unchanged sidebar output and invalidate at state
|
|
|
249
249
|
t.after(installSidebar(f.tui, theme));
|
|
250
250
|
|
|
251
251
|
const first = renderLayoutFrame(f.root, 140, 20, () => {});
|
|
252
|
-
assert.deepEqual(counts, { footer: 1, changes:
|
|
252
|
+
assert.deepEqual(counts, { footer: 1, changes: 0, agents: 1, todo: 1 });
|
|
253
253
|
const sidebarRail = first.root.children[1]?.component as ScrollView;
|
|
254
254
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
255
|
-
assert.deepEqual(counts, { footer: 1, changes:
|
|
255
|
+
assert.deepEqual(counts, { footer: 1, changes: 0, agents: 1, todo: 1 });
|
|
256
256
|
|
|
257
257
|
todo = "Todo two";
|
|
258
258
|
sidebarRail.invalidate();
|
|
259
259
|
const changed = renderLayoutFrame(f.root, 140, 20, () => {});
|
|
260
260
|
assert.match(changed.lines.join("\n"), /Todo two/);
|
|
261
|
-
assert.deepEqual(counts, { footer: 2, changes:
|
|
261
|
+
assert.deepEqual(counts, { footer: 2, changes: 0, agents: 2, todo: 2 });
|
|
262
262
|
|
|
263
263
|
f.host.terminal.columns = 139;
|
|
264
264
|
renderLayoutFrame(f.root, 139, 20, () => {});
|
|
265
265
|
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
266
266
|
f.host.terminal.columns = 140;
|
|
267
267
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
268
|
-
assert.deepEqual(counts, { footer: 3, changes:
|
|
268
|
+
assert.deepEqual(counts, { footer: 3, changes: 0, agents: 3, todo: 3 });
|
|
269
269
|
|
|
270
270
|
f.host.mode = "regular";
|
|
271
271
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
272
272
|
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
273
273
|
f.host.mode = "fullscreen";
|
|
274
274
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
275
|
-
assert.deepEqual(counts, { footer: 4, changes:
|
|
275
|
+
assert.deepEqual(counts, { footer: 4, changes: 0, agents: 4, todo: 4 });
|
|
276
276
|
|
|
277
277
|
let replacementRenders = 0;
|
|
278
278
|
sidebarPart(f.tui, "todo", {
|
|
@@ -385,3 +385,87 @@ test("unsupported roots, empty rails and overflowing parts leave native layout i
|
|
|
385
385
|
t.after(installSidebar(f.tui, theme));
|
|
386
386
|
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
387
387
|
});
|
|
388
|
+
|
|
389
|
+
// A real native subtree exposes duplicate rendering hidden by empty-layout fixtures.
|
|
390
|
+
function nativeSidebarFixture(legacyMeasurement = false) {
|
|
391
|
+
let transcriptRenders = 0;
|
|
392
|
+
let editorRows = 1;
|
|
393
|
+
const transcript = {
|
|
394
|
+
render(width: number) {
|
|
395
|
+
transcriptRenders++;
|
|
396
|
+
return Array.from({ length: 1000 }, (_, index) => `\x1b[32mEntry ${index} 界 é at ${width}\x1b[0m`);
|
|
397
|
+
},
|
|
398
|
+
invalidate() {},
|
|
399
|
+
};
|
|
400
|
+
const primary = new ScrollView(transcript, { primary: true, follow: "end", scrollbar: "always" });
|
|
401
|
+
const editor = { render: () => Array.from({ length: editorRows }, (_, index) => `Editor ${index}${index === editorRows - 1 ? CURSOR_MARKER : ""}`), invalidate() {} };
|
|
402
|
+
const root = new VStack([{ component: primary, basis: 0, grow: 1 }, { component: editor }]);
|
|
403
|
+
const originalRender = root.render;
|
|
404
|
+
const host = { mode: "fullscreen", terminal: { columns: 180 }, layoutRoot: root, requestRender() {} };
|
|
405
|
+
const tui = host as unknown as TUI;
|
|
406
|
+
sidebarPart(tui, "footer", { render: () => Array.from({ length: 100 }, (_, i) => `Status ${i}`), invalidate() {} });
|
|
407
|
+
const dispose = installSidebar(tui, theme);
|
|
408
|
+
const nativeRoot = root as unknown as { [NODE](): { entries: Array<{ component: { render(width: number): string[] } }> } };
|
|
409
|
+
if (legacyMeasurement) nativeRoot[NODE]().entries[0].component.render = (width) => root.render(width);
|
|
410
|
+
return {
|
|
411
|
+
root, primary, host, dispose, originalRender,
|
|
412
|
+
growEditor: () => { editorRows = 4; },
|
|
413
|
+
frame(width = host.terminal.columns, height = 30) {
|
|
414
|
+
host.terminal.columns = width;
|
|
415
|
+
transcriptRenders = 0;
|
|
416
|
+
const frame = renderLayoutFrame(root, width, height, () => {});
|
|
417
|
+
return { frame, transcriptRenders };
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function layoutGeometry(box: LayoutBox): unknown {
|
|
423
|
+
return { rect: box.rect, clip: box.clip, lineOffset: box.lineOffset, scroll: !!box.scrollView, children: box.children.map(layoutGeometry) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
test("fullscreen native transcript renders once without changing bytes, geometry or primary scroll", (t) => {
|
|
427
|
+
const current = nativeSidebarFixture();
|
|
428
|
+
const legacy = nativeSidebarFixture(true);
|
|
429
|
+
t.after(current.dispose);
|
|
430
|
+
t.after(legacy.dispose);
|
|
431
|
+
for (const grow of [false, true]) {
|
|
432
|
+
if (grow) { current.growEditor(); legacy.growEditor(); }
|
|
433
|
+
for (const [width, height] of [[180, 30], [140, 18], [220, 50]]) {
|
|
434
|
+
const actual = current.frame(width, height);
|
|
435
|
+
const expected = legacy.frame(width, height);
|
|
436
|
+
assert.equal(actual.transcriptRenders, 1, "measurement must not render the native transcript");
|
|
437
|
+
assert.equal(expected.transcriptRenders, 2, "legacy control must reproduce the redundant render");
|
|
438
|
+
assert.deepEqual(actual.frame.lines, expected.frame.lines);
|
|
439
|
+
assert.deepEqual(layoutGeometry(actual.frame.root), layoutGeometry(expected.frame.root));
|
|
440
|
+
assert.equal(actual.frame.primaryScrollView, current.primary);
|
|
441
|
+
assert.equal(current.root.render, current.originalRender, "native root rendering is not patched");
|
|
442
|
+
assert.ok(actual.frame.lines.some(line => line.includes(CURSOR_MARKER)), "editor cursor remains visible");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("native transcript and sidebar keep separate scroll routing across resize, breakpoint and mode transitions", (t) => {
|
|
448
|
+
const fixture = nativeSidebarFixture();
|
|
449
|
+
t.after(fixture.dispose);
|
|
450
|
+
let result = fixture.frame();
|
|
451
|
+
const rail = result.frame.root.children[1].component as ScrollView;
|
|
452
|
+
assert.equal(getScrollViewsAt(result.frame, 1, 1)[0], fixture.primary);
|
|
453
|
+
assert.equal(getScrollViewsAt(result.frame, 179, 1)[0], rail);
|
|
454
|
+
const transcriptTop = fixture.primary.scrollTop;
|
|
455
|
+
rail.scrollBy(5);
|
|
456
|
+
result = fixture.frame();
|
|
457
|
+
assert.equal(fixture.primary.scrollTop, transcriptTop);
|
|
458
|
+
assert.equal(rail.scrollTop, 5);
|
|
459
|
+
fixture.primary.scrollBy(-7);
|
|
460
|
+
result = fixture.frame();
|
|
461
|
+
assert.equal(fixture.primary.scrollTop, transcriptTop - 7);
|
|
462
|
+
assert.equal(rail.scrollTop, 5);
|
|
463
|
+
assert.equal(result.transcriptRenders, 1);
|
|
464
|
+
for (const [mode, width] of [["fullscreen", 139], ["fullscreen", 140], ["regular", 180], ["fullscreen", 180]] as const) {
|
|
465
|
+
fixture.host.mode = mode;
|
|
466
|
+
result = fixture.frame(width);
|
|
467
|
+
assert.equal(result.transcriptRenders, 1);
|
|
468
|
+
assert.equal(result.frame.primaryScrollView, fixture.primary);
|
|
469
|
+
assert.equal(result.frame.root.children.some(box => box.component === rail), mode === "fullscreen" && width >= 140);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
@@ -2,11 +2,63 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { syncBuiltinESMExports } from "node:module";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
5
6
|
import startup, { readGitBranch } from "../extensions/startup-banner.ts";
|
|
6
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
8
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
9
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
9
10
|
|
|
11
|
+
test("startup artwork spells Gentle Shell with aligned animation spans", () => {
|
|
12
|
+
const source = readFileSync(new URL("../extensions/startup-banner.ts", import.meta.url), "utf8");
|
|
13
|
+
const logo = JSON.parse(source.match(/const TEXT_LOGO = (\[[\s\S]*?\]);/)![1].replace(/,\s*]/, "]")) as string[];
|
|
14
|
+
const weights = JSON.parse(source.match(/const LETTER_WEIGHTS = (\[[^;]+\]);/)![1]) as number[];
|
|
15
|
+
// Preserve the original script, including its descending G and dark shadow.
|
|
16
|
+
const gentle = [
|
|
17
|
+
" ▄▄▄▀▀▀▀▀██ ▄▄▀▄▄",
|
|
18
|
+
" ▄▄█▀▀▒▒▒▒▒▄▄█▀▒ ▄██ ▄▄█▀█▄█▀▒▒",
|
|
19
|
+
" ▄▄██▀▒▒▒▒▒▄▄▄▀▀▒▒▒▒ ▄▄▄ ▀▀▀▀██▀▀▀▀▀███▀█▄▀▀▒▒▒▒",
|
|
20
|
+
" ▄██▀▒▒▒▒ ▒▒▄▄█ ▄▄▄▀██ ▄▄▄▀▀▀▄ ▄██▀▒▒▒▒▄██▀▀▀▒▄▄███",
|
|
21
|
+
" ██▀▒▒▒ ▄▄▄███▀▄██▀▀▀▄▄██▀▀▄█▀▄▄██▀▒▒▒▄▄██▀▒▒▄██▀▀▀▄▄",
|
|
22
|
+
" ▀█▄▄▄▄▄▀▀▀█▄▄███▄▒▀▀▀▀▀▀▒▀▀▒▒▀▀▀▀▒██▄▄▀▀▀ ▀█▄▀▀▀ ▀▀▀▀▀▒▒",
|
|
23
|
+
" ▒▄▄▄█▀▀▀█▄█▀▀▒▒▒▒ ▒▒▒▒▒▒ ▒▒ ▒▒▒▒ ▒▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒",
|
|
24
|
+
" ▄▄▀▀ ▒▒▒▒▄██▀▒▒▒▒",
|
|
25
|
+
" ▄█ ▒▒▒▄▄██▀▀▒▒▒▒",
|
|
26
|
+
" ▀▀▀▀▀▀▒▒▒▒▒▒",
|
|
27
|
+
" ▒▒▒▒▒▒",
|
|
28
|
+
];
|
|
29
|
+
assert.equal(logo.length, 11, "retain the full-height script silhouette");
|
|
30
|
+
for (const [row, prefix] of gentle.entries()) {
|
|
31
|
+
assert.ok(logo[row].startsWith(prefix), `original Gentle script row ${row}`);
|
|
32
|
+
assert.ok(logo[row].includes("▒") || row === 0, "retain dark shadow");
|
|
33
|
+
}
|
|
34
|
+
assert.equal(weights.length, 11, "one variable-width span per GENTLESHELL letter");
|
|
35
|
+
assert.ok(new Set(weights).size > 2, "script spans must not use fixed block-font widths");
|
|
36
|
+
assert.ok(logo.slice(0, 7).every((line) => /[▄▀█]/.test(line.slice(68))), "Shell has tall slanted strokes");
|
|
37
|
+
assert.ok(logo.slice(1, 7).every((line) => /▒/.test(line.slice(68))), "Shell retains the dark shadow");
|
|
38
|
+
const shell = logo.map((line) => line.slice(68));
|
|
39
|
+
assert.match(shell[0], /▄▄█▀▀▀██/, "S has its upper bowl");
|
|
40
|
+
assert.match(shell[3], /▀▀▀██▄▄/, "S curves into its lower bowl");
|
|
41
|
+
assert.match(shell[5], /▀█▄▄▄▄█▀▀▒▒/, "S closes with a shadowed exit stroke");
|
|
42
|
+
assert.match(shell[4], /▄██▀▄██▀██▒/, "h has a rising stem and connected arch");
|
|
43
|
+
assert.deepEqual(logo.slice(3, 6).map((line) => line.slice(93, 101)), [
|
|
44
|
+
" ▄▄▀▀██ ",
|
|
45
|
+
"▄██▄▄▀▒ ",
|
|
46
|
+
"▀█▄▄▄▄▀▀",
|
|
47
|
+
], "e has an upper loop/counter, a crossbar, and a curved lower exit rather than a wedge");
|
|
48
|
+
assert.equal(logo[3][100], " ", "e loop stays distinct from the following l");
|
|
49
|
+
assert.equal(logo[4][100], " ", "e counter opens before the l stem without moving it");
|
|
50
|
+
assert.equal(shell[0].split("▄▄▀▄▄").length - 1, 2, "both l ascenders retain looped tops");
|
|
51
|
+
assert.match(shell[5], /█▄▄▀▀▀▄▄▀█▄▄▀▀▀$/, "both l exits connect with an ascending script stroke");
|
|
52
|
+
assert.equal(Math.max(...logo.map((line) => line.length)), 120, "connected script retains glyph scale while tightening letter placement");
|
|
53
|
+
// Like Gentle, adjacent baseline strokes must meet, not merely have smaller gaps.
|
|
54
|
+
assert.match(shell[4], /▀██▄▄██▀▄██▀██▒/, "S exit meets the h entry");
|
|
55
|
+
assert.match(shell[5], /▀█▄▄▄▄▀▀█▄▄▀▀▀/, "e lower exit still meets the unchanged l stem");
|
|
56
|
+
assert.match(shell[5], /██▄▄▀▀█▄▄▄▄▀▀/, "h exit remains connected to e");
|
|
57
|
+
assert.deepEqual(weights, [22, 9, 8, 9, 8, 9, 10, 13, 8, 8, 13]);
|
|
58
|
+
assert.equal(weights.reduce((sum, width) => sum + width, 0), Math.max(...logo.map((line) => line.length)) - 3,
|
|
59
|
+
"span widths exactly cover the ink bounds from the G descender to the final l shadow");
|
|
60
|
+
});
|
|
61
|
+
|
|
10
62
|
test("startup branch lookup uses direct git argv and hides its Windows child", async () => {
|
|
11
63
|
const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
|
|
12
64
|
const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
|
|
@@ -46,6 +98,7 @@ test("startup banner keeps animating after invalidate and cleans up on dispose",
|
|
|
46
98
|
header = factory({ requestRender() { renders++; } }, { fg: (_role: string, text: string) => text });
|
|
47
99
|
} } });
|
|
48
100
|
t.mock.timers.tick(50);
|
|
101
|
+
assert.match(header!.render(200).join("\n"), /\x1b\[38;2;95;30;60m▒/, "script shadow keeps the dark pink palette");
|
|
49
102
|
const afterBoot = renders;
|
|
50
103
|
t.mock.timers.tick(25);
|
|
51
104
|
assert.ok(renders > afterBoot, "animation timer requests renders");
|
|
@@ -91,13 +144,13 @@ for (const showRose of [false, true]) for (const showTextLogo of [false, true])
|
|
|
91
144
|
} } });
|
|
92
145
|
t.mock.timers.tick(50);
|
|
93
146
|
try {
|
|
94
|
-
for (const width of [40, 80, 160]) {
|
|
147
|
+
for (const width of [40, 80, 160, 200]) {
|
|
95
148
|
const lines = header!.render(width);
|
|
96
149
|
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
97
150
|
const text = stripAnsi(lines.join("\n"));
|
|
98
151
|
assert.match(text, /GIT:/);
|
|
99
152
|
assert.match(text, /PATH:/);
|
|
100
|
-
if (width
|
|
153
|
+
if (width >= 160) {
|
|
101
154
|
assert.equal(/[\u2800-\u28ff]/.test(text), showRose);
|
|
102
155
|
assert.equal(/[▒▄▀█]/.test(text), showTextLogo);
|
|
103
156
|
}
|