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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
|
|
6
|
+
export const VISUAL_SCHEMA = "gentle-pi.visual-customization/v1";
|
|
7
|
+
export const STATUS_PLACEMENT = { AUTO: "auto", RIGHT: "right", BOTTOM: "bottom", HIDDEN: "hidden" } as const;
|
|
8
|
+
export const HEADER_PLACEMENT = { TOP: "top", BELOW_INPUT: "below-input" } as const;
|
|
9
|
+
export const DENSITY = { COMFORTABLE: "comfortable", COMPACT: "compact", MINIMAL: "minimal" } as const;
|
|
10
|
+
export type StatusPlacement = (typeof STATUS_PLACEMENT)[keyof typeof STATUS_PLACEMENT];
|
|
11
|
+
export type HeaderPlacement = (typeof HEADER_PLACEMENT)[keyof typeof HEADER_PLACEMENT];
|
|
12
|
+
export type Density = (typeof DENSITY)[keyof typeof DENSITY];
|
|
13
|
+
|
|
14
|
+
export interface VisualVisibility {
|
|
15
|
+
changes: boolean;
|
|
16
|
+
rdd: boolean;
|
|
17
|
+
agents: boolean;
|
|
18
|
+
todo: boolean;
|
|
19
|
+
usageCost: boolean;
|
|
20
|
+
modelDetails: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface VisualSettings {
|
|
23
|
+
statusPlacement: StatusPlacement;
|
|
24
|
+
headerPlacement: HeaderPlacement;
|
|
25
|
+
density: Density;
|
|
26
|
+
visibility: VisualVisibility;
|
|
27
|
+
}
|
|
28
|
+
export const DEFAULT_VISUAL_SETTINGS: VisualSettings = {
|
|
29
|
+
statusPlacement: STATUS_PLACEMENT.AUTO,
|
|
30
|
+
headerPlacement: HEADER_PLACEMENT.TOP,
|
|
31
|
+
density: DENSITY.COMFORTABLE,
|
|
32
|
+
visibility: { changes: true, rdd: true, agents: true, todo: true, usageCost: true, modelDetails: true },
|
|
33
|
+
};
|
|
34
|
+
/** Sections row order; `rdd` follows `changes` as the RDD group follows Changes in the Status card. */
|
|
35
|
+
export const VISUAL_SECTION_KEYS = ["changes", "rdd", "agents", "todo", "usageCost", "modelDetails"] as const satisfies readonly (keyof VisualVisibility)[];
|
|
36
|
+
/** Keys added after v1 shipped: stored settings may omit them together, and they default to shown. */
|
|
37
|
+
const ADDED_SECTION_KEYS: readonly (keyof VisualVisibility)[] = ["rdd"];
|
|
38
|
+
const LEGACY_SECTION_KEYS = VISUAL_SECTION_KEYS.filter((key) => !ADDED_SECTION_KEYS.includes(key));
|
|
39
|
+
interface VisualOptions { gentlePiConfigHome?: string }
|
|
40
|
+
export interface VisualResolution {
|
|
41
|
+
settings: VisualSettings;
|
|
42
|
+
source: "global_file" | "default";
|
|
43
|
+
malformed: boolean;
|
|
44
|
+
readError: boolean;
|
|
45
|
+
globalFile: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
function keysMatch(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
52
|
+
return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
53
|
+
}
|
|
54
|
+
function member<T extends string>(value: unknown, choices: Record<string, T>): value is T {
|
|
55
|
+
return typeof value === "string" && Object.values(choices).includes(value as T);
|
|
56
|
+
}
|
|
57
|
+
export function isVisualSettings(value: unknown): value is VisualSettings {
|
|
58
|
+
if (!record(value) || !keysMatch(value, ["statusPlacement", "headerPlacement", "density", "visibility"])) return false;
|
|
59
|
+
const visibility = value.visibility;
|
|
60
|
+
return member(value.statusPlacement, STATUS_PLACEMENT)
|
|
61
|
+
&& member(value.headerPlacement, HEADER_PLACEMENT)
|
|
62
|
+
&& member(value.density, DENSITY)
|
|
63
|
+
&& record(visibility)
|
|
64
|
+
&& keysMatch(visibility, VISUAL_SECTION_KEYS)
|
|
65
|
+
&& Object.values(visibility).every((item) => typeof item === "boolean");
|
|
66
|
+
}
|
|
67
|
+
/** Reader-side validation: accepts the current shape and legacy visibility that predates added sections. Writers stay strict. */
|
|
68
|
+
export function normalizeVisualSettings(value: unknown): VisualSettings | undefined {
|
|
69
|
+
if (!record(value) || !record(value.visibility)) return undefined;
|
|
70
|
+
const visibility = value.visibility;
|
|
71
|
+
const candidate = keysMatch(visibility, LEGACY_SECTION_KEYS)
|
|
72
|
+
? { ...value, visibility: Object.fromEntries(VISUAL_SECTION_KEYS.map((key) => [key, visibility[key] ?? true])) }
|
|
73
|
+
: value;
|
|
74
|
+
return isVisualSettings(candidate) ? candidate : undefined;
|
|
75
|
+
}
|
|
76
|
+
export function parseVisualSettingsFile(raw: string): VisualSettings | undefined {
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(raw);
|
|
79
|
+
if (!record(parsed) || parsed.schema !== VISUAL_SCHEMA) return undefined;
|
|
80
|
+
const { schema: _schema, ...settings } = parsed;
|
|
81
|
+
return normalizeVisualSettings(settings);
|
|
82
|
+
} catch { return undefined; }
|
|
83
|
+
}
|
|
84
|
+
export function resolveVisualSettings(options: VisualOptions = {}): VisualResolution {
|
|
85
|
+
const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-customization.json");
|
|
86
|
+
try {
|
|
87
|
+
const settings = parseVisualSettingsFile(readFileSync(globalFile, "utf8"));
|
|
88
|
+
return { settings: settings ?? structuredClone(DEFAULT_VISUAL_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const missing = record(error) && error.code === "ENOENT";
|
|
91
|
+
return { settings: structuredClone(DEFAULT_VISUAL_SETTINGS), source: missing ? "default" : "global_file", malformed: false, readError: !missing, globalFile };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Same-directory rename keeps partial JSON invisible to readers. */
|
|
95
|
+
export function writeVisualSettings(settings: VisualSettings, options: VisualOptions = {}): string {
|
|
96
|
+
if (!isVisualSettings(settings)) throw new TypeError("Invalid visual settings");
|
|
97
|
+
const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
98
|
+
const path = join(home, "visual-customization.json");
|
|
99
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
100
|
+
mkdirSync(home, { recursive: true });
|
|
101
|
+
try {
|
|
102
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: VISUAL_SCHEMA, ...settings })}\n`, { flag: "wx", mode: 0o600 });
|
|
103
|
+
renameSync(temporary, path);
|
|
104
|
+
} finally {
|
|
105
|
+
try { unlinkSync(temporary); } catch { /* Rename consumed the temporary file. */ }
|
|
106
|
+
}
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { decodeKittyPrintable, isKeyRelease, matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { VisualProfile } from "./visual-profiles.ts";
|
|
3
|
+
import { CARD_PADDING, renderPaletteCard, renderPaletteSelection } from "./command-palette.ts";
|
|
4
|
+
import { stripAnsi } from "./terminal-theme.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_INPUT_CODEPOINTS = 512;
|
|
7
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
|
+
|
|
9
|
+
function safeText(text: string): boolean {
|
|
10
|
+
return [...text].every((character) => character === "\u200c" || character === "\u200d" || !/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(character));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function appendInput(current: string, addition: string): string {
|
|
14
|
+
if (!safeText(addition)) return current;
|
|
15
|
+
return current + [...addition].slice(0, Math.max(0, MAX_INPUT_CODEPOINTS - [...current].length)).join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Pi 0.85.1's terminal forwards bracketed paste as one wrapped input event
|
|
19
|
+
// to custom overlays. Only unwrap a complete frame, never terminal controls.
|
|
20
|
+
function pastedText(data: string): string | undefined {
|
|
21
|
+
const start = "\x1b[200~", end = "\x1b[201~";
|
|
22
|
+
if (!data.startsWith(start) || !data.endsWith(end)) return undefined;
|
|
23
|
+
const text = data.slice(start.length, -end.length);
|
|
24
|
+
return safeText(text) ? text : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dropLastGrapheme(text: string): string {
|
|
28
|
+
const segments = [...graphemes.segment(text)];
|
|
29
|
+
return segments.length ? text.slice(0, segments[segments.length - 1]!.index) : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CustomizeCategory = "Animations" | "Banner" | "Themes" | "Editor" | "History" | "Layout" | "Cards" | "Sections" | "Profiles" | "Reset";
|
|
33
|
+
|
|
34
|
+
export interface CustomizeRow {
|
|
35
|
+
category?: CustomizeCategory;
|
|
36
|
+
label: string | (() => string);
|
|
37
|
+
/** Read-only representation of the highlighted choice, never an application. */
|
|
38
|
+
preview?: () => { title: string; sample: string };
|
|
39
|
+
action(): void | Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export interface ProfileActions {
|
|
42
|
+
list(): VisualProfile[];
|
|
43
|
+
save(name: string, replace: boolean): void | Promise<void>;
|
|
44
|
+
apply(name: string): void | Promise<void>;
|
|
45
|
+
delete(name: string): void | Promise<void>;
|
|
46
|
+
reset(): void | Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface CustomizeViewOptions {
|
|
49
|
+
profiles?: ProfileActions;
|
|
50
|
+
rows: CustomizeRow[];
|
|
51
|
+
theme: { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
52
|
+
requestRender(): void;
|
|
53
|
+
rowsAvailable?: () => number;
|
|
54
|
+
onError?: (error: Error) => void;
|
|
55
|
+
onClose(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One interaction per instance; the selection survives updates to store-backed labels. */
|
|
59
|
+
export class VisualCustomizeView {
|
|
60
|
+
private selected = 0;
|
|
61
|
+
private mainWidth?: number;
|
|
62
|
+
private categoryIndex = 0;
|
|
63
|
+
private pane: "categories" | "controls";
|
|
64
|
+
private readonly categories: string[];
|
|
65
|
+
private readonly controls = new Map<string, number>();
|
|
66
|
+
private closed = false;
|
|
67
|
+
private busy = false;
|
|
68
|
+
private readonly options: CustomizeViewOptions;
|
|
69
|
+
private error?: string;
|
|
70
|
+
private profileOpen = false;
|
|
71
|
+
private profileIndex = 0;
|
|
72
|
+
private profileInput?: string;
|
|
73
|
+
private profileWidth?: number;
|
|
74
|
+
private profileInputVisible = false;
|
|
75
|
+
private confirmation?: { action: "replace" | "apply" | "delete" | "reset"; name?: string; fingerprint?: string };
|
|
76
|
+
private confirmationVisible = false;
|
|
77
|
+
private profileBusy = false;
|
|
78
|
+
openProfiles(): void { this.profileOpen = true; this.error = undefined; this.options.requestRender(); }
|
|
79
|
+
private profileAction(action: () => void | Promise<void>): void {
|
|
80
|
+
if (this.profileBusy) return;
|
|
81
|
+
this.profileBusy = true;
|
|
82
|
+
try {
|
|
83
|
+
void Promise.resolve(action()).catch((error: unknown) => {
|
|
84
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
85
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
+
}).finally(() => { this.profileBusy = false; this.options.requestRender(); });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.profileBusy = false;
|
|
89
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
90
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
private confirmationLines(choice: NonNullable<VisualCustomizeView["confirmation"]>, inner: number, capacity: number): string[] | undefined {
|
|
94
|
+
const prompt = `Confirm ${choice.action} ${choice.name ?? "catalog"}? y yes · any other key cancel`;
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
if (visibleWidth(prompt) <= inner) lines.push(prompt);
|
|
97
|
+
else {
|
|
98
|
+
lines.push(`Confirm ${choice.action}`);
|
|
99
|
+
let remaining = `${choice.name ?? "catalog"}?`;
|
|
100
|
+
while (remaining.length && inner > 0) {
|
|
101
|
+
let chunk = "";
|
|
102
|
+
for (const { segment } of graphemes.segment(remaining)) {
|
|
103
|
+
if (visibleWidth(chunk + segment) > inner) break;
|
|
104
|
+
chunk += segment;
|
|
105
|
+
}
|
|
106
|
+
if (!chunk) return undefined;
|
|
107
|
+
lines.push(chunk);
|
|
108
|
+
remaining = remaining.slice(chunk.length);
|
|
109
|
+
}
|
|
110
|
+
if (remaining.length) return undefined;
|
|
111
|
+
lines.push("y yes · any other key cancel");
|
|
112
|
+
}
|
|
113
|
+
return lines.length <= capacity && lines.every(line => visibleWidth(line) <= inner) ? lines : undefined;
|
|
114
|
+
}
|
|
115
|
+
private handleProfiles(data: string): void {
|
|
116
|
+
const profiles = this.options.profiles!;
|
|
117
|
+
if (matchesKey(data, Key.escape)) {
|
|
118
|
+
if (this.profileInput !== undefined) this.profileInput = undefined;
|
|
119
|
+
else if (this.confirmation) { this.confirmation = undefined; this.confirmationVisible = false; }
|
|
120
|
+
else this.profileOpen = false;
|
|
121
|
+
} else if (this.profileInput !== undefined) {
|
|
122
|
+
if (data.startsWith("\x1b[200~")) {
|
|
123
|
+
const pasted = pastedText(data);
|
|
124
|
+
if (pasted !== undefined) this.profileInput = appendInput(this.profileInput, pasted);
|
|
125
|
+
} else if (matchesKey(data, Key.enter) || data.endsWith("\r") && safeText(data.slice(0, -1))) {
|
|
126
|
+
if (!matchesKey(data, Key.enter)) this.profileInput = appendInput(this.profileInput, data.slice(0, -1));
|
|
127
|
+
// A resize or error may hide an open field. Never save a value that
|
|
128
|
+
// cannot fit in the current profile card at its last rendered width.
|
|
129
|
+
if (this.profileWidth !== undefined) this.renderProfiles(this.profileWidth);
|
|
130
|
+
if (this.profileInputVisible) {
|
|
131
|
+
const name = this.profileInput;
|
|
132
|
+
this.profileInput = undefined;
|
|
133
|
+
this.profileInputVisible = false;
|
|
134
|
+
this.profileAction(() => profiles.save(name, false));
|
|
135
|
+
}
|
|
136
|
+
} else if (matchesKey(data, Key.backspace) || data === "\x7f") this.profileInput = dropLastGrapheme(this.profileInput);
|
|
137
|
+
else { const text = decodeKittyPrintable(data) ?? data; if (safeText(text)) this.profileInput = appendInput(this.profileInput, text); }
|
|
138
|
+
} else if (this.confirmation) {
|
|
139
|
+
const choice = this.confirmation;
|
|
140
|
+
const width = this.profileWidth;
|
|
141
|
+
const size = width === undefined ? undefined : this.detailSize(width);
|
|
142
|
+
const visible = this.confirmationVisible && size !== undefined && size.height >= 3 && size.inner >= 1
|
|
143
|
+
&& this.confirmationLines(choice, size.inner, size.capacity) !== undefined;
|
|
144
|
+
this.confirmation = undefined;
|
|
145
|
+
this.confirmationVisible = false;
|
|
146
|
+
if (matchesKey(data, "y") && visible) {
|
|
147
|
+
if (choice.action === "reset") this.profileAction(() => profiles.reset());
|
|
148
|
+
else if (choice.name) {
|
|
149
|
+
const name = choice.name;
|
|
150
|
+
try {
|
|
151
|
+
const current = profiles.list().find(item => item.name === name);
|
|
152
|
+
if (!current || JSON.stringify(current) !== choice.fingerprint) this.error = "Profile changed; choose again.";
|
|
153
|
+
else this.profileAction(() => choice.action === "replace" ? profiles.save(name, true) : choice.action === "apply" ? profiles.apply(name) : profiles.delete(name));
|
|
154
|
+
} catch (error) { this.error = error instanceof Error ? error.message : String(error); }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else if (!this.profileBusy) {
|
|
158
|
+
let items: VisualProfile[];
|
|
159
|
+
try { items = profiles.list(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); this.options.requestRender(); return; }
|
|
160
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "k")) this.profileIndex = (this.profileIndex + items.length - 1) % (items.length || 1);
|
|
161
|
+
else if (matchesKey(data, Key.down) || matchesKey(data, "j")) this.profileIndex = (this.profileIndex + 1) % (items.length || 1);
|
|
162
|
+
else if (matchesKey(data, "s")) { this.profileInput = ""; this.profileInputVisible = false; }
|
|
163
|
+
else if (matchesKey(data, "z")) { this.confirmation = { action: "reset" }; this.confirmationVisible = false; }
|
|
164
|
+
else if (items.length && (matchesKey(data, "r") || matchesKey(data, "a") || matchesKey(data, "d"))) {
|
|
165
|
+
const target = items[this.profileIndex]!;
|
|
166
|
+
this.confirmation = { action: matchesKey(data, "r") ? "replace" : matchesKey(data, "a") ? "apply" : "delete", name: target.name, fingerprint: JSON.stringify(target) };
|
|
167
|
+
this.confirmationVisible = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.options.requestRender();
|
|
171
|
+
}
|
|
172
|
+
constructor(options: CustomizeViewOptions) {
|
|
173
|
+
this.options = options;
|
|
174
|
+
this.categories = [...new Set(options.rows.map(row => row.category ?? "Settings"))];
|
|
175
|
+
this.pane = options.rows.some(row => row.category) ? "categories" : "controls";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private categoryRows(): CustomizeRow[] {
|
|
179
|
+
return this.options.rows.filter(row => (row.category ?? "Settings") === this.categories[this.categoryIndex]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
handleInput(data: string): void {
|
|
183
|
+
if (this.closed || isKeyRelease(data)) return;
|
|
184
|
+
if (this.profileOpen && this.options.profiles) { this.handleProfiles(data); return; }
|
|
185
|
+
if (matchesKey(data, Key.escape)) { this.closed = true; this.options.onClose(); return; }
|
|
186
|
+
if (this.options.profiles && matchesKey(data, "p")) { this.openProfiles(); return; }
|
|
187
|
+
const rows = this.categoryRows();
|
|
188
|
+
const length = this.pane === "categories" ? this.categories.length : rows.length;
|
|
189
|
+
if (!length) return;
|
|
190
|
+
this.selected = Math.min(this.controls.get(this.categories[this.categoryIndex]!) ?? 0, Math.max(0, rows.length - 1));
|
|
191
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.tab) && this.pane === "controls") this.pane = "categories";
|
|
192
|
+
else if (matchesKey(data, Key.right) || matchesKey(data, Key.tab) && this.pane === "categories") this.pane = "controls";
|
|
193
|
+
else if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
|
|
194
|
+
if (this.pane === "categories") this.categoryIndex = (this.categoryIndex + length - 1) % length;
|
|
195
|
+
else this.controls.set(this.categories[this.categoryIndex]!, (this.selected + length - 1) % length);
|
|
196
|
+
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
|
|
197
|
+
if (this.pane === "categories") this.categoryIndex = (this.categoryIndex + 1) % length;
|
|
198
|
+
else this.controls.set(this.categories[this.categoryIndex]!, (this.selected + 1) % length);
|
|
199
|
+
} else if ((matchesKey(data, Key.enter) || matchesKey(data, Key.space)) && this.pane === "categories") this.pane = "controls";
|
|
200
|
+
else if ((matchesKey(data, Key.enter) || matchesKey(data, Key.space)) && !this.busy &&
|
|
201
|
+
this.mainWidth !== undefined && this.mainWidth >= CARD_PADDING + 3 &&
|
|
202
|
+
(this.options.rowsAvailable?.() ?? 24) >= 4 && rows[this.selected]) {
|
|
203
|
+
this.busy = true;
|
|
204
|
+
try {
|
|
205
|
+
void Promise.resolve(rows[this.selected]?.action()).catch((error: unknown) => {
|
|
206
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
207
|
+
}).finally(() => { this.busy = false; if (!this.closed) this.options.requestRender(); });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.busy = false;
|
|
210
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.options.requestRender();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
render(width: number): string[] {
|
|
217
|
+
if (this.profileOpen && this.options.profiles) {
|
|
218
|
+
if (width <= 0) { this.confirmationVisible = false; return []; }
|
|
219
|
+
return this.renderProfiles(width);
|
|
220
|
+
}
|
|
221
|
+
this.mainWidth = width;
|
|
222
|
+
if (width <= 0) { this.confirmationVisible = false; return []; }
|
|
223
|
+
const available = this.options.rowsAvailable?.() ?? 24;
|
|
224
|
+
const height = Number.isFinite(available) ? Math.max(0, Math.min(24, Math.floor(available))) : 0;
|
|
225
|
+
if (!height) return [];
|
|
226
|
+
// A card needs two border rows. At extreme dimensions keep the old
|
|
227
|
+
// read-only heading rather than letting invisible controls be activated.
|
|
228
|
+
if (width < CARD_PADDING + 1 || height < 4) return Array.from({ length: Math.min(height, 2) }, (_, i) => truncateToWidth(i ? "Enlarge terminal" : "Visual customization", width, ""));
|
|
229
|
+
const inner = width - CARD_PADDING;
|
|
230
|
+
const category = this.categories[this.categoryIndex] ?? "Settings";
|
|
231
|
+
const rows = this.categoryRows();
|
|
232
|
+
const narrow = width < 60;
|
|
233
|
+
const navOnly = narrow && this.pane === "categories" && this.categories.length > 1;
|
|
234
|
+
const leftWidth = narrow ? 0 : Math.min(20, Math.max(14, Math.floor(inner * 0.28)));
|
|
235
|
+
const rightWidth = inner - leftWidth - (leftWidth ? 3 : 0);
|
|
236
|
+
const previewLines = height >= 11 ? 3 : height >= 8 ? 1 : 0;
|
|
237
|
+
const showHeading = height >= 6;
|
|
238
|
+
const showTopGap = height >= 12;
|
|
239
|
+
const showBottomGap = height >= 9;
|
|
240
|
+
const chrome = 1 + Number(showHeading) * 2 + Number(showTopGap) + Number(showBottomGap);
|
|
241
|
+
const contentHeight = Math.max(1, height - 2 - chrome - previewLines);
|
|
242
|
+
this.selected = Math.min(this.controls.get(category) ?? 0, Math.max(0, rows.length - 1));
|
|
243
|
+
const position = navOnly ? this.categoryIndex : this.selected;
|
|
244
|
+
const listLength = navOnly ? this.categories.length : rows.length;
|
|
245
|
+
const start = Math.min(Math.max(0, position - contentHeight + 1), Math.max(0, listLength - contentHeight));
|
|
246
|
+
const navStart = Math.min(Math.max(0, this.categoryIndex - contentHeight + 1), Math.max(0, this.categories.length - contentHeight));
|
|
247
|
+
const paint = (text: string, size: number, role = "text") => {
|
|
248
|
+
const clipped = truncateToWidth(text, Math.max(0, size), "…");
|
|
249
|
+
return this.options.theme.fg(role, clipped + " ".repeat(Math.max(0, size - visibleWidth(clipped))));
|
|
250
|
+
};
|
|
251
|
+
const join = (left: string, right: string) => leftWidth ? `${left}${" ".repeat(Math.max(0, leftWidth - visibleWidth(stripAnsi(left))))} ${paint("│", 1, "border")} ${right}` : right;
|
|
252
|
+
const heading = navOnly ? "Categories · choose a category →" : `${category} · ${rows.length ? `${this.selected + 1}/${rows.length}` : "empty"}`;
|
|
253
|
+
const lines: string[] = [paint("Visual customization", inner, "accent")];
|
|
254
|
+
// Commands uses a header, grouped section labels, whitespace and a footer.
|
|
255
|
+
if (showHeading) lines.push(join(leftWidth ? paint("CATEGORIES", leftWidth, "accent") : "", paint(heading, rightWidth, "accent")));
|
|
256
|
+
if (showTopGap) lines.push("");
|
|
257
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
258
|
+
const nav = this.categories[navStart + i];
|
|
259
|
+
const left = nav ? renderPaletteSelection(nav, leftWidth, navStart + i === this.categoryIndex, this.pane === "categories" ? this.options.theme : { fg: this.options.theme.fg.bind(this.options.theme) }) : "";
|
|
260
|
+
const row = rows[start + i];
|
|
261
|
+
const item = navOnly ? this.categories[start + i] : row ? typeof row.label === "function" ? row.label() : row.label : undefined;
|
|
262
|
+
const right = item ? renderPaletteSelection(item, rightWidth, start + i === position, navOnly || this.pane === "controls" ? this.options.theme : { fg: this.options.theme.fg.bind(this.options.theme) }) : rows.length ? "" : paint("No settings available", rightWidth, "muted");
|
|
263
|
+
lines.push(join(left, right));
|
|
264
|
+
}
|
|
265
|
+
if (previewLines === 3) {
|
|
266
|
+
let preview: ReturnType<NonNullable<CustomizeRow["preview"]>> | undefined;
|
|
267
|
+
try { if (!navOnly) preview = rows[this.selected]?.preview?.(); } catch { /* Never substitute the active theme for an unreadable source. */ }
|
|
268
|
+
lines.push("");
|
|
269
|
+
lines.push(paint(navOnly ? "→ controls · Esc close" : preview ? `Preview · ${preview.title}` : "Preview unavailable", inner, "accent"));
|
|
270
|
+
lines.push(paint(navOnly ? "" : preview?.sample ?? "No read-only sample for this choice", inner, "muted"));
|
|
271
|
+
} else if (previewLines === 1) lines.push(paint(navOnly ? "→ controls · Esc close" : "Preview omitted · enlarge terminal", inner, "muted"));
|
|
272
|
+
if (showBottomGap) lines.push("");
|
|
273
|
+
if (showHeading) lines.push(paint(navOnly ? "↑/↓ categories · → open · Esc close" : previewLines ? "←/→ or Tab panes · ↑/↓ move · Enter apply · p profiles · Esc close" : "Preview omitted · ←/→ panes · ↑/↓ move · Enter apply · Esc close", inner, "muted"));
|
|
274
|
+
// The top-right close hint mirrors Commands without consuming another row.
|
|
275
|
+
lines[0] = paint("Visual customization" + " ".repeat(Math.max(1, inner - visibleWidth("Visual customization") - 3)) + "esc", inner, "accent");
|
|
276
|
+
return renderPaletteCard(lines.slice(0, height - 2), width, this.options.theme);
|
|
277
|
+
}
|
|
278
|
+
private detailSize(width: number): { height: number; inner: number; capacity: number } {
|
|
279
|
+
const available = this.options.rowsAvailable?.() ?? 24;
|
|
280
|
+
const height = Number.isFinite(available) ? Math.max(0, Math.min(24, Math.floor(available))) : 0;
|
|
281
|
+
return { height, inner: Math.max(0, width - CARD_PADDING), capacity: Math.max(0, height - 2) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private detailFrame(lines: string[], width: number, height: number): string[] {
|
|
285
|
+
if (!height || width <= 0) return [];
|
|
286
|
+
if (width < CARD_PADDING + 1 || height < 3) return lines.slice(0, height).map(line => truncateToWidth(stripAnsi(line), width, ""));
|
|
287
|
+
return renderPaletteCard(lines.slice(0, height - 2), width, this.options.theme);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private renderProfiles(width: number): string[] {
|
|
291
|
+
this.profileWidth = width;
|
|
292
|
+
this.profileInputVisible = false;
|
|
293
|
+
const { height, inner, capacity } = this.detailSize(width);
|
|
294
|
+
if (height < 3 || inner < 1) { this.confirmationVisible = false; return this.detailFrame(["Visual profiles", "Enlarge terminal"], width, height); }
|
|
295
|
+
let items: VisualProfile[];
|
|
296
|
+
try { items = this.options.profiles!.list(); } catch (error) {
|
|
297
|
+
this.confirmationVisible = false;
|
|
298
|
+
return this.detailFrame([this.options.theme.fg("accent", "Visual profiles"), `Profiles unavailable: ${error instanceof Error ? error.message : String(error)}`, "Esc back"], width, height);
|
|
299
|
+
}
|
|
300
|
+
this.profileIndex = Math.min(this.profileIndex, Math.max(0, items.length - 1));
|
|
301
|
+
const selected = items[this.profileIndex];
|
|
302
|
+
const choice = this.confirmation;
|
|
303
|
+
const promptLines = choice ? this.confirmationLines(choice, inner, capacity) : undefined;
|
|
304
|
+
this.confirmationVisible = promptLines !== undefined;
|
|
305
|
+
const lines: string[] = promptLines ? promptLines.map(line => this.options.theme.fg("warning", line)) : choice ? [this.options.theme.fg("warning", "Confirmation unavailable · Esc back")] : [];
|
|
306
|
+
// Input outranks the error, header and preview on tiny terminals.
|
|
307
|
+
const input = this.profileInput !== undefined ? `New profile name: ${this.profileInput}▏` : undefined;
|
|
308
|
+
if (input && lines.length < capacity) {
|
|
309
|
+
this.profileInputVisible = visibleWidth(input) <= inner;
|
|
310
|
+
lines.push(this.options.theme.fg("accent", input));
|
|
311
|
+
}
|
|
312
|
+
if (lines.length < capacity) lines.push(this.options.theme.fg("accent", "Visual profiles (preview only)"));
|
|
313
|
+
if (lines.length < capacity) lines.push(selected ? renderPaletteSelection(`${this.profileIndex + 1}/${items.length}: ${selected.name}`, inner, true, this.options.theme) : this.options.theme.fg("muted", "No saved profiles"));
|
|
314
|
+
const details = selected ? [
|
|
315
|
+
`Theme: ${selected.themeName}`,
|
|
316
|
+
`Animation: ${selected.animationPolicy} · Banner: rose ${selected.banner.showRose ? "on" : "off"}, logo ${selected.banner.showTextLogo ? "on" : "off"}, ${selected.banner.color}`,
|
|
317
|
+
`Layout: ${selected.visual.statusPlacement} · ${selected.visual.headerPlacement} · ${selected.visual.density}`,
|
|
318
|
+
`Sections: ${Object.entries(selected.visual.visibility).map(([key, value]) => `${key}:${value ? "on" : "off"}`).join(" · ")}`,
|
|
319
|
+
] : [];
|
|
320
|
+
const tail = this.error ? [`Error: ${this.error}`] : [];
|
|
321
|
+
const footer = "↑/↓ select · s save current · r replace · a apply · d delete · z reset catalog · Esc back";
|
|
322
|
+
const free = Math.max(0, capacity - lines.length - tail.length - 1);
|
|
323
|
+
lines.push(...details.slice(0, free).map(line => this.options.theme.fg("text", line)));
|
|
324
|
+
for (const line of tail) if (lines.length < capacity) lines.push(this.options.theme.fg(line.startsWith("Error:") ? "error" : "accent", line));
|
|
325
|
+
if (lines.length < capacity) lines.push(this.options.theme.fg("muted", footer));
|
|
326
|
+
return this.detailFrame(lines, width, height);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
invalidate(): void { this.options.requestRender(); }
|
|
330
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
import { ANIMATION_POLICY, type AnimationPolicy } from "./animation-policy.ts";
|
|
6
|
+
import { isVisualSettings, normalizeVisualSettings, type VisualSettings } from "./visual-customization-policy.ts";
|
|
7
|
+
import type { BannerConfig } from "../extensions/startup-banner.ts";
|
|
8
|
+
|
|
9
|
+
export const VISUAL_PROFILES_SCHEMA = "gentle-pi.visual-profiles/v1";
|
|
10
|
+
const MAX_BYTES = 256_000;
|
|
11
|
+
const MAX_PROFILES = 64;
|
|
12
|
+
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
13
|
+
// O_NOFOLLOW hardens the open against a symlink swapped in between the lstat check below and
|
|
14
|
+
// the open call (TOCTOU) on platforms that support it. It is unavailable on Windows, where the
|
|
15
|
+
// pre-open lstat check is the only symlink guard and a race window remains: a symlink swapped in
|
|
16
|
+
// after the lstat check but before open would still be followed there.
|
|
17
|
+
const READ_FLAGS = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW : fsConstants.O_RDONLY;
|
|
18
|
+
|
|
19
|
+
export interface VisualProfileValues {
|
|
20
|
+
themeName: string;
|
|
21
|
+
animationPolicy: AnimationPolicy;
|
|
22
|
+
banner: BannerConfig;
|
|
23
|
+
visual: VisualSettings;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VisualProfile extends VisualProfileValues {
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Options {
|
|
31
|
+
gentlePiConfigHome?: string;
|
|
32
|
+
replace?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VisualProfilesResolution {
|
|
36
|
+
profiles: VisualProfile[];
|
|
37
|
+
source: "global_file" | "default";
|
|
38
|
+
malformed: boolean;
|
|
39
|
+
readError: boolean;
|
|
40
|
+
globalFile: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const record = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
44
|
+
|
|
45
|
+
const exact = (v: Record<string, unknown>, keys: string[]) => Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
|
|
46
|
+
|
|
47
|
+
function valid(v: unknown): v is VisualProfile {
|
|
48
|
+
if (!record(v) || !exact(v, ["name", "themeName", "animationPolicy", "banner", "visual"])) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const b = v.banner;
|
|
52
|
+
return (
|
|
53
|
+
typeof v.name === "string" &&
|
|
54
|
+
NAME.test(v.name) &&
|
|
55
|
+
typeof v.themeName === "string" &&
|
|
56
|
+
[...v.themeName].length > 0 &&
|
|
57
|
+
[...v.themeName].length <= 128 &&
|
|
58
|
+
v.themeName.trim() === v.themeName &&
|
|
59
|
+
!/[\\/\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(v.themeName) &&
|
|
60
|
+
Object.values(ANIMATION_POLICY).includes(v.animationPolicy as AnimationPolicy) &&
|
|
61
|
+
record(b) &&
|
|
62
|
+
exact(b, ["showRose", "showTextLogo", "color"]) &&
|
|
63
|
+
typeof b.showRose === "boolean" &&
|
|
64
|
+
typeof b.showTextLogo === "boolean" &&
|
|
65
|
+
["pink", "cyan", "yellow", "green"].includes(b.color as string) &&
|
|
66
|
+
isVisualSettings(v.visual)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseVisualProfilesFile(raw: string): VisualProfile[] | undefined {
|
|
71
|
+
if (Buffer.byteLength(raw) > MAX_BYTES) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const value: unknown = JSON.parse(raw);
|
|
76
|
+
if (
|
|
77
|
+
!record(value) ||
|
|
78
|
+
!exact(value, ["schema", "profiles"]) ||
|
|
79
|
+
value.schema !== VISUAL_PROFILES_SCHEMA ||
|
|
80
|
+
!Array.isArray(value.profiles) ||
|
|
81
|
+
value.profiles.length > MAX_PROFILES
|
|
82
|
+
) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const names = new Set<string>();
|
|
86
|
+
const profiles: VisualProfile[] = [];
|
|
87
|
+
for (const stored of value.profiles) {
|
|
88
|
+
// Legacy visual settings are normalized before the strict profile check.
|
|
89
|
+
const visual = record(stored) ? normalizeVisualSettings(stored.visual) : undefined;
|
|
90
|
+
const profile = visual && { ...stored, visual };
|
|
91
|
+
if (!valid(profile) || names.has(profile.name)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
names.add(profile.name);
|
|
95
|
+
profiles.push(profile);
|
|
96
|
+
}
|
|
97
|
+
return profiles;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readVisualProfiles(options: Options = {}): VisualProfilesResolution {
|
|
104
|
+
const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-profiles.json");
|
|
105
|
+
let fd: number | undefined;
|
|
106
|
+
try {
|
|
107
|
+
try {
|
|
108
|
+
// Platform-independent guard: reject a symlink (or other non-regular entry) before opening
|
|
109
|
+
// it, so the check applies even where O_NOFOLLOW is unavailable (Windows).
|
|
110
|
+
const lst = lstatSync(globalFile);
|
|
111
|
+
if (!lst.isFile()) {
|
|
112
|
+
return { profiles: [], source: "global_file", malformed: false, readError: true, globalFile };
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
116
|
+
return { profiles: [], source: "global_file", malformed: false, readError: true, globalFile };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
fd = openSync(globalFile, READ_FLAGS);
|
|
120
|
+
const stat = fstatSync(fd);
|
|
121
|
+
if (!stat.isFile()) {
|
|
122
|
+
return { profiles: [], source: "global_file", malformed: false, readError: true, globalFile };
|
|
123
|
+
}
|
|
124
|
+
if (stat.size > MAX_BYTES) {
|
|
125
|
+
return { profiles: [], source: "global_file", malformed: true, readError: false, globalFile };
|
|
126
|
+
}
|
|
127
|
+
const bytes = readFileSync(fd);
|
|
128
|
+
let profiles: VisualProfile[] | undefined;
|
|
129
|
+
if (bytes.length <= MAX_BYTES) {
|
|
130
|
+
try {
|
|
131
|
+
profiles = parseVisualProfilesFile(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
132
|
+
} catch {
|
|
133
|
+
/* Malformed UTF-8. */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { profiles: profiles ?? [], source: "global_file", malformed: profiles === undefined, readError: false, globalFile };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const missing = (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
139
|
+
return { profiles: [], source: missing ? "default" : "global_file", malformed: false, readError: !missing, globalFile };
|
|
140
|
+
} finally {
|
|
141
|
+
if (fd !== undefined) {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function existing(options: Options): VisualProfile[] {
|
|
148
|
+
const result = readVisualProfiles(options);
|
|
149
|
+
if (result.readError || result.malformed) {
|
|
150
|
+
throw new Error(`Cannot edit unreadable or malformed visual profiles: ${result.globalFile}`);
|
|
151
|
+
}
|
|
152
|
+
return result.profiles;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function write(profiles: VisualProfile[], options: Options): string {
|
|
156
|
+
const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
|
|
157
|
+
const path = join(home, "visual-profiles.json");
|
|
158
|
+
const data = `${JSON.stringify({ schema: VISUAL_PROFILES_SCHEMA, profiles })}\n`;
|
|
159
|
+
if (Buffer.byteLength(data) > MAX_BYTES) {
|
|
160
|
+
throw new RangeError("Visual profiles exceed 256 KB.");
|
|
161
|
+
}
|
|
162
|
+
mkdirSync(home, { recursive: true });
|
|
163
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
164
|
+
try {
|
|
165
|
+
writeFileSync(temporary, data, { flag: "wx", mode: 0o600 });
|
|
166
|
+
renameSync(temporary, path);
|
|
167
|
+
} finally {
|
|
168
|
+
try {
|
|
169
|
+
unlinkSync(temporary);
|
|
170
|
+
} catch {
|
|
171
|
+
/* Already renamed. */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return path;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function listVisualProfiles(options: Options = {}): string[] {
|
|
178
|
+
return existing(options)
|
|
179
|
+
.map(p => p.name)
|
|
180
|
+
.sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function getVisualProfile(name: string, options: Options = {}): VisualProfile | undefined {
|
|
184
|
+
if (!NAME.test(name)) {
|
|
185
|
+
throw new TypeError("Invalid visual profile name.");
|
|
186
|
+
}
|
|
187
|
+
const profile = existing(options).find(p => p.name === name);
|
|
188
|
+
return profile && structuredClone(profile);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function saveVisualProfile(name: string, values: VisualProfileValues, options: Options = {}): string {
|
|
192
|
+
const profile = { name, ...values };
|
|
193
|
+
if (!valid(profile)) {
|
|
194
|
+
throw new TypeError("Invalid visual profile name or settings.");
|
|
195
|
+
}
|
|
196
|
+
const profiles = existing(options);
|
|
197
|
+
const index = profiles.findIndex(p => p.name === name);
|
|
198
|
+
if (index >= 0 && !options.replace) {
|
|
199
|
+
throw new Error(`Visual profile ${name} already exists; specify replace: true.`);
|
|
200
|
+
}
|
|
201
|
+
if (index < 0 && profiles.length >= MAX_PROFILES) {
|
|
202
|
+
throw new RangeError("Too many visual profiles.");
|
|
203
|
+
}
|
|
204
|
+
if (index < 0) {
|
|
205
|
+
profiles.push(structuredClone(profile));
|
|
206
|
+
} else {
|
|
207
|
+
profiles[index] = structuredClone(profile);
|
|
208
|
+
}
|
|
209
|
+
return write(profiles, options);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function deleteVisualProfile(name: string, options: Options = {}): string {
|
|
213
|
+
if (!NAME.test(name)) {
|
|
214
|
+
throw new TypeError("Invalid visual profile name.");
|
|
215
|
+
}
|
|
216
|
+
const profiles = existing(options);
|
|
217
|
+
const index = profiles.findIndex(p => p.name === name);
|
|
218
|
+
if (index < 0) {
|
|
219
|
+
throw new Error(`Visual profile ${name} not found.`);
|
|
220
|
+
}
|
|
221
|
+
profiles.splice(index, 1);
|
|
222
|
+
return write(profiles, options);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function resetVisualProfiles(options: Options = {}): string {
|
|
226
|
+
existing(options);
|
|
227
|
+
return write([], options);
|
|
228
|
+
}
|