gentle-pi 3.7.0 → 4.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
package/tests/shell-card.test.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CARD_STYLE, CARD_TONE, cardAwaitingResult, cardRunningLine, cardStyle, cardTopRows, markCardResult, panelExtraRows, panelHeaderRow, renderCard, setCardStyle,
|
|
6
|
+
type Card, type CardStyle, type CardTone,
|
|
7
|
+
} from "../lib/shell-card.ts";
|
|
5
8
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
6
9
|
|
|
7
10
|
// Cards are how Gentle notices look: the same rounded frame as the prompt,
|
|
@@ -135,9 +138,358 @@ test("renderCard truncates ANSI-styled title content at display width", () => {
|
|
|
135
138
|
for (const line of lines) assert.equal(visibleWidth(line), 30, `"${stripAnsi(line)}" is not 30 wide`);
|
|
136
139
|
});
|
|
137
140
|
|
|
141
|
+
test("tool heading continuation reserves configured hint columns before ANSI and Unicode wrapping", () => {
|
|
142
|
+
const title = "bash $ printf 界e\u0301 alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa";
|
|
143
|
+
const hint = "\x1b[36mconfigured key to expand\x1b[0m";
|
|
144
|
+
const value = card({ title: `\x1b[1m${title}\x1b[0m`, subtitle: undefined, glyph: "🌹︎", body: [] });
|
|
145
|
+
for (const width of [60, 80, 120]) {
|
|
146
|
+
const lines = cardTopRows(value, ansiTheme, width, hint);
|
|
147
|
+
assert.match(stripAnsi(lines[0]), /configured key to expand ╮$/);
|
|
148
|
+
assert.match(stripAnsi(lines[0]), /^╭─ 🌹︎ bash \$ printf/);
|
|
149
|
+
assert.ok(lines.length > 1, "long heading continues inside the same frame");
|
|
150
|
+
assert.ok(lines.every((line) => visibleWidth(line) === width));
|
|
151
|
+
const content = lines.map(stripAnsi).map((line, index) => index === 0
|
|
152
|
+
? line.replace(/^╭─ 🌹︎ /, "").replace(/ ─+ configured key to expand ╮$/, "")
|
|
153
|
+
: line.replace(/^│ /, "").replace(/ +│$/, "")).join(" ").replace(/\s+/g, " ").trim();
|
|
154
|
+
assert.equal(content, title, "hint reservation must not lose any command text");
|
|
155
|
+
}
|
|
156
|
+
for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) {
|
|
157
|
+
const lines = cardTopRows(value, ansiTheme, width, hint);
|
|
158
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
159
|
+
assert.ok(!lines.some((line) => stripAnsi(line).includes("configured key")));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("tool previews share the frame with a configurable physical-row budget", () => {
|
|
164
|
+
const value = card({ body: ["first useful row", "界🌹e\u0301".repeat(80), "last"], tone: CARD_TONE.ERROR });
|
|
165
|
+
for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24, 100]) {
|
|
166
|
+
const lines = renderCard(value, ansiTheme, width, { expanded: false, previewRows: 3 });
|
|
167
|
+
assert.ok(lines.length <= 5);
|
|
168
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
169
|
+
if (width === 0) assert.deepEqual(lines, []);
|
|
170
|
+
if (width === 100) assert.match(stripAnsi(lines[1]), /first useful row/);
|
|
171
|
+
}
|
|
172
|
+
assert.equal(renderCard(card({ body: ["one", "two", "three", "four"] }), plainTheme, 60, { expanded: false, previewRows: 3 }).length, 5);
|
|
173
|
+
const multiline = renderCard(card({ body: ["one\ntwo\nthree\nfour"] }), plainTheme, 60, { expanded: false, previewRows: 3 });
|
|
174
|
+
assert.equal(multiline.length, 5);
|
|
175
|
+
assert.ok(multiline.every((line) => !line.includes("\n")), "each returned row is physically one terminal row");
|
|
176
|
+
assert.doesNotMatch(multiline.join("\n"), /four/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("legacy renderCard keeps its empty-row shape at nonpositive widths while opt-in previews stay empty", () => {
|
|
180
|
+
// Agents, Todos and Status call renderCard without their own width guard, so
|
|
181
|
+
// their row count at width zero must remain the pre-preview shape: every
|
|
182
|
+
// row is present and empty. Only the opt-in previewRows path returns nothing.
|
|
183
|
+
const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d"] });
|
|
184
|
+
const empty = card({ glyph: "✎", subtitle: undefined, body: [], tone: CARD_TONE.WARNING });
|
|
185
|
+
for (const width of [0, -1, -7]) {
|
|
186
|
+
for (const theme of [plainTheme, taggedTheme, ansiTheme]) {
|
|
187
|
+
assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true }), ["", "", "", "", "", "", ""], `expanded at ${width}`);
|
|
188
|
+
assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true, hint: "ctrl+o expand" }), ["", "", "", "", "", "", ""], `hinted at ${width}`);
|
|
189
|
+
assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: false }), ["", "", ""], `collapsed at ${width}`);
|
|
190
|
+
assert.deepEqual(renderCard(empty, theme, width, { expanded: true }), ["", ""], `empty body at ${width}`);
|
|
191
|
+
assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: false, previewRows: 3 }), [], `preview at ${width}`);
|
|
192
|
+
assert.deepEqual(renderCard(paragraphs, theme, width, { expanded: true, previewRows: 3 }), [], `expanded preview at ${width}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
138
197
|
test("renderCard never exceeds extremely narrow supplied widths", () => {
|
|
139
198
|
for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
|
|
140
199
|
const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), ansiTheme, width, { expanded: true, hint: "ctrl+o to expand" });
|
|
141
200
|
for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
142
201
|
}
|
|
143
202
|
});
|
|
203
|
+
|
|
204
|
+
// Float style: the same rows become a borderless panel. Content cells keep a
|
|
205
|
+
// tone-matched background, the frame turns into a tone accent bar and spaces,
|
|
206
|
+
// and a one-column margin stays outside the panel on both sides.
|
|
207
|
+
const FG_CODE: Record<string, number> = { border: 240, accent: 211, success: 114, warning: 221, error: 203, dim: 245, muted: 244, text: 252 };
|
|
208
|
+
const BG_CODE: Record<string, number> = { toolSuccessBg: 22, toolPendingBg: 58, toolErrorBg: 52 };
|
|
209
|
+
const floatTheme = {
|
|
210
|
+
fg(color: string, text: string) {
|
|
211
|
+
return `\x1b[38;5;${FG_CODE[color] ?? 250}m${text}\x1b[39m`;
|
|
212
|
+
},
|
|
213
|
+
bg(color: string, text: string) {
|
|
214
|
+
return `\x1b[48;5;${BG_CODE[color] ?? 17}m${text}\x1b[49m`;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
function withCardStyle<T>(style: CardStyle, run: () => T): T {
|
|
219
|
+
setCardStyle(style);
|
|
220
|
+
try {
|
|
221
|
+
return run();
|
|
222
|
+
} finally {
|
|
223
|
+
setCardStyle(CARD_STYLE.NEON);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Background code of every visible cell, following resets exactly as a terminal does. */
|
|
228
|
+
function cellBackgrounds(line: string): (number | undefined)[] {
|
|
229
|
+
const cells: (number | undefined)[] = [];
|
|
230
|
+
let bg: number | undefined;
|
|
231
|
+
for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
|
|
232
|
+
if (!token.startsWith("\x1b")) {
|
|
233
|
+
for (let cell = 0; cell < visibleWidth(token); cell++) cells.push(bg);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const codes = token.slice(2, -1).split(";").map((code) => (code === "" ? 0 : Number(code)));
|
|
237
|
+
for (let index = 0; index < codes.length; index++) {
|
|
238
|
+
if (codes[index] === 0 || codes[index] === 49) bg = undefined;
|
|
239
|
+
if (codes[index] === 48 && codes[index + 1] === 5) bg = codes[index + 2];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return cells;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const floatCard = (overrides: Partial<Card> = {}) => card({ title: "read", subtitle: undefined, glyph: "⌖", body: ["alpha", "beta"], tone: CARD_TONE.SUCCESS, ...overrides });
|
|
246
|
+
|
|
247
|
+
test("card style defaults to the float style and lives in a process-wide slot", () => {
|
|
248
|
+
const slot = Symbol.for("gentle-pi.card-style");
|
|
249
|
+
const state = globalThis as typeof globalThis & { [slot]?: string };
|
|
250
|
+
// Own the slot for this test only, whatever earlier tests left behind.
|
|
251
|
+
const found = state[slot];
|
|
252
|
+
try {
|
|
253
|
+
delete state[slot];
|
|
254
|
+
assert.equal(cardStyle(), CARD_STYLE.FLOAT, "an unset slot reads as the float style");
|
|
255
|
+
withCardStyle(CARD_STYLE.FLOAT, () => assert.equal(state[slot], CARD_STYLE.FLOAT));
|
|
256
|
+
state[slot] = "neon";
|
|
257
|
+
assert.equal(cardStyle(), CARD_STYLE.NEON, "another loader's copy sees the same slot");
|
|
258
|
+
state[slot] = "bogus";
|
|
259
|
+
assert.equal(cardStyle(), CARD_STYLE.FLOAT, "unknown values read as the float style");
|
|
260
|
+
} finally {
|
|
261
|
+
if (found === undefined) delete state[slot];
|
|
262
|
+
else state[slot] = found;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("float cards are borderless panels with a margin, an accent bar and balanced blank rows", () => {
|
|
267
|
+
const width = 40;
|
|
268
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, width, { expanded: false, previewRows: 3, hint: "ctrl+o" }));
|
|
269
|
+
const plain = lines.map(stripAnsi);
|
|
270
|
+
assert.deepEqual(plain, [
|
|
271
|
+
` ▎${" ".repeat(width - 3)} `,
|
|
272
|
+
` ▎ ⌖ read${" ".repeat(width - 19)}ctrl+o `,
|
|
273
|
+
` ▎${" ".repeat(width - 3)} `,
|
|
274
|
+
` ▎ alpha${" ".repeat(width - 9)} `,
|
|
275
|
+
` ▎ beta${" ".repeat(width - 8)} `,
|
|
276
|
+
` ▎${" ".repeat(width - 3)} `,
|
|
277
|
+
]);
|
|
278
|
+
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
279
|
+
assert.doesNotMatch(plain.join("\n"), /[╭╮╰╯│─]/);
|
|
280
|
+
// The glyph starts in the same column as the body text.
|
|
281
|
+
assert.equal(plain[1]!.indexOf("⌖"), plain[3]!.indexOf("alpha"));
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
const cells = cellBackgrounds(line);
|
|
284
|
+
assert.equal(cells[0], undefined, "left margin stays transparent");
|
|
285
|
+
assert.equal(cells[width - 1], undefined, "right margin stays transparent");
|
|
286
|
+
assert.deepEqual(cells.slice(1, -1), Array(width - 2).fill(BG_CODE.toolSuccessBg));
|
|
287
|
+
assert.ok(line.startsWith(` \x1b[48;5;${BG_CODE.toolSuccessBg}m\x1b[38;5;${FG_CODE.success}m▎`), "the bar carries the tone colour");
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("float panels pick the background from the tone", () => {
|
|
292
|
+
const expected = { [CARD_TONE.INFO]: BG_CODE.toolSuccessBg, [CARD_TONE.SUCCESS]: BG_CODE.toolSuccessBg, [CARD_TONE.WARNING]: BG_CODE.toolPendingBg, [CARD_TONE.ERROR]: BG_CODE.toolErrorBg };
|
|
293
|
+
for (const [tone, code] of Object.entries(expected)) {
|
|
294
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ tone: tone as CardTone }), floatTheme, 30, { expanded: true, previewRows: 3 }));
|
|
295
|
+
for (const line of lines) assert.deepEqual(cellBackgrounds(line).slice(1, -1), Array(28).fill(code), `${tone}: ${JSON.stringify(line)}`);
|
|
296
|
+
for (const panel of [false, true]) {
|
|
297
|
+
const rows = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ tone: tone as CardTone }), floatTheme, 30, panel ? { expanded: true, panel: true } : { expanded: true, previewRows: 3 }));
|
|
298
|
+
for (const [index, row] of rows.entries()) {
|
|
299
|
+
assert.match(stripAnsi(row), /^ ▎/, `${tone}, panel=${panel}, row ${index}: continuous accent including padding`);
|
|
300
|
+
assert.ok(row.includes(`\x1b[38;5;${FG_CODE[tone === CARD_TONE.INFO ? "border" : tone]}m▎`), "every accent uses its tone foreground");
|
|
301
|
+
assert.deepEqual(cellBackgrounds(row).slice(1, -1), Array(28).fill(code), "every padding cell is fully painted");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("float panels keep body text with box-drawing characters intact", () => {
|
|
308
|
+
const body = "│ keep ╭─╮ and ╰─╯ │";
|
|
309
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ body: [body], title: "a ─ title │ ╮" }), floatTheme, 50, { expanded: true, previewRows: 3 })).map(stripAnsi);
|
|
310
|
+
assert.equal(lines[3], ` ▎ ${body}${" ".repeat(50 - 4 - visibleWidth(body))} `);
|
|
311
|
+
assert.ok(lines[1]!.startsWith(" ▎ ⌖ a ─ title │ ╮ "), JSON.stringify(lines[1]));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("float panels re-arm their background after content and truncation resets", () => {
|
|
315
|
+
// A long heading is truncated, and pi-tui's truncation inserts its own reset.
|
|
316
|
+
const long = `red\x1b[0m blue\x1b[49m tail`;
|
|
317
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ title: "x".repeat(80), body: [long] }), floatTheme, 30, { expanded: false, previewRows: 1 }));
|
|
318
|
+
assert.match(lines[1]!, /\x1b\[0m/, "the truncated heading carries a reset");
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
assert.equal(visibleWidth(line), 30);
|
|
321
|
+
assert.deepEqual(cellBackgrounds(line).slice(1, -1), Array(28).fill(BG_CODE.toolSuccessBg), JSON.stringify(line));
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("float cards without a body skip the separator and keep the blank rows around the heading", () => {
|
|
326
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard({ body: [] }), floatTheme, 30, { expanded: true, previewRows: 3 })).map(stripAnsi);
|
|
327
|
+
assert.deepEqual(lines, [` ▎${" ".repeat(27)} `, ` ▎ ⌖ read${" ".repeat(20)} `, ` ▎${" ".repeat(27)} `]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("float falls back to the outlined card without a theme background or below ten columns", () => {
|
|
331
|
+
const plainOptions = { expanded: false, previewRows: 3, hint: "ctrl+o" };
|
|
332
|
+
for (const width of [0, 1, 5, 9, 40]) {
|
|
333
|
+
const outlined = renderCard(floatCard(), ansiTheme, width, plainOptions);
|
|
334
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), ansiTheme, width, plainOptions)), outlined, `no bg at ${width}`);
|
|
335
|
+
}
|
|
336
|
+
const throwing = { ...floatTheme, bg(color: string, text: string): string { throw new Error(`unknown background ${color}${text}`); } };
|
|
337
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), throwing, 40, plainOptions)), renderCard(floatCard(), throwing, 40, plainOptions), "a missing background token");
|
|
338
|
+
for (const width of [0, 1, 5, 9]) {
|
|
339
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, width, plainOptions)), renderCard(floatCard(), floatTheme, width, plainOptions), `narrow ${width}`);
|
|
340
|
+
}
|
|
341
|
+
assert.match(stripAnsi(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(floatCard(), floatTheme, 10, plainOptions))[1]!), /^ ▎ ⌖ r {4}$/);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("the neon style paints no background even when the theme can", () => {
|
|
345
|
+
for (const width of [8, 40]) {
|
|
346
|
+
for (const line of renderCard(floatCard(), floatTheme, width, { expanded: true, previewRows: 3 })) {
|
|
347
|
+
assert.ok(cellBackgrounds(line).every((cell) => cell === undefined), JSON.stringify(line));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Panels (Agents, Todos, Status rail) opt in with `panel`. Neon keeps the
|
|
353
|
+
// outlined frame; float paints them like float cards, centered between two
|
|
354
|
+
// accent-barred padding rows with a blank row between header and body: two
|
|
355
|
+
// rows taller when a body exists, with the header on row 1.
|
|
356
|
+
const panelCard = (overrides: Partial<Card> = {}) => card({ title: "Agents", subtitle: "2 running", glyph: "◐", body: ["alpha", "beta"], ...overrides });
|
|
357
|
+
|
|
358
|
+
test("legacy renderCard without the panel opt-in stays byte-identical in both styles", () => {
|
|
359
|
+
const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d", "│ ╭─╮"] });
|
|
360
|
+
for (const width of [-3, 0, 1, 4, 9, 10, 40, 80]) {
|
|
361
|
+
for (const options of [{ expanded: true }, { expanded: false }, { expanded: true, hint: "ctrl+o" }]) {
|
|
362
|
+
for (const theme of [plainTheme, taggedTheme, floatTheme]) {
|
|
363
|
+
const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, options));
|
|
364
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(paragraphs, theme, width, options)), outlined, `width ${width}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("a panel in the neon style is byte-identical to the legacy outlined card", () => {
|
|
371
|
+
const paragraphs = card({ subtitle: "2 running", body: ["ab", "", "c d", "│ ╭─╮"] });
|
|
372
|
+
for (const width of [-3, 0, 1, 4, 9, 10, 40, 80]) {
|
|
373
|
+
for (const options of [{ expanded: true }, { expanded: false }, { expanded: true, hint: "ctrl+o" }]) {
|
|
374
|
+
for (const theme of [plainTheme, taggedTheme, floatTheme]) {
|
|
375
|
+
const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, options));
|
|
376
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.NEON, () => renderCard(paragraphs, theme, width, { ...options, panel: true })), outlined, `width ${width}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("a panel in the float style is a float card centered between padding rows, with a blank row after the header, two rows taller than neon", () => {
|
|
383
|
+
const options = { expanded: true, hint: "ctrl+a", panel: true };
|
|
384
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), floatTheme, 40, options));
|
|
385
|
+
assert.deepEqual(lines.map(stripAnsi), [
|
|
386
|
+
` ▎${" ".repeat(37)} `,
|
|
387
|
+
` ▎ ◐ Agents 2 running${" ".repeat(9)}ctrl+a `,
|
|
388
|
+
` ▎${" ".repeat(37)} `,
|
|
389
|
+
` ▎ alpha${" ".repeat(32)}`,
|
|
390
|
+
` ▎ beta${" ".repeat(33)}`,
|
|
391
|
+
` ▎${" ".repeat(37)} `,
|
|
392
|
+
]);
|
|
393
|
+
assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), floatTheme, 40, options)).length + 2);
|
|
394
|
+
for (const line of lines) {
|
|
395
|
+
const cells = cellBackgrounds(line);
|
|
396
|
+
assert.equal(cells.length, 40);
|
|
397
|
+
assert.equal(cells[0], undefined, "the left margin stays unpainted");
|
|
398
|
+
assert.equal(cells[39], undefined, "the right margin stays unpainted");
|
|
399
|
+
assert.ok(cells.slice(1, 39).every((cell) => cell === BG_CODE.toolSuccessBg), "the neutral tone paints the float card background");
|
|
400
|
+
}
|
|
401
|
+
assert.ok(lines.every((line) => line.includes(`\x1b[38;5;${FG_CODE.border}m▎`)), "the accent bar uses the frame role on every row, padding and separator rows included");
|
|
402
|
+
assert.ok(lines[1]!.includes(`\x1b[38;5;${FG_CODE.muted}m2 running`), "the count is muted");
|
|
403
|
+
assert.ok(lines[1]!.includes(`\x1b[38;5;${FG_CODE.muted}mctrl+a`), "the hint is muted");
|
|
404
|
+
|
|
405
|
+
const warning = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ tone: CARD_TONE.WARNING }), floatTheme, 40, options));
|
|
406
|
+
assert.ok(warning.every((line) => cellBackgrounds(line).slice(1, 39).every((cell) => cell === BG_CODE.toolPendingBg)), "a warning panel paints the warning card background");
|
|
407
|
+
assert.ok(warning[0]!.includes(`\x1b[38;5;${FG_CODE.warning}m▎`));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("a float panel keeps embedded styled title segments, re-arms its background, drops a hint that does not fit, and is two rows taller than neon", () => {
|
|
411
|
+
const control = "\x1b[38;5;211m▾ Collapse\x1b[0m";
|
|
412
|
+
const [, header] = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ title: `Todos ${control}`, subtitle: "1 of 3", glyph: "☰" }), floatTheme, 40, { expanded: true, panel: true }));
|
|
413
|
+
assert.match(stripAnsi(header!), /^ ▎ ☰ Todos ▾ Collapse 1 of 3 +$/, "the title keeps its case");
|
|
414
|
+
assert.ok(cellBackgrounds(header!).slice(1, 39).every((cell) => cell === BG_CODE.toolSuccessBg), "a reset inside the title re-arms the background");
|
|
415
|
+
assert.equal(stripAnsi(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), floatTheme, 26, { expanded: true, hint: "ctrl+a expand", panel: true }))[1]!), ` ▎ ◐ Agents 2 running${" ".repeat(3)} `, "the hint drops when it does not fit");
|
|
416
|
+
for (let width = 10; width <= 80; width++) {
|
|
417
|
+
// Chrome adds two rows. Content wider than the float body (two columns
|
|
418
|
+
// narrower than neon) still wraps, so this body fits at every width.
|
|
419
|
+
const short = panelCard({ body: ["ab", "", "cd"] });
|
|
420
|
+
for (const expanded of [true, false]) {
|
|
421
|
+
const options = { expanded, hint: "ctrl+o", panel: true };
|
|
422
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(short, floatTheme, width, options));
|
|
423
|
+
assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(short, floatTheme, width, options)).length + 2, `rows at ${width}`);
|
|
424
|
+
const headerRow = withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width));
|
|
425
|
+
assert.equal(headerRow, 1);
|
|
426
|
+
assert.match(stripAnsi(lines[headerRow]!), /^ ▎ ◐/, `header on row ${headerRow} at ${width}`);
|
|
427
|
+
assert.equal(stripAnsi(lines[headerRow + 1]!), ` ▎${" ".repeat(width - 3)} `, `a blank separator row follows the header at ${width}`);
|
|
428
|
+
for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
429
|
+
}
|
|
430
|
+
const long = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ subtitle: "3 active · 2 done · 1 failed", body: ["a".repeat(120)] }), floatTheme, width, { expanded: true, hint: "ctrl+o", panel: true }));
|
|
431
|
+
for (const line of long) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("a float panel without a body skips the separator row: padding, header, padding", () => {
|
|
436
|
+
const options = { expanded: true, hint: "ctrl+a", panel: true };
|
|
437
|
+
const lines = withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard({ body: [] }), floatTheme, 40, options)).map(stripAnsi);
|
|
438
|
+
assert.deepEqual(lines, [
|
|
439
|
+
` ▎${" ".repeat(37)} `,
|
|
440
|
+
` ▎ ◐ Agents 2 running${" ".repeat(9)}ctrl+a `,
|
|
441
|
+
` ▎${" ".repeat(37)} `,
|
|
442
|
+
]);
|
|
443
|
+
assert.equal(lines.length, withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard({ body: [] }), floatTheme, 40, options)).length + 1);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("panelExtraRows is 2 only where the float panel applies, so a capped panel body can stay as tall as neon", () => {
|
|
447
|
+
for (const width of [10, 40, 80]) {
|
|
448
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width)), 2, `float at ${width}`);
|
|
449
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width, CARD_TONE.ERROR)), 2, `error tone at ${width}`);
|
|
450
|
+
assert.equal(withCardStyle(CARD_STYLE.NEON, () => panelExtraRows(floatTheme, width)), 0, `neon at ${width}`);
|
|
451
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(taggedTheme, width)), 0, `no bg at ${width}`);
|
|
452
|
+
}
|
|
453
|
+
for (const width of [-3, 0, 9]) assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelExtraRows(floatTheme, width)), 0, `narrow ${width}`);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("panelHeaderRow is 1 only where the float panel applies, so callers can hit-test the header", () => {
|
|
457
|
+
for (const width of [10, 40, 80]) {
|
|
458
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width)), 1, `float at ${width}`);
|
|
459
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width, CARD_TONE.ERROR)), 1, `error tone at ${width}`);
|
|
460
|
+
assert.equal(withCardStyle(CARD_STYLE.NEON, () => panelHeaderRow(floatTheme, width)), 0, `neon at ${width}`);
|
|
461
|
+
assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(taggedTheme, width)), 0, `no bg at ${width}`);
|
|
462
|
+
}
|
|
463
|
+
for (const width of [-3, 0, 9]) assert.equal(withCardStyle(CARD_STYLE.FLOAT, () => panelHeaderRow(floatTheme, width)), 0, `narrow ${width}`);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("a float panel falls back to the outlined card below ten columns or without a theme background", () => {
|
|
467
|
+
const options = { expanded: true, hint: "ctrl+a", panel: true };
|
|
468
|
+
for (const width of [-3, 0, 1, 4, 9]) {
|
|
469
|
+
for (const theme of [taggedTheme, floatTheme]) {
|
|
470
|
+
const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), theme, width, options));
|
|
471
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), theme, width, options)), outlined, `width ${width}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const width of [10, 40, 80]) {
|
|
475
|
+
const outlined = withCardStyle(CARD_STYLE.NEON, () => renderCard(panelCard(), taggedTheme, width, options));
|
|
476
|
+
assert.deepEqual(withCardStyle(CARD_STYLE.FLOAT, () => renderCard(panelCard(), taggedTheme, width, options)), outlined, `no bg at ${width}`);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("a call card awaits its result until pi finishes the row or a result renderer marks it", () => {
|
|
481
|
+
const state = {};
|
|
482
|
+
assert.equal(cardAwaitingResult({ isPartial: true, state }), true);
|
|
483
|
+
assert.equal(cardAwaitingResult({ state }), true, "an unknown lifecycle is still pending");
|
|
484
|
+
assert.equal(cardAwaitingResult({ isPartial: true }), true, "no shared state: only the final flag closes it");
|
|
485
|
+
assert.equal(cardAwaitingResult({ isPartial: false, state }), false, "a final result always exists");
|
|
486
|
+
markCardResult(state);
|
|
487
|
+
assert.equal(cardAwaitingResult({ isPartial: true, state }), false, "a partial result component closes the frame");
|
|
488
|
+
assert.deepEqual(Object.keys(state), [], "the mark never shows up as ordinary row state");
|
|
489
|
+
for (const value of [undefined, null, 1, "state"]) {
|
|
490
|
+
assert.doesNotThrow(() => markCardResult(value));
|
|
491
|
+
assert.equal(cardAwaitingResult({ isPartial: true, state: value }), true);
|
|
492
|
+
}
|
|
493
|
+
assert.equal(cardRunningLine(CARD_TONE.WARNING, plainTheme, 20), `│ running…${" ".repeat(8)} │`);
|
|
494
|
+
for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8]) assert.ok(visibleWidth(cardRunningLine(CARD_TONE.WARNING, plainTheme, width)) <= width);
|
|
495
|
+
});
|
|
@@ -470,6 +470,30 @@ test("colorDiff drops git headers and colors hunks, additions, and removals by r
|
|
|
470
470
|
]);
|
|
471
471
|
});
|
|
472
472
|
|
|
473
|
+
test("colorDiff sanitizes control characters, strips carriage returns, and expands tabs", () => {
|
|
474
|
+
const raw = [
|
|
475
|
+
"diff --git a/file.ts b/file.ts\r",
|
|
476
|
+
"index 1..2 100644\r",
|
|
477
|
+
"--- a/file.ts\r",
|
|
478
|
+
"+++ b/file.ts\r",
|
|
479
|
+
"@@ -1,3 +1,3 @@\r",
|
|
480
|
+
" \tconst before = 1;\r",
|
|
481
|
+
"-\tconst removed = 2;\x1b[31minjected\x1b[0m\r",
|
|
482
|
+
"+\t\tconst added = 3;\x07\r",
|
|
483
|
+
].join("\n");
|
|
484
|
+
const lines = colorDiff(raw, taggedTheme);
|
|
485
|
+
assert.deepEqual(lines, [
|
|
486
|
+
"<customMessageLabel>@@ -1,3 +1,3 @@</customMessageLabel>",
|
|
487
|
+
"<toolDiffContext> const before = 1;</toolDiffContext>",
|
|
488
|
+
"<toolDiffRemoved>- const removed = 2;injected</toolDiffRemoved>",
|
|
489
|
+
"<toolDiffAdded>+ const added = 3;</toolDiffAdded>",
|
|
490
|
+
]);
|
|
491
|
+
for (const line of lines) {
|
|
492
|
+
assert.ok(!line.includes("\t"), "tabs must be expanded to spaces");
|
|
493
|
+
assert.ok(!line.includes("\r"), "carriage returns must be stripped");
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
473
497
|
test("ChangesView renders a framed two-pane layout at the requested size", async () => {
|
|
474
498
|
const { view: component } = view();
|
|
475
499
|
await settle();
|
|
@@ -484,6 +508,34 @@ test("ChangesView renders a framed two-pane layout at the requested size", async
|
|
|
484
508
|
assert.match(plain[10], /j\/k file .* o open in editor .* esc close/);
|
|
485
509
|
});
|
|
486
510
|
|
|
511
|
+
test("ChangesView renders tabbed CRLF modified diffs with aligned frame columns", async () => {
|
|
512
|
+
const tabbedDiff = [
|
|
513
|
+
"diff --git a/lib/a.ts b/lib/a.ts\r",
|
|
514
|
+
"index 1..2 100644\r",
|
|
515
|
+
"--- a/lib/a.ts\r",
|
|
516
|
+
"+++ b/lib/a.ts\r",
|
|
517
|
+
"@@ -1,2 +1,3 @@\r",
|
|
518
|
+
" \tconst a = 1;\r",
|
|
519
|
+
"-\tconst b = 2;\r",
|
|
520
|
+
"+\tconst b = 3;\r",
|
|
521
|
+
"+\t\tconst c = 4;\r",
|
|
522
|
+
].join("\n");
|
|
523
|
+
const { view: component } = view({
|
|
524
|
+
loadDiff: async () => tabbedDiff,
|
|
525
|
+
});
|
|
526
|
+
await settle();
|
|
527
|
+
const lines = component.render(80);
|
|
528
|
+
assert.equal(lines.length, 12);
|
|
529
|
+
for (const line of lines) {
|
|
530
|
+
assert.equal(visibleWidth(line), 80, `"${stripAnsi(line)}" is not 80 wide`);
|
|
531
|
+
assert.ok(!line.includes("\r"), "carriage return must not leak into rendered line");
|
|
532
|
+
assert.ok(!line.includes("\t"), "raw tab must not leak into rendered line");
|
|
533
|
+
}
|
|
534
|
+
const plain = lines.map(stripAnsi);
|
|
535
|
+
assert.match(plain[1], /^│ ▸ M lib\/a\.ts +\+2 -1 +│ @@ -1,2 \+1,3 @@ +│$/);
|
|
536
|
+
assert.match(plain[2], /^│ A lib\/b\.ts +\+10 -0 +│ +const a = 1; +│$/);
|
|
537
|
+
});
|
|
538
|
+
|
|
487
539
|
test("ChangesView loads the selected diff lazily and moves with j/k and arrows", async () => {
|
|
488
540
|
const { view: component, calls, events } = view();
|
|
489
541
|
await settle();
|
|
@@ -52,6 +52,7 @@ const CURSOR = "\x1b[7m \x1b[0m";
|
|
|
52
52
|
|
|
53
53
|
function options(overrides: Partial<PromptFrameOptions> = {}): PromptFrameOptions {
|
|
54
54
|
return {
|
|
55
|
+
style: "neon",
|
|
55
56
|
state: PROMPT_STATE.IDLE,
|
|
56
57
|
tick: 0,
|
|
57
58
|
borderColor: (text) => text,
|
|
@@ -87,6 +88,40 @@ test("framePromptLines paints the frame with the editor border color and the pet
|
|
|
87
88
|
assert.match(lines[2], /^\[b\]╰─+╯\[\/b\]$/);
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
test("framePromptLines renders an explicit ODD phase workingLabel instead of the generic working label", () => {
|
|
92
|
+
const plain = (_color: string, text: string) => text;
|
|
93
|
+
const phased = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: "exploring…" }));
|
|
94
|
+
assert.match(stripAnsi(phased[0]), /^╭─ ✾ exploring… ─+╮$/);
|
|
95
|
+
assert.equal(visibleWidth(phased[0]), 40);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("framePromptLines falls back to the generic working label when no phase was reported", () => {
|
|
99
|
+
const plain = (_color: string, text: string) => text;
|
|
100
|
+
const fallback = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: undefined }));
|
|
101
|
+
assert.match(stripAnsi(fallback[0]), /^╭─ ✾ working… ─+╮$/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("framePromptLines ignores workingLabel outside the working state", () => {
|
|
105
|
+
const plain = (_color: string, text: string) => text;
|
|
106
|
+
const idle = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.IDLE, fg: plain, workingLabel: "exploring…" }));
|
|
107
|
+
assert.equal(stripAnsi(idle[0]).includes("exploring"), false);
|
|
108
|
+
const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED, fg: plain, workingLabel: "exploring…" }));
|
|
109
|
+
assert.match(stripAnsi(queued[0]), /^╭─ ✿ queued ─+╮$/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("framePromptLines stays width-safe at narrow widths with the longest ODD phase label", () => {
|
|
113
|
+
const plain = (_color: string, text: string) => text;
|
|
114
|
+
// "implementing…" is the longest ODD_PHASES label (lib/odd-phase.ts).
|
|
115
|
+
const longestLabel = "implementing…";
|
|
116
|
+
for (const width of [1, 4, 8, 10, 14, 20, 40]) {
|
|
117
|
+
const lines = framePromptLines(editorLines(Math.max(4, width)), width, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: longestLabel }));
|
|
118
|
+
for (const line of lines) assert.ok(visibleWidth(line) <= width, `width ${width}: "${line}" exceeds the frame width`);
|
|
119
|
+
}
|
|
120
|
+
// At a comfortable width the full label still renders, unclipped.
|
|
121
|
+
const comfortable = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain, workingLabel: longestLabel }));
|
|
122
|
+
assert.match(stripAnsi(comfortable[0]), /implementing…/);
|
|
123
|
+
});
|
|
124
|
+
|
|
90
125
|
test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
|
|
91
126
|
assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
|
|
92
127
|
assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent"]);
|
|
@@ -163,7 +198,7 @@ test("withPromptHint leaves the line alone when the hint does not fit", () => {
|
|
|
163
198
|
assert.equal(hinted, line);
|
|
164
199
|
});
|
|
165
200
|
|
|
166
|
-
test("working prompt stays transparent at narrow and normal widths", () => {
|
|
201
|
+
test("neon/fallback working prompt stays transparent at narrow and normal widths", () => {
|
|
167
202
|
for (const width of [0, 1, 2, 3, 8, 40]) {
|
|
168
203
|
const lines = framePromptLines(["──", ` ${CURSOR}界`, "──"], width, options({
|
|
169
204
|
state: PROMPT_STATE.WORKING, fg: (_c, t) => t,
|
|
@@ -187,8 +222,65 @@ test("working prompt stays transparent at narrow and normal widths", () => {
|
|
|
187
222
|
}
|
|
188
223
|
});
|
|
189
224
|
|
|
190
|
-
test("transparent prompt preserves pi's cursor reset", () => {
|
|
225
|
+
test("neon/fallback transparent prompt preserves pi's cursor reset", () => {
|
|
191
226
|
const lines = framePromptLines(editorLines(40), 40, options({ fg: (_c, t) => t }));
|
|
192
227
|
assert.ok(lines[1].includes(CURSOR));
|
|
193
228
|
assert.doesNotMatch(lines.join("\n"), /\x1b\[44m/);
|
|
194
229
|
});
|
|
230
|
+
|
|
231
|
+
const floatBg = (_role: string, text: string) => `\x1b[48;2;20;30;40m${text}\x1b[49m`;
|
|
232
|
+
|
|
233
|
+
test("T2 float prompt keeps status and content inside the background with inset and bottom padding", () => {
|
|
234
|
+
const rows = framePromptLines(["── ↑ 2 more", `ab${CURSOR}cd`, "── ↓ 3 more"], 80, options({
|
|
235
|
+
style: "float", bg: floatBg, fg: (_c, t) => t, state: PROMPT_STATE.WORKING, workingLabel: "exploring…", escHint: "esc again to cancel",
|
|
236
|
+
}));
|
|
237
|
+
assert.equal(rows.length, 3);
|
|
238
|
+
assert.match(stripAnsi(rows[0]), /^ ▎ ✿ exploring… · ↑ 2 more · esc again to cancel/);
|
|
239
|
+
assert.match(stripAnsi(rows[1]), /^ ▎ ab cd + $/);
|
|
240
|
+
assert.ok(rows[1].includes(`${CURSOR}\x1b[48;2;20;30;40m`));
|
|
241
|
+
assert.equal(stripAnsi(rows[2]), ` ▎${" ".repeat(77)} `);
|
|
242
|
+
assert.ok(rows.every((row) => row.startsWith(" \x1b[48;2;20;30;40m") && row.endsWith("\x1b[49m ")));
|
|
243
|
+
assert.ok(rows.every((row) => visibleWidth(row) === 80));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("T2 float prompt falls back byte-exactly before narrow or unusable backgrounds", () => {
|
|
247
|
+
for (const width of [0, 1, 2, 9, 10, 30, 80]) {
|
|
248
|
+
const native = ["──", `x${CURSOR}`, "──"];
|
|
249
|
+
const plain = options({ fg: (_c, t) => t });
|
|
250
|
+
const neon = framePromptLines(native, width, { ...plain, style: "neon", bg: floatBg });
|
|
251
|
+
assert.deepEqual(neon, framePromptLines(native, width, plain));
|
|
252
|
+
for (const bg of [undefined, (_r: string, t: string) => t,
|
|
253
|
+
(_r: string, t: string) => `\x1b[39m${t}\x1b[49m`,
|
|
254
|
+
(_r: string, t: string) => `\x1b[44m\x1b[49m${t}\x1b[49m`,
|
|
255
|
+
(_r: string, _t: string): string => { throw new Error("missing"); }]) {
|
|
256
|
+
assert.deepEqual(framePromptLines(native, width, { ...plain, style: "float", bg }), neon);
|
|
257
|
+
}
|
|
258
|
+
const float = framePromptLines(native, width, { ...plain, style: "float", bg: floatBg });
|
|
259
|
+
if (width < 10) assert.deepEqual(float, neon);
|
|
260
|
+
else assert.match(stripAnsi(float[1]), /^ ▎/);
|
|
261
|
+
assert.ok(float.every((row) => visibleWidth(row) <= width));
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("T2 float prompt idle label uses the quiet card background without changing other states or neon", () => {
|
|
266
|
+
const bg = (role: string, text: string) => {
|
|
267
|
+
assert.equal(role, "toolSuccessBg");
|
|
268
|
+
return floatBg(role, text);
|
|
269
|
+
};
|
|
270
|
+
for (const state of [PROMPT_STATE.IDLE, PROMPT_STATE.WORKING, PROMPT_STATE.QUEUED]) {
|
|
271
|
+
const rows = framePromptLines(["──", "draft", "──"], 80, options({ style: "float", bg, state, fg: (_c, t) => t }));
|
|
272
|
+
assert.match(stripAnsi(rows[0]), state === PROMPT_STATE.IDLE ? /✿ waiting for input/ : state === PROMPT_STATE.WORKING ? /working…/ : /queued/);
|
|
273
|
+
if (state !== PROMPT_STATE.IDLE) assert.doesNotMatch(stripAnsi(rows[0]), /waiting for input/);
|
|
274
|
+
}
|
|
275
|
+
const neon = framePromptLines(["──", "draft", "──"], 80, options({ style: "neon", state: PROMPT_STATE.IDLE, fg: (_c, t) => t }));
|
|
276
|
+
assert.doesNotMatch(stripAnsi(neon[0]), /waiting for input/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("T2 float prompt rearms real SGR resets without altering inversion or marker bytes", () => {
|
|
280
|
+
for (const open of ["\x1b[44m", "\x1b[48;5;24m", "\x1b[48;2;0;30;0m"]) {
|
|
281
|
+
const marker = "\x1b_pi:c\x07";
|
|
282
|
+
const native = `${marker}\x1b[7mx\x1b[0mY\x1b[0;7mZ\x1b[27m`;
|
|
283
|
+
const rows = framePromptLines(["──", native, "──"], 30, options({ style: "float", bg: (_r, t) => `${open}${t}\x1b[49m`, fg: (_c, t) => t }));
|
|
284
|
+
assert.ok(rows[1].includes(`${marker}\x1b[7mx\x1b[0m${open}Y\x1b[0;7m${open}Z\x1b[27m`));
|
|
285
|
+
}
|
|
286
|
+
});
|