@nredd/pi-session-kit 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +167 -2
- package/dist/pi-stats-export.mjs +657 -0
- package/docs/stats-snapshots.md +122 -0
- package/package.json +45 -4
- package/src/child.ts +79 -0
- package/src/config.ts +64 -0
- package/src/diff/component.ts +214 -0
- package/src/diff/git.ts +289 -0
- package/src/diff/index.ts +65 -0
- package/src/diff/parse.ts +180 -0
- package/src/diff/view.ts +98 -0
- package/src/goal/index.ts +343 -0
- package/src/goal/state.ts +270 -0
- package/src/goal/subagents.ts +77 -0
- package/src/index.ts +31 -0
- package/src/loop/index.ts +151 -0
- package/src/loop/state.ts +107 -0
- package/src/monitor/README.md +12 -0
- package/src/monitor/coalescer.ts +75 -0
- package/src/monitor/index.ts +213 -0
- package/src/monitor/manager.ts +322 -0
- package/src/monitor/matcher.ts +113 -0
- package/src/monitor/messages.ts +93 -0
- package/src/monitor/output.ts +41 -0
- package/src/monitor/pidfiles.ts +410 -0
- package/src/monitor/process.ts +124 -0
- package/src/monitor/ring.ts +52 -0
- package/src/monitor/splitter.ts +57 -0
- package/src/monitor/types.ts +35 -0
- package/src/monitor/validate.ts +59 -0
- package/src/monitor/widget.ts +75 -0
- package/src/output.ts +25 -0
- package/src/rules/index.ts +94 -0
- package/src/rules/logic.ts +96 -0
- package/src/shared.ts +177 -0
- package/src/stats/aggregate.ts +287 -0
- package/src/stats/chart.ts +247 -0
- package/src/stats/cli.ts +50 -0
- package/src/stats/component.ts +496 -0
- package/src/stats/days.ts +46 -0
- package/src/stats/format.ts +49 -0
- package/src/stats/heatmap.ts +98 -0
- package/src/stats/index.ts +81 -0
- package/src/stats/library.ts +4 -0
- package/src/stats/load.ts +512 -0
- package/src/stats/palette.ts +22 -0
- package/src/stats/snapshot.ts +497 -0
- package/src/titles/aliases.ts +20 -0
- package/src/titles/index.ts +82 -0
- package/src/titles/logic.ts +160 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Offline stats and portable snapshots
|
|
2
|
+
|
|
3
|
+
Build the committed `dist/pi-stats-export.mjs` with `npm ci && npm run build:stats-export`.
|
|
4
|
+
The exact esbuild version and dependencies are locked; tests assert byte-identical rebuilds.
|
|
5
|
+
Copy only that file. Runtime is Node 22+ with builtins only, no Pi install, extension, private
|
|
6
|
+
repository, package install, auth or network access.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
node ARTIFACT export --agent-dir DIR --output OUT [--sessions-dir ROOT]
|
|
10
|
+
node ARTIFACT import --agent-dir DIR --input REPO
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`--agent-dir` defaults to `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. The session root defaults to
|
|
14
|
+
`PI_CODING_AGENT_SESSION_DIR`, then `DIR/sessions`; `--sessions-dir` takes precedence. History
|
|
15
|
+
already cached under former roots is retained, even after files disappear. Paths are resolved
|
|
16
|
+
locally and never exported. Run export before fetch/commit/push, then import the updated data
|
|
17
|
+
checkout. Git, locking concurrent sync runs, retrying pushes and network deadlines belong to the
|
|
18
|
+
sync helper, not this data-only artifact.
|
|
19
|
+
|
|
20
|
+
CLI results:
|
|
21
|
+
|
|
22
|
+
- Export: `{ "machineId": "<UUID>", "snapshotDir": "<OUT>/machines/<UUID>" }` on stdout.
|
|
23
|
+
- Import: `{ "imported": N, "retained": N, "problems": [], "importedAt": MS }` on stdout.
|
|
24
|
+
- Exactly one JSON object is emitted. Diagnostics go to stderr. Invalid commands, malformed local
|
|
25
|
+
scans, unwritable caches and publication failures exit nonzero. Partial/corrupt imports emit
|
|
26
|
+
a JSON result and exit nonzero, retaining every failed machine's last-good snapshot.
|
|
27
|
+
- A failed export never replaces the previous manifest. Its validated immutable shards may remain
|
|
28
|
+
unpublished. Export writes only its own machine directory; it never edits another machine.
|
|
29
|
+
|
|
30
|
+
Installation state, never synced:
|
|
31
|
+
|
|
32
|
+
- `DIR/pi-session-kit/machine.json`: persistent UUID v4 and label, created exclusively and
|
|
33
|
+
atomically on first use. Default label is `Pi machine`, not the hostname. A corrupt identity
|
|
34
|
+
fails rather than silently enrolling another machine. Copying an entire agent dir also copies
|
|
35
|
+
its installation identity: remove `machine.json` on a genuinely new installation before export.
|
|
36
|
+
- `DIR/pi-session-kit/stats-cache.json`: local parser cache v2, including file paths and compact
|
|
37
|
+
usage tuples. Version 1 entries for deleted files stay intact; existing files reparse even if
|
|
38
|
+
their mtime/size match. Strong identities are not invented for inaccessible legacy files.
|
|
39
|
+
- `DIR/pi-session-kit/stats-global.json`: one atomically replaced document containing validated
|
|
40
|
+
last-good machine snapshots, last successful import time and coverage problems. Imports do not
|
|
41
|
+
mutate local usage or installation identity. A wholly failed import does not advance the sync
|
|
42
|
+
time; a partial import records valid updates and marks coverage incomplete.
|
|
43
|
+
|
|
44
|
+
Snapshot version 1:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
machines/<UUID>/manifest.json
|
|
48
|
+
machines/<UUID>/<YYYY-MM>-<sha256>.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Manifest exact keys:
|
|
52
|
+
|
|
53
|
+
- `version`: `1`.
|
|
54
|
+
- `machineId`: persistent UUID v4, matching its directory.
|
|
55
|
+
- `label`: installation label, maximum 1024 characters. Not a session name.
|
|
56
|
+
- `exportedAt`: positive UTC Unix milliseconds.
|
|
57
|
+
- `shards`: unique month references, each exactly `{month, file, sha256, bytes}`. `file` MUST
|
|
58
|
+
equal `<month>-<sha256>.json`. No absolute paths, traversal or arbitrary filenames.
|
|
59
|
+
|
|
60
|
+
Shard exact keys: `{version, machineId, month, events, sessions}`. All shard JSON is canonical
|
|
61
|
+
`JSON.stringify` output (no indentation/newline), bounded to 128 MiB per file. Manifest hashes
|
|
62
|
+
cover exact UTF-8 shard bytes. Immutable content-addressed shards are published first, manifest
|
|
63
|
+
last; unreferenced old shard generations are deliberately retained and ignored. No cleanup races
|
|
64
|
+
with readers. Each manifest references exactly one shard per month.
|
|
65
|
+
|
|
66
|
+
Event exact keys:
|
|
67
|
+
|
|
68
|
+
- `id`: SHA-256 event fingerprint.
|
|
69
|
+
- `entryId`: SHA-256 of `entry:<preserved assistant entry ID>`, or `null` for legacy records.
|
|
70
|
+
- `sessionId`: opaque session ID.
|
|
71
|
+
- `ms`: original positive UTC Unix milliseconds, in the shard's UTC month.
|
|
72
|
+
- `provider`, `model`: raw physical attribution, each nonempty and at most 1024 characters.
|
|
73
|
+
- `input`, `output`, `cacheRead`, `cacheWrite`: nonnegative safe integers, not all zero.
|
|
74
|
+
- `cost`: finite nonnegative recorded notional cost.
|
|
75
|
+
|
|
76
|
+
The identified fingerprint is SHA-256 of the JSON array
|
|
77
|
+
`[entryId, ms, provider, model, input, output, cacheRead, cacheWrite]`.
|
|
78
|
+
Notional price changes do not create new events. Different entry IDs survive equal usage. Copied entries across forks/machines count once. Legacy
|
|
79
|
+
fingerprinting and matching use `[provider, model, ms, input, output, cacheRead, cacheWrite]`,
|
|
80
|
+
intentionally without cost to preserve the old tuple rule; identified counterparts supersede
|
|
81
|
+
legacy duplicates.
|
|
82
|
+
|
|
83
|
+
Session exact keys: `{id, startMs, endMs, child}`. Header IDs hash as `session:<header ID>`;
|
|
84
|
+
unidentified legacy sessions use a local path hash and therefore cannot always be matched across
|
|
85
|
+
machines. Session metadata is independent of event dedup, lives in the start timestamp's UTC
|
|
86
|
+
month, and merges time bounds by ID through the same helper for local and imported histories.
|
|
87
|
+
Children retain usage but do not contribute to session count.
|
|
88
|
+
Only sessions owning a retained event count. The oldest session owns copied events, independent
|
|
89
|
+
of machine read order. Every exported event references session metadata.
|
|
90
|
+
|
|
91
|
+
No message content, tool arguments/results, paths, session names, environment, credentials or
|
|
92
|
+
source objects are exported. Explicit projections, not object spreads, enforce the allowlist.
|
|
93
|
+
Raw attribution is retained to avoid display-sanitization collisions. Schema validators reject
|
|
94
|
+
unknown fields, unsupported versions, duplicate IDs, invalid counters/time/months, excluded test
|
|
95
|
+
providers, identity/digest/byte-size mismatches, symlink files and missing session metadata.
|
|
96
|
+
|
|
97
|
+
Import validates a whole machine before replacement, retaining last-good data for bad, missing
|
|
98
|
+
or older snapshots. Other valid machines still advance. Only the final validated global document
|
|
99
|
+
is atomically renamed, so `/stats` sees either the old or new complete cache, never a mixed import.
|
|
100
|
+
Producer updates are invisible until the producer exports AND the viewer imports. Coverage counts
|
|
101
|
+
known machines, not undiscovered installations; it is not proof that every machine has synced.
|
|
102
|
+
|
|
103
|
+
`/stats` freshly scans local sessions and combines them with this cache without network access.
|
|
104
|
+
Its UI labels cached coverage, successful import time, local-only/never-synced state and incomplete
|
|
105
|
+
coverage. Future calls are ignored in display totals, not removed from durable history. Statistics
|
|
106
|
+
are not subscription quota measurements or router inputs.
|
|
107
|
+
|
|
108
|
+
Harness API:
|
|
109
|
+
|
|
110
|
+
- `src/stats/library.ts` is a Pi-independent barrel for parser, aggregation and snapshot APIs.
|
|
111
|
+
- `parseSession`, `flatten`, `dedupCalls`, `hourlySeries`, `makeSnapshot`, `validateManifest`,
|
|
112
|
+
`validateShard`, `validateSnapshot`, `exportStats`, `importStats`, `loadCombined`, `coverageLabel`.
|
|
113
|
+
- `hourlySeries(calls, "5h" | "24h", now, keyOf?)` is deterministic, zero-filled, filters exact
|
|
114
|
+
rolling boundaries and labels partial edges. UTC buckets keep repeated DST hours separate;
|
|
115
|
+
viewer-local labels carry UTC offsets, including fractional-offset zones.
|
|
116
|
+
|
|
117
|
+
Limits: legacy no-ID copies can only use tuple dedup, legacy session metadata without header IDs
|
|
118
|
+
cannot reliably match across machines, and concurrent sync workflows must serialize writes to
|
|
119
|
+
one agent directory. The combined global cache is bounded to 128 MiB; a larger import fails
|
|
120
|
+
without replacing last-good data. There is no automatic discovery/network refresh or daemon. Reinitializing
|
|
121
|
+
both the local parser cache and source history permanently loses that machine's local history;
|
|
122
|
+
keep these installation files out of synced snapshots but do not routinely delete them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nredd/pi-session-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"publishConfig": {"access": "public"},
|
|
5
|
+
"description": "Session-scoped pi tools and commands, offline cross-machine usage stats and a portable privacy-only stats CLI. No daemon.",
|
|
6
|
+
"author": "nredd",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": ["src", "dist/pi-stats-export.mjs", "docs/stats-snapshots.md", "LICENSE", "README.md"],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/nredd/pi-harness.git",
|
|
13
|
+
"directory": "packages/session-kit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
21
|
+
"@earendil-works/pi-tui": "*",
|
|
22
|
+
"typebox": "*"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"lint": "biome check src/ test/",
|
|
26
|
+
"format": "biome check --write src/ test/",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"build:stats-export": "esbuild src/stats/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/pi-stats-export.mjs",
|
|
29
|
+
"test": "npm run build:stats-export && vitest run",
|
|
30
|
+
"check": "npm run lint && npm run typecheck && npm run test"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@biomejs/biome": "2.5.15",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
35
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
36
|
+
"@types/node": "26.6.5",
|
|
37
|
+
"esbuild": "0.28.2",
|
|
38
|
+
"typebox": "1.3.36",
|
|
39
|
+
"typescript": "7.0.2",
|
|
40
|
+
"vitest": "5.0.3"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./src/index.ts"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/child.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent child-session detection.
|
|
3
|
+
*
|
|
4
|
+
* `pi-subagents` runs children in-process and loads extensions into them, but its own child
|
|
5
|
+
* marker is a private `AsyncLocalStorage`, unreachable from another package. It names every child
|
|
6
|
+
* session `<agent type>#<first 8 chars of the agent id>` before `session_start` fires, so that
|
|
7
|
+
* name bootstraps a session-bound custom entry so renaming and reloading cannot lose identity.
|
|
8
|
+
* TODO: replace the name heuristic if `pi-subagents` ever exports a shared marker.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { TSchema } from "typebox";
|
|
13
|
+
|
|
14
|
+
const CHILD_NAME = /#[0-9a-f]{8}$/;
|
|
15
|
+
export const CHILD_ENTRY = "session-kit:child";
|
|
16
|
+
|
|
17
|
+
const identities = new WeakMap<object, { child: boolean }>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register once, at the beginning of a module factory, before its session rebuild hooks.
|
|
21
|
+
* Runtime APIs are unbound during factory loading; inspect only the active branch on startup.
|
|
22
|
+
* Include the session id because forks/fresh Plan sessions can inherit a parent's entries.
|
|
23
|
+
*/
|
|
24
|
+
export function registerChildIdentity(pi: ExtensionAPI): void {
|
|
25
|
+
if (identities.has(pi)) return;
|
|
26
|
+
const identity = { child: false };
|
|
27
|
+
identities.set(pi, identity);
|
|
28
|
+
pi.on("session_start", (_event, ctx) => {
|
|
29
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
30
|
+
const marked = ctx.sessionManager.getBranch().some((entry) => {
|
|
31
|
+
if (entry.type !== "custom" || entry.customType !== CHILD_ENTRY) return false;
|
|
32
|
+
const data = entry.data as { version?: unknown; sessionId?: unknown } | null;
|
|
33
|
+
return data?.version === 1 && data.sessionId === sessionId;
|
|
34
|
+
});
|
|
35
|
+
identity.child = marked || isChildSessionName(pi.getSessionName());
|
|
36
|
+
if (identity.child && !marked) pi.appendEntry(CHILD_ENTRY, { version: 1, sessionId });
|
|
37
|
+
});
|
|
38
|
+
pi.on("session_shutdown", () => {
|
|
39
|
+
identity.child = false;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isChildSessionName(name: string | undefined): boolean {
|
|
44
|
+
return name !== undefined && CHILD_NAME.test(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isChildSession(pi: Pick<ExtensionAPI, "getSessionName">): boolean {
|
|
48
|
+
return identities.get(pi)?.child === true || isChildSessionName(pi.getSessionName());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Withdraw a parent-only capability at child binding, not merely from the active set.
|
|
53
|
+
*
|
|
54
|
+
* Child scoping reactivates registered tools after `session_start`; deferred promotion can
|
|
55
|
+
* do so again later. Pi's public `hidden` exposure prevents both schema activation and nested
|
|
56
|
+
* execution. Keep call-time guards too, including after a child renames its session.
|
|
57
|
+
*/
|
|
58
|
+
export function registerParentTool<TParams extends TSchema, TDetails, TState>(
|
|
59
|
+
pi: ExtensionAPI,
|
|
60
|
+
tool: ToolDefinition<TParams, TDetails, TState>,
|
|
61
|
+
): void {
|
|
62
|
+
registerChildIdentity(pi);
|
|
63
|
+
const reason = `${tool.name} is not available in subagents`;
|
|
64
|
+
const guarded: ToolDefinition<TParams, TDetails, TState> = {
|
|
65
|
+
...tool,
|
|
66
|
+
async execute(...args) {
|
|
67
|
+
if (isChildSession(pi)) throw new Error(reason);
|
|
68
|
+
return tool.execute(...args);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
pi.registerTool(guarded);
|
|
72
|
+
pi.on("session_start", () => {
|
|
73
|
+
pi.registerTool(isChildSession(pi) ? { ...guarded, exposure: "hidden" } : guarded);
|
|
74
|
+
});
|
|
75
|
+
pi.on("tool_call", (event) => {
|
|
76
|
+
if (event.toolName === tool.name && isChildSession(pi)) return { block: true, reason };
|
|
77
|
+
return undefined;
|
|
78
|
+
});
|
|
79
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-feature switches for pi-session-kit, read from `~/.pi/agent/pi-session-kit.json`.
|
|
3
|
+
*
|
|
4
|
+
* Every feature defaults to on. A missing or malformed file yields the defaults; a malformed file
|
|
5
|
+
* is reported on stderr once so a typo never silently disables the kit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const FEATURES = ["titles", "rules", "goal", "loop", "monitor", "diff", "stats"] as const;
|
|
13
|
+
export type Feature = (typeof FEATURES)[number];
|
|
14
|
+
|
|
15
|
+
export interface KitConfig {
|
|
16
|
+
features: Record<Feature, boolean>;
|
|
17
|
+
/** Overrides the auto-title model, as `provider/id`. */
|
|
18
|
+
titleModel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configPath(agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent")): string {
|
|
22
|
+
return join(agentDir, "pi-session-kit.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultConfig(): KitConfig {
|
|
26
|
+
return { features: Object.fromEntries(FEATURES.map((f) => [f, true])) as Record<Feature, boolean> };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parse a config document, ignoring unknown keys and wrong-typed values. */
|
|
30
|
+
export function parseConfig(raw: unknown): KitConfig {
|
|
31
|
+
const config = defaultConfig();
|
|
32
|
+
if (typeof raw !== "object" || raw === null) return config;
|
|
33
|
+
const doc = raw as Record<string, unknown>;
|
|
34
|
+
for (const feature of FEATURES) {
|
|
35
|
+
const section = doc[feature];
|
|
36
|
+
if (typeof section === "object" && section !== null) {
|
|
37
|
+
const enabled = (section as Record<string, unknown>).enabled;
|
|
38
|
+
if (typeof enabled === "boolean") config.features[feature] = enabled;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const titles = doc.titles;
|
|
42
|
+
if (typeof titles === "object" && titles !== null) {
|
|
43
|
+
const model = (titles as Record<string, unknown>).model;
|
|
44
|
+
if (typeof model === "string" && model.includes("/")) config.titleModel = model;
|
|
45
|
+
}
|
|
46
|
+
return config;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadConfig(path = configPath()): KitConfig {
|
|
50
|
+
let text: string;
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(path, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultConfig();
|
|
55
|
+
console.error(`pi-session-kit: cannot read ${path}: ${(error as Error).message}`);
|
|
56
|
+
return defaultConfig();
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return parseConfig(JSON.parse(text));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error(`pi-session-kit: ${path} is not valid JSON (${(error as Error).message}); using defaults`);
|
|
62
|
+
return defaultConfig();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** The `/diff` overlay: file list on the left, scrollable themed patch on the right. Read-only. */
|
|
2
|
+
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type Component, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { classifyLines, type DiffFile, type FileStatus, type LineKind } from "./parse.ts";
|
|
6
|
+
import {
|
|
7
|
+
clamp,
|
|
8
|
+
clampScroll,
|
|
9
|
+
fitCols,
|
|
10
|
+
fitPathLeft,
|
|
11
|
+
layout,
|
|
12
|
+
listTop,
|
|
13
|
+
maxScroll,
|
|
14
|
+
pageSize,
|
|
15
|
+
sanitize,
|
|
16
|
+
window,
|
|
17
|
+
} from "./view.ts";
|
|
18
|
+
|
|
19
|
+
type ThemeLike = Pick<Theme, "fg" | "bg" | "bold">;
|
|
20
|
+
|
|
21
|
+
export interface DiffViewOptions {
|
|
22
|
+
files: DiffFile[];
|
|
23
|
+
label: string;
|
|
24
|
+
theme: ThemeLike;
|
|
25
|
+
/** Current terminal height. */
|
|
26
|
+
rows: () => number;
|
|
27
|
+
requestRender: () => void;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
notices?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STATUS_LETTER: Record<FileStatus, string> = {
|
|
33
|
+
modified: "M",
|
|
34
|
+
added: "A",
|
|
35
|
+
deleted: "D",
|
|
36
|
+
renamed: "R",
|
|
37
|
+
copied: "C",
|
|
38
|
+
binary: "B",
|
|
39
|
+
mode: "T",
|
|
40
|
+
untracked: "?",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const HELP = [
|
|
44
|
+
" j/k scroll n/p file g/G top/bottom PgUp/PgDn </> pan q close",
|
|
45
|
+
" j/k n/p g/G PgUp/PgDn q",
|
|
46
|
+
" j/k n/p q",
|
|
47
|
+
" q",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
interface Row {
|
|
51
|
+
text: string;
|
|
52
|
+
kind: LineKind | "notice";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class DiffView implements Component {
|
|
56
|
+
private selected = 0;
|
|
57
|
+
private readonly scroll: number[];
|
|
58
|
+
private hscroll = 0;
|
|
59
|
+
private listScroll = 0;
|
|
60
|
+
private bodyHeight = 1;
|
|
61
|
+
private readonly rowCache = new Map<number, { kinds: LineKind[]; plain: string[] }>();
|
|
62
|
+
|
|
63
|
+
constructor(private readonly o: DiffViewOptions) {
|
|
64
|
+
this.scroll = o.files.map(() => 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
invalidate(): void {
|
|
68
|
+
// Nothing themed is cached: every render rebuilds colours from the live theme.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- state -------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
get selectedIndex(): number {
|
|
74
|
+
return this.selected;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get scrollOffset(): number {
|
|
78
|
+
return this.scroll[this.selected] ?? 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private rowsFor(index: number): Row[] {
|
|
82
|
+
const file = this.o.files[index];
|
|
83
|
+
if (!file) return [];
|
|
84
|
+
let cached = this.rowCache.get(index);
|
|
85
|
+
if (!cached) {
|
|
86
|
+
cached = { kinds: classifyLines(file), plain: file.lines.map((l) => sanitize(l)) };
|
|
87
|
+
this.rowCache.set(index, cached);
|
|
88
|
+
}
|
|
89
|
+
const rows: Row[] = cached.plain.map((text, i) => ({ text, kind: cached?.kinds[i] ?? "context" }));
|
|
90
|
+
if (file.notice) rows.push({ text: `... ${file.notice}`, kind: "notice" });
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private total(): number {
|
|
95
|
+
const file = this.o.files[this.selected];
|
|
96
|
+
return file ? file.lines.length + (file.notice ? 1 : 0) : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private setScroll(value: number): void {
|
|
100
|
+
this.scroll[this.selected] = clampScroll(value, this.total(), this.bodyHeight);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private selectFile(index: number): void {
|
|
104
|
+
this.selected = clamp(index, 0, Math.max(0, this.o.files.length - 1));
|
|
105
|
+
this.hscroll = 0;
|
|
106
|
+
this.setScroll(this.scrollOffset);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- input -------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
handleInput(data: string): void {
|
|
112
|
+
const off = this.scrollOffset;
|
|
113
|
+
const page = pageSize(this.bodyHeight);
|
|
114
|
+
if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
115
|
+
this.o.onClose();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (data === "j" || matchesKey(data, Key.down)) this.setScroll(off + 1);
|
|
119
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.setScroll(off - 1);
|
|
120
|
+
else if (matchesKey(data, Key.pageDown) || data === " ") this.setScroll(off + page);
|
|
121
|
+
else if (matchesKey(data, Key.pageUp)) this.setScroll(off - page);
|
|
122
|
+
else if (data === "g" || matchesKey(data, Key.home)) this.setScroll(0);
|
|
123
|
+
else if (data === "G" || matchesKey(data, Key.shift("g")) || matchesKey(data, Key.end)) {
|
|
124
|
+
this.setScroll(maxScroll(this.total(), this.bodyHeight));
|
|
125
|
+
} else if (data === "n") this.selectFile(this.selected + 1);
|
|
126
|
+
else if (data === "p") this.selectFile(this.selected - 1);
|
|
127
|
+
else if (data === "l" || matchesKey(data, Key.right)) this.hscroll += 8;
|
|
128
|
+
else if (data === "h" || matchesKey(data, Key.left)) this.hscroll = Math.max(0, this.hscroll - 8);
|
|
129
|
+
else return;
|
|
130
|
+
this.o.requestRender();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- render ------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
render(width: number): string[] {
|
|
136
|
+
const w = Math.max(1, Math.floor(width));
|
|
137
|
+
const rows = Math.max(3, Math.floor(this.o.rows()));
|
|
138
|
+
this.bodyHeight = Math.max(1, rows - 2);
|
|
139
|
+
this.setScroll(this.scrollOffset);
|
|
140
|
+
const lay = layout(w);
|
|
141
|
+
const out: string[] = [this.title(w)];
|
|
142
|
+
|
|
143
|
+
const patch = this.rowsFor(this.selected);
|
|
144
|
+
const off = this.scrollOffset;
|
|
145
|
+
this.listScroll = listTop(this.listScroll, this.selected, this.o.files.length, this.bodyHeight);
|
|
146
|
+
const sep = this.o.theme.fg("borderMuted", "│");
|
|
147
|
+
|
|
148
|
+
for (let y = 0; y < this.bodyHeight; y++) {
|
|
149
|
+
const right = this.patchCell(patch[off + y], lay.right);
|
|
150
|
+
if (lay.left === 0) {
|
|
151
|
+
out.push(right);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push(this.listCell(this.listScroll + y, lay.left) + sep + right);
|
|
155
|
+
}
|
|
156
|
+
out.push(this.help(w, patch.length, off));
|
|
157
|
+
return out.map((line) => truncateToWidth(line, w, ""));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private title(width: number): string {
|
|
161
|
+
const files = this.o.files;
|
|
162
|
+
const add = files.reduce((n, f) => n + f.additions, 0);
|
|
163
|
+
const del = files.reduce((n, f) => n + f.deletions, 0);
|
|
164
|
+
const t = this.o.theme;
|
|
165
|
+
const plain = ` /diff ${sanitize(this.o.label)} ${files.length} file${files.length === 1 ? "" : "s"} +${add} -${del}`;
|
|
166
|
+
const extra = this.o.notices?.length ? ` ! ${sanitize(this.o.notices.join("; "))}` : "";
|
|
167
|
+
return t.bold(t.fg("accent", fitCols(plain + extra, width)));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private listCell(index: number, width: number): string {
|
|
171
|
+
const file = this.o.files[index];
|
|
172
|
+
if (!file) return " ".repeat(width);
|
|
173
|
+
const t = this.o.theme;
|
|
174
|
+
const letter = STATUS_LETTER[file.status];
|
|
175
|
+
const color =
|
|
176
|
+
file.status === "added" || file.status === "untracked"
|
|
177
|
+
? "toolDiffAdded"
|
|
178
|
+
: file.status === "deleted"
|
|
179
|
+
? "toolDiffRemoved"
|
|
180
|
+
: "accent";
|
|
181
|
+
const path = fitPathLeft(sanitize(file.path), Math.max(0, width - 3));
|
|
182
|
+
if (index === this.selected) return t.bg("selectedBg", t.bold(fitCols(` ${letter} ${path}`, width)));
|
|
183
|
+
// colour only the status letter; the path stays plain
|
|
184
|
+
return fitCols(` ${t.fg(color, letter)} ${path}`, width);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private patchCell(row: Row | undefined, width: number): string {
|
|
188
|
+
if (!row) return " ".repeat(Math.max(0, width));
|
|
189
|
+
const t = this.o.theme;
|
|
190
|
+
const text = window(row.text, this.hscroll, width);
|
|
191
|
+
switch (row.kind) {
|
|
192
|
+
case "added":
|
|
193
|
+
return t.fg("toolDiffAdded", text);
|
|
194
|
+
case "removed":
|
|
195
|
+
return t.fg("toolDiffRemoved", text);
|
|
196
|
+
case "hunk":
|
|
197
|
+
return t.fg("accent", text);
|
|
198
|
+
case "meta":
|
|
199
|
+
return t.fg("muted", text);
|
|
200
|
+
case "notice":
|
|
201
|
+
return t.fg("warning", text);
|
|
202
|
+
default:
|
|
203
|
+
return t.fg("toolDiffContext", text);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private help(width: number, total: number, off: number): string {
|
|
208
|
+
const t = this.o.theme;
|
|
209
|
+
const pos = ` ${this.selected + 1}/${this.o.files.length} ${total === 0 ? 0 : off + 1}-${Math.min(total, off + this.bodyHeight)}/${total} `;
|
|
210
|
+
const keys = HELP.find((h) => h.length + pos.length <= width) ?? "";
|
|
211
|
+
const gap = Math.max(0, width - keys.length - pos.length);
|
|
212
|
+
return t.fg("muted", fitCols(`${keys}${" ".repeat(gap)}${pos}`, width));
|
|
213
|
+
}
|
|
214
|
+
}
|