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,431 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
|
+
import { formatElapsed } from "./agents-widget.ts";
|
|
3
|
+
import { formatCost, formatTokens } from "./shell-bar.ts";
|
|
4
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
5
|
+
import { aggregateStats, daysBetween, shiftDay, STATS_RANGE, STATS_SCOPE, weekdayOf, type CurrentSessionStats, type SessionRecord, type StatsRange, type StatsScope, type StatsSummary, type TokenTotals } from "./stats-collector.ts";
|
|
6
|
+
|
|
7
|
+
// Gentle Stats overlay: historical usage from local Pi sessions in three
|
|
8
|
+
// tabs. Overview is a weekday-by-week heatmap plus headline figures, Models
|
|
9
|
+
// splits tokens and cost per model, Session describes the live session. All
|
|
10
|
+
// colors come from theme roles so the panel follows the active Gentle theme.
|
|
11
|
+
|
|
12
|
+
export interface StatsViewTheme {
|
|
13
|
+
fg(role: string, text: string): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StatsViewDeps {
|
|
17
|
+
theme: StatsViewTheme;
|
|
18
|
+
rows: number | (() => number);
|
|
19
|
+
cwd: string;
|
|
20
|
+
now(): number;
|
|
21
|
+
dayKey?: (ms: number) => string;
|
|
22
|
+
load(): Promise<readonly SessionRecord[]>;
|
|
23
|
+
current(): CurrentSessionStats;
|
|
24
|
+
onClose(): void;
|
|
25
|
+
requestRender(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const STATS_TAB = {
|
|
29
|
+
OVERVIEW: "overview",
|
|
30
|
+
MODELS: "models",
|
|
31
|
+
SESSION: "session",
|
|
32
|
+
} as const;
|
|
33
|
+
export type StatsTab = (typeof STATS_TAB)[keyof typeof STATS_TAB];
|
|
34
|
+
|
|
35
|
+
const TABS: ReadonlyArray<{ id: StatsTab; label: string }> = [
|
|
36
|
+
{ id: STATS_TAB.OVERVIEW, label: "Overview" },
|
|
37
|
+
{ id: STATS_TAB.MODELS, label: "Models" },
|
|
38
|
+
{ id: STATS_TAB.SESSION, label: "Session" },
|
|
39
|
+
];
|
|
40
|
+
const RANGES: readonly StatsRange[] = [STATS_RANGE.ALL, STATS_RANGE.WEEK, STATS_RANGE.MONTH];
|
|
41
|
+
const RANGE_LABEL: Record<StatsRange, string> = { [STATS_RANGE.ALL]: "All time", [STATS_RANGE.WEEK]: "Last 7 days", [STATS_RANGE.MONTH]: "Last 30 days" };
|
|
42
|
+
const SCOPE_LABEL: Record<StatsScope, string> = { [STATS_SCOPE.ALL]: "All projects", [STATS_SCOPE.PROJECT]: "This project" };
|
|
43
|
+
|
|
44
|
+
const ROLE = {
|
|
45
|
+
FRAME: "border",
|
|
46
|
+
TITLE: "customMessageLabel",
|
|
47
|
+
ACTIVE: "accent",
|
|
48
|
+
INACTIVE: "muted",
|
|
49
|
+
LABEL: "muted",
|
|
50
|
+
VALUE: "text",
|
|
51
|
+
META: "dim",
|
|
52
|
+
KEY: "accent",
|
|
53
|
+
KEY_TEXT: "dim",
|
|
54
|
+
HEAT: "accent",
|
|
55
|
+
HEAT_IDLE: "borderMuted",
|
|
56
|
+
BAR_EMPTY: "borderMuted",
|
|
57
|
+
ADDED: "toolDiffAdded",
|
|
58
|
+
REMOVED: "toolDiffRemoved",
|
|
59
|
+
ERROR: "error",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
const TITLE = "✿ Stats";
|
|
63
|
+
const CLOSE_BUTTON = "[× Close]";
|
|
64
|
+
const CLOSE_BUTTON_NARROW = "[×]";
|
|
65
|
+
const FOOTNOTE = "Local Pi sessions on this machine · subagent runs not included";
|
|
66
|
+
const LOADING = "Loading local sessions…";
|
|
67
|
+
const KEYS = [
|
|
68
|
+
["tab", "view", "tab"],
|
|
69
|
+
["r", "range", "range"],
|
|
70
|
+
["s", "scope", "scope"],
|
|
71
|
+
["q", "close", "close"],
|
|
72
|
+
] as const;
|
|
73
|
+
const HINT_GAP = " ";
|
|
74
|
+
// The frame draws "│ " before the fitted content of every body row.
|
|
75
|
+
const CONTENT_OFFSET = 2;
|
|
76
|
+
const LABEL_WIDTH = 16;
|
|
77
|
+
const TWO_COLUMN_MIN = 64;
|
|
78
|
+
const HEATMAP_WEEKS = 52;
|
|
79
|
+
const HEATMAP_LABEL_WIDTH = 4;
|
|
80
|
+
const HEAT_CELL = 2;
|
|
81
|
+
const HEAT_IDLE = "·";
|
|
82
|
+
const HEAT_SHADES = ["░", "▒", "▓", "█"];
|
|
83
|
+
const WEEKDAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""];
|
|
84
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
85
|
+
const BAR_FILLED = "█";
|
|
86
|
+
const BAR_EMPTY = "░";
|
|
87
|
+
// Don Quixote runs to roughly 430k words, about 500k tokens: a playful yardstick, not a measurement.
|
|
88
|
+
const DON_QUIXOTE_TOKENS = 500_000;
|
|
89
|
+
|
|
90
|
+
type Action = `tab:${StatsTab}` | "tab" | "range" | "scope" | "close";
|
|
91
|
+
|
|
92
|
+
interface Span {
|
|
93
|
+
row: number;
|
|
94
|
+
start: number;
|
|
95
|
+
end: number;
|
|
96
|
+
action: Action;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type LoadState = { kind: "loading" } | { kind: "ready"; sessions: readonly SessionRecord[] } | { kind: "error"; message: string };
|
|
100
|
+
|
|
101
|
+
function rule(length: number): string {
|
|
102
|
+
return "─".repeat(Math.max(0, length));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fit(text: string, width: number): string {
|
|
106
|
+
const clipped = truncateToWidth(text, Math.max(0, width), "…");
|
|
107
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function padLeft(text: string, width: number): string {
|
|
111
|
+
return " ".repeat(Math.max(0, width - visibleWidth(text))) + text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function monthDay(day: string): string {
|
|
115
|
+
const [, month, date] = day.split("-").map(Number);
|
|
116
|
+
return `${MONTHS[month - 1]} ${date}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function plural(count: number, noun: string): string {
|
|
120
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function percent(share: number): string {
|
|
124
|
+
return `${Math.round(share * 100)}%`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function tokenBreakdown(totals: TokenTotals): string {
|
|
128
|
+
return `in ${formatTokens(totals.input)} · out ${formatTokens(totals.output)} · cache read ${formatTokens(totals.cacheRead)} · cache write ${formatTokens(totals.cacheWrite)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function literaryComparison(tokens: number): string {
|
|
132
|
+
const ratio = tokens / DON_QUIXOTE_TOKENS;
|
|
133
|
+
if (ratio >= 1) return `✿ That's ~${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)}x the tokens in Don Quixote.`;
|
|
134
|
+
const share = ratio * 100;
|
|
135
|
+
return `✿ That's ~${share >= 10 ? Math.round(share) : share.toFixed(1)}% of the tokens in Don Quixote.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class StatsView {
|
|
139
|
+
private readonly deps: StatsViewDeps;
|
|
140
|
+
private tab: StatsTab = STATS_TAB.OVERVIEW;
|
|
141
|
+
private range: StatsRange = STATS_RANGE.ALL;
|
|
142
|
+
private scope: StatsScope = STATS_SCOPE.ALL;
|
|
143
|
+
private state: LoadState = { kind: "loading" };
|
|
144
|
+
private summary: { key: string; stats: StatsSummary } | undefined;
|
|
145
|
+
private spans: Span[] = [];
|
|
146
|
+
private layout: { width: number; height: number } | undefined;
|
|
147
|
+
private hovered: Action | undefined;
|
|
148
|
+
private closed = false;
|
|
149
|
+
|
|
150
|
+
constructor(deps: StatsViewDeps) {
|
|
151
|
+
this.deps = deps;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Load (or reload) the session records; never rejects. */
|
|
155
|
+
async load(): Promise<void> {
|
|
156
|
+
this.state = { kind: "loading" };
|
|
157
|
+
this.summary = undefined;
|
|
158
|
+
try {
|
|
159
|
+
this.state = { kind: "ready", sessions: await this.deps.load() };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.state = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
|
162
|
+
}
|
|
163
|
+
this.summary = undefined;
|
|
164
|
+
if (!this.closed) this.deps.requestRender();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
handleInput(data: string): void {
|
|
168
|
+
if (this.closed) return;
|
|
169
|
+
if (data === "q" || matchesKey(data, Key.escape)) return this.close();
|
|
170
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) this.shiftTab(1);
|
|
171
|
+
else if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) this.shiftTab(-1);
|
|
172
|
+
else if (data === "1" || data === "2" || data === "3") this.setTab(TABS[Number(data) - 1].id);
|
|
173
|
+
else if (data === "r") this.cycleRange();
|
|
174
|
+
else if (data === "s") this.toggleScope();
|
|
175
|
+
else return;
|
|
176
|
+
this.deps.requestRender();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
180
|
+
if (this.closed) return undefined;
|
|
181
|
+
const layout = this.layout;
|
|
182
|
+
const span = layout && event.width === layout.width && event.height === layout.height
|
|
183
|
+
? this.spans.find((candidate) => candidate.row === event.y && event.x >= candidate.start && event.x < candidate.end)
|
|
184
|
+
: undefined;
|
|
185
|
+
if (event.type === "move" && event.button === "none") {
|
|
186
|
+
if (span?.action === this.hovered) return span ? { handled: true } : undefined;
|
|
187
|
+
this.hovered = span?.action;
|
|
188
|
+
return { handled: true, render: true };
|
|
189
|
+
}
|
|
190
|
+
if (event.type !== "click" || event.button !== "left" || !span) return undefined;
|
|
191
|
+
this.run(span.action);
|
|
192
|
+
return { handled: true, render: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
invalidate(): void {
|
|
196
|
+
this.layout = undefined;
|
|
197
|
+
this.spans = [];
|
|
198
|
+
this.hovered = undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
dispose(): void {
|
|
202
|
+
this.closed = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
render(width: number): string[] {
|
|
206
|
+
const height = Math.max(0, typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows);
|
|
207
|
+
const theme = this.deps.theme;
|
|
208
|
+
const inner = Math.max(0, width - 2);
|
|
209
|
+
const content = Math.max(0, width - 4);
|
|
210
|
+
this.spans = [];
|
|
211
|
+
const frame = (line: string) => `${theme.fg(ROLE.FRAME, "│")} ${fit(line, content)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
212
|
+
const body = this.body(content);
|
|
213
|
+
const room = Math.max(0, height - 5);
|
|
214
|
+
const shown = body.slice(0, room);
|
|
215
|
+
while (shown.length < room) shown.push("");
|
|
216
|
+
const lines = [
|
|
217
|
+
this.top(width),
|
|
218
|
+
frame(this.tabsLine(content, 1)),
|
|
219
|
+
...shown.map(frame),
|
|
220
|
+
frame(theme.fg(ROLE.META, FOOTNOTE)),
|
|
221
|
+
frame(this.footer(content, height - 2)),
|
|
222
|
+
theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`),
|
|
223
|
+
].slice(0, height);
|
|
224
|
+
this.layout = { width, height: lines.length };
|
|
225
|
+
return lines;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private top(width: number): string {
|
|
229
|
+
const theme = this.deps.theme;
|
|
230
|
+
const close = width >= 60 ? CLOSE_BUTTON : CLOSE_BUTTON_NARROW;
|
|
231
|
+
const fill = width - 8 - visibleWidth(TITLE) - close.length;
|
|
232
|
+
if (fill < 1) return theme.fg(ROLE.FRAME, "╭─ ") + fit(theme.fg(ROLE.TITLE, TITLE), Math.max(0, width - 5)) + theme.fg(ROLE.FRAME, " ╮");
|
|
233
|
+
this.spans.push({ row: 0, start: width - 3 - close.length, end: width - 3, action: "close" });
|
|
234
|
+
return theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, TITLE) + theme.fg(ROLE.FRAME, ` ${rule(fill)} `) +
|
|
235
|
+
paintHoverable(theme, close, this.hovered === "close", ROLE.KEY) + theme.fg(ROLE.FRAME, " ─╮");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private tabsLine(width: number, row: number): string {
|
|
239
|
+
const theme = this.deps.theme;
|
|
240
|
+
let plain = "";
|
|
241
|
+
let painted = "";
|
|
242
|
+
const spans: Span[] = [];
|
|
243
|
+
for (const { id, label } of TABS) {
|
|
244
|
+
if (plain) { plain += " "; painted += " "; }
|
|
245
|
+
const text = id === this.tab ? `[${label}]` : ` ${label} `;
|
|
246
|
+
const action: Action = `tab:${id}`;
|
|
247
|
+
spans.push({ row, start: CONTENT_OFFSET + plain.length, end: CONTENT_OFFSET + plain.length + text.length, action });
|
|
248
|
+
plain += text;
|
|
249
|
+
painted += paintHoverable(theme, text, this.hovered === action, id === this.tab ? ROLE.ACTIVE : ROLE.INACTIVE);
|
|
250
|
+
}
|
|
251
|
+
if (plain.length <= width) this.spans.push(...spans);
|
|
252
|
+
const filters = this.tab === STATS_TAB.SESSION ? "This session" : `${RANGE_LABEL[this.range]} · ${SCOPE_LABEL[this.scope]}`;
|
|
253
|
+
const gap = width - plain.length - filters.length;
|
|
254
|
+
return gap >= 2 ? painted + " ".repeat(gap) + theme.fg(ROLE.META, filters) : painted;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private footer(width: number, row: number): string {
|
|
258
|
+
const theme = this.deps.theme;
|
|
259
|
+
const plainWidth = KEYS.reduce((sum, [key, label]) => sum + key.length + 1 + label.length, 0) + HINT_GAP.length * (KEYS.length - 1);
|
|
260
|
+
let cursor = CONTENT_OFFSET;
|
|
261
|
+
const parts: string[] = [];
|
|
262
|
+
for (const [key, label, action] of KEYS) {
|
|
263
|
+
const text = `${key} ${label}`;
|
|
264
|
+
if (plainWidth <= width) this.spans.push({ row, start: cursor, end: cursor + text.length, action });
|
|
265
|
+
cursor += text.length + HINT_GAP.length;
|
|
266
|
+
parts.push(this.hovered === action ? paintHoverable(theme, text, true) : `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`);
|
|
267
|
+
}
|
|
268
|
+
return parts.join(HINT_GAP);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private body(width: number): string[] {
|
|
272
|
+
if (this.tab === STATS_TAB.SESSION) return ["", ...this.sessionTab(width)];
|
|
273
|
+
const theme = this.deps.theme;
|
|
274
|
+
if (this.state.kind === "loading") return ["", theme.fg(ROLE.META, LOADING)];
|
|
275
|
+
if (this.state.kind === "error") return ["", theme.fg(ROLE.ERROR, `Could not read local sessions: ${this.state.message}`)];
|
|
276
|
+
const stats = this.stats(this.state.sessions);
|
|
277
|
+
if (stats.sessions === 0) {
|
|
278
|
+
const hint = this.range === STATS_RANGE.ALL && this.scope === STATS_SCOPE.ALL ? "." : " for this view · press r or s to widen it.";
|
|
279
|
+
return ["", theme.fg(ROLE.META, `No usage recorded yet${hint}`)];
|
|
280
|
+
}
|
|
281
|
+
return ["", ...(this.tab === STATS_TAB.MODELS ? this.modelsTab(stats, width) : this.overviewTab(stats, width))];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private stats(sessions: readonly SessionRecord[]): StatsSummary {
|
|
285
|
+
const key = `${this.range}|${this.scope}`;
|
|
286
|
+
if (this.summary?.key !== key) {
|
|
287
|
+
this.summary = { key, stats: aggregateStats(sessions, { range: this.range, scope: this.scope, cwd: this.deps.cwd, now: this.deps.now(), dayKey: this.deps.dayKey }) };
|
|
288
|
+
}
|
|
289
|
+
return this.summary.stats;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private overviewTab(stats: StatsSummary, width: number): string[] {
|
|
293
|
+
const theme = this.deps.theme;
|
|
294
|
+
const pairs: Array<[string, string]> = [
|
|
295
|
+
["Favorite model", stats.models[0]?.model ?? "—"],
|
|
296
|
+
["Total tokens", formatTokens(stats.totals.total)],
|
|
297
|
+
["Sessions", String(stats.sessions)],
|
|
298
|
+
["Longest session", stats.longestSession ? formatElapsed(stats.longestSession.durationMs) : "—"],
|
|
299
|
+
["Active days", `${stats.activeDays}/${stats.daysInRange}`],
|
|
300
|
+
["Longest streak", plural(stats.longestStreak, "day")],
|
|
301
|
+
["Most active day", stats.mostActiveDay ? monthDay(stats.mostActiveDay.day) : "—"],
|
|
302
|
+
["Current streak", plural(stats.currentStreak, "day")],
|
|
303
|
+
];
|
|
304
|
+
return [
|
|
305
|
+
...this.heatmap(stats, width),
|
|
306
|
+
"",
|
|
307
|
+
...this.grid(pairs, width),
|
|
308
|
+
this.field("Tokens", tokenBreakdown(stats.totals)),
|
|
309
|
+
this.field("Cost", formatCost(stats.totals.cost)),
|
|
310
|
+
"",
|
|
311
|
+
theme.fg(ROLE.ACTIVE, literaryComparison(stats.totals.total)),
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private heatmap(stats: StatsSummary, width: number): string[] {
|
|
316
|
+
const theme = this.deps.theme;
|
|
317
|
+
const { today, firstDay } = stats;
|
|
318
|
+
const mondayOffset = (day: string) => (weekdayOf(day) + 6) % 7;
|
|
319
|
+
const lastMonday = shiftDay(today, -mondayOffset(today));
|
|
320
|
+
const needed = this.range === STATS_RANGE.ALL ? HEATMAP_WEEKS : Math.ceil((daysBetween(shiftDay(firstDay, -mondayOffset(firstDay)), today) + 1) / 7);
|
|
321
|
+
const columns = Math.max(1, Math.min(needed, Math.floor((width - HEATMAP_LABEL_WIDTH) / HEAT_CELL)));
|
|
322
|
+
const mondays = Array.from({ length: columns }, (_, column) => shiftDay(lastMonday, -7 * (columns - 1 - column)));
|
|
323
|
+
const max = Math.max(0, ...Object.values(stats.days));
|
|
324
|
+
const months = Array.from({ length: HEATMAP_LABEL_WIDTH + columns * HEAT_CELL }, () => " ");
|
|
325
|
+
let previousMonth = "";
|
|
326
|
+
let freeFrom = 0;
|
|
327
|
+
mondays.forEach((monday, column) => {
|
|
328
|
+
const month = monday.slice(0, 7);
|
|
329
|
+
const at = HEATMAP_LABEL_WIDTH + column * HEAT_CELL;
|
|
330
|
+
if (month !== previousMonth && at >= freeFrom && at + 3 <= months.length) {
|
|
331
|
+
MONTHS[Number(month.slice(5)) - 1].split("").forEach((char, offset) => { months[at + offset] = char; });
|
|
332
|
+
freeFrom = at + 4;
|
|
333
|
+
}
|
|
334
|
+
previousMonth = month;
|
|
335
|
+
});
|
|
336
|
+
const rows = WEEKDAY_LABELS.map((label, weekday) => {
|
|
337
|
+
const cells = mondays.map((monday) => {
|
|
338
|
+
const day = shiftDay(monday, weekday);
|
|
339
|
+
if (day > today || (this.range !== STATS_RANGE.ALL && day < firstDay)) return " ".repeat(HEAT_CELL);
|
|
340
|
+
const tokens = stats.days[day];
|
|
341
|
+
if (tokens === undefined) return theme.fg(ROLE.HEAT_IDLE, HEAT_IDLE) + " ";
|
|
342
|
+
const level = max > 0 ? Math.min(HEAT_SHADES.length, Math.max(1, Math.ceil((HEAT_SHADES.length * tokens) / max))) : 1;
|
|
343
|
+
return theme.fg(ROLE.HEAT, HEAT_SHADES[level - 1]) + " ";
|
|
344
|
+
});
|
|
345
|
+
return theme.fg(ROLE.META, label.padEnd(HEATMAP_LABEL_WIDTH)) + cells.join("");
|
|
346
|
+
});
|
|
347
|
+
const legend = " ".repeat(HEATMAP_LABEL_WIDTH) + theme.fg(ROLE.META, "Less ") + [theme.fg(ROLE.HEAT_IDLE, HEAT_IDLE), ...HEAT_SHADES.map((shade) => theme.fg(ROLE.HEAT, shade))].join(" ") + theme.fg(ROLE.META, " More");
|
|
348
|
+
return [theme.fg(ROLE.META, months.join("").trimEnd()), ...rows, legend];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private modelsTab(stats: StatsSummary, width: number): string[] {
|
|
352
|
+
const theme = this.deps.theme;
|
|
353
|
+
const nameWidth = Math.min(28, Math.max(5, ...stats.models.map((model) => model.model.length)));
|
|
354
|
+
const tail = 4 + 2 + 7 + 2 + 8 + 2 + 5;
|
|
355
|
+
const barWidth = Math.max(4, Math.min(24, width - nameWidth - tail - 2));
|
|
356
|
+
const header = theme.fg(ROLE.LABEL, `${fit("Model", nameWidth)} ${fit("Share", barWidth + 5)} ${padLeft("Tokens", 7)} ${padLeft("Cost", 8)} ${padLeft("Msgs", 5)}`);
|
|
357
|
+
const rows = stats.models.map((model) => {
|
|
358
|
+
const filled = Math.round(model.share * barWidth);
|
|
359
|
+
const bar = theme.fg(ROLE.ACTIVE, BAR_FILLED.repeat(filled)) + theme.fg(ROLE.BAR_EMPTY, BAR_EMPTY.repeat(barWidth - filled));
|
|
360
|
+
return `${theme.fg(ROLE.VALUE, fit(model.model, nameWidth))} ${bar} ${padLeft(percent(model.share), 4)} ${padLeft(formatTokens(model.tokens), 7)} ${padLeft(formatCost(model.cost), 8)} ${padLeft(String(model.messages), 5)}`;
|
|
361
|
+
});
|
|
362
|
+
return [header, ...rows, "", theme.fg(ROLE.META, `${formatTokens(stats.totals.total)} tokens · ${formatCost(stats.totals.cost)} across ${plural(stats.sessions, "session")}`)];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private sessionTab(_width: number): string[] {
|
|
366
|
+
const theme = this.deps.theme;
|
|
367
|
+
const current = this.deps.current();
|
|
368
|
+
const lines = current.lines
|
|
369
|
+
? `${theme.fg(ROLE.ADDED, `+${current.lines.added}`)} ${theme.fg(ROLE.REMOVED, `−${current.lines.removed}`)}`
|
|
370
|
+
: theme.fg(ROLE.META, "no captured file changes");
|
|
371
|
+
return [
|
|
372
|
+
this.field("Model", current.model ?? "—"),
|
|
373
|
+
this.field("Cost", formatCost(current.totals.cost)),
|
|
374
|
+
this.field("Duration", formatElapsed(current.durationMs)),
|
|
375
|
+
this.field("Messages", String(current.messages)),
|
|
376
|
+
this.field("Tokens", `${formatTokens(current.totals.total)} total · ${tokenBreakdown(current.totals)}`),
|
|
377
|
+
this.field("Lines", lines),
|
|
378
|
+
"",
|
|
379
|
+
theme.fg(ROLE.META, "Wall time since the session's first entry; range and scope do not apply here."),
|
|
380
|
+
];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private field(label: string, value: string): string {
|
|
384
|
+
return this.deps.theme.fg(ROLE.LABEL, label.padEnd(LABEL_WIDTH)) + this.deps.theme.fg(ROLE.VALUE, value);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private grid(pairs: ReadonlyArray<[string, string]>, width: number): string[] {
|
|
388
|
+
if (width < TWO_COLUMN_MIN) return pairs.map(([label, value]) => this.field(label, value));
|
|
389
|
+
const column = Math.floor(width / 2);
|
|
390
|
+
const rows: string[] = [];
|
|
391
|
+
for (let index = 0; index < pairs.length; index += 2) {
|
|
392
|
+
const [left, right] = [pairs[index], pairs[index + 1]];
|
|
393
|
+
rows.push(fit(this.field(left[0], left[1]), column) + (right ? this.field(right[0], right[1]) : ""));
|
|
394
|
+
}
|
|
395
|
+
return rows;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private run(action: Action): void {
|
|
399
|
+
if (action === "close") return this.close();
|
|
400
|
+
if (action === "tab") this.shiftTab(1);
|
|
401
|
+
else if (action === "range") this.cycleRange();
|
|
402
|
+
else if (action === "scope") this.toggleScope();
|
|
403
|
+
else this.setTab(action.slice("tab:".length) as StatsTab);
|
|
404
|
+
this.deps.requestRender();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private shiftTab(delta: number): void {
|
|
408
|
+
const index = TABS.findIndex((tab) => tab.id === this.tab);
|
|
409
|
+
this.setTab(TABS[(index + delta + TABS.length) % TABS.length].id);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private setTab(tab: StatsTab): void {
|
|
413
|
+
this.tab = tab;
|
|
414
|
+
this.hovered = undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private cycleRange(): void {
|
|
418
|
+
this.range = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private toggleScope(): void {
|
|
422
|
+
this.scope = this.scope === STATS_SCOPE.ALL ? STATS_SCOPE.PROJECT : STATS_SCOPE.ALL;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private close(): void {
|
|
426
|
+
if (this.closed) return;
|
|
427
|
+
this.closed = true;
|
|
428
|
+
this.spans = [];
|
|
429
|
+
this.deps.onClose();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { colorToRgb, parseColor } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 256_000;
|
|
6
|
+
const HEX = /^#[0-9a-fA-F]{6}$/;
|
|
7
|
+
const REFERENCE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
8
|
+
|
|
9
|
+
/** Read only the installed source supplied by Pi; never use the active theme as a fallback. */
|
|
10
|
+
export function sourcePalettePreview(name: string, sourcePath: string | undefined): { title: string; sample: string } {
|
|
11
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) || !sourcePath || !isAbsolute(sourcePath)) throw new Error("Installed theme source unavailable.");
|
|
12
|
+
// Pi's installed themes can live behind platform symlinks. Resolve the
|
|
13
|
+
// Pi-supplied path, then open the target without following another symlink.
|
|
14
|
+
const path = realpathSync(sourcePath);
|
|
15
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
16
|
+
let text: string;
|
|
17
|
+
try {
|
|
18
|
+
const stat = fstatSync(fd);
|
|
19
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("Theme source unavailable or too large.");
|
|
20
|
+
const bytes = readFileSync(fd);
|
|
21
|
+
if (bytes.length > MAX_BYTES) throw new Error("Theme source too large.");
|
|
22
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
23
|
+
} finally { closeSync(fd); }
|
|
24
|
+
const document: unknown = JSON.parse(text);
|
|
25
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Invalid theme source.");
|
|
26
|
+
const { name: sourceName, colors, vars } = document as Record<string, unknown>;
|
|
27
|
+
if (sourceName !== name || !colors || typeof colors !== "object" || Array.isArray(colors)) throw new Error("Theme source does not match installed theme.");
|
|
28
|
+
if (vars !== undefined && (!vars || typeof vars !== "object" || Array.isArray(vars))) throw new Error("Invalid theme variables.");
|
|
29
|
+
const palette = colors as Record<string, unknown>;
|
|
30
|
+
const variables = (vars ?? {}) as Record<string, unknown>;
|
|
31
|
+
const escape = (role: "accent" | "text", background: boolean): string => {
|
|
32
|
+
let value: unknown = palette[role];
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
while (typeof value === "string" && REFERENCE.test(value)) {
|
|
35
|
+
if (seen.has(value) || !Object.hasOwn(variables, value)) throw new Error("Invalid theme color reference.");
|
|
36
|
+
seen.add(value);
|
|
37
|
+
value = variables[value];
|
|
38
|
+
}
|
|
39
|
+
const prefix = background ? 48 : 38;
|
|
40
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255) return `\x1b[${prefix};5;${value}m`;
|
|
41
|
+
if (typeof value === "string" && HEX.test(value)) return `\x1b[${prefix};2;${parseInt(value.slice(1, 3), 16)};${parseInt(value.slice(3, 5), 16)};${parseInt(value.slice(5, 7), 16)}m`;
|
|
42
|
+
// Pi 0.99.1's built-in palettes use OKHSL. Delegate the exact color
|
|
43
|
+
// grammar, finite/range checks and conversion to the public host utility;
|
|
44
|
+
// do not broaden this source-only preview to arbitrary CSS colors.
|
|
45
|
+
if (typeof value === "string" && value.startsWith("okhsl(")) {
|
|
46
|
+
const { r, g, b } = colorToRgb(parseColor(value));
|
|
47
|
+
return `\x1b[${prefix};2;${r};${g};${b}m`;
|
|
48
|
+
}
|
|
49
|
+
throw new Error("Invalid theme palette color.");
|
|
50
|
+
};
|
|
51
|
+
return { title: `${name} · source palette`, sample: `${escape("accent", true)} \x1b[0m ${escape("text", false)}Aa sample text\x1b[0m` };
|
|
52
|
+
}
|