dsh-hashline-edit 0.2.0 → 0.2.2

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.
Files changed (109) hide show
  1. package/README.md +59 -55
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +7225 -6445
  4. package/lib/types/dsh/contracts.d.ts +3 -12
  5. package/lib/types/dsh/fs.d.ts +0 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/host-compat.d.ts +78 -7
  8. package/lib/types/dsh/index.d.ts +7 -17
  9. package/lib/types/dsh/metrics/events.d.ts +1 -1
  10. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  11. package/lib/types/dsh/sandbox.d.ts +2 -2
  12. package/lib/types/dsh/shell-hook.d.ts +1 -1
  13. package/lib/types/dsh/tools/edit.d.ts +10 -0
  14. package/lib/types/dsh/tools/read.d.ts +3 -3
  15. package/lib/types/src/domain-errors.d.ts +2 -16
  16. package/lib/types/src/edit-tool.d.ts +41 -0
  17. package/lib/types/src/edit-undo.d.ts +2 -11
  18. package/lib/types/src/file-content/index.d.ts +1 -1
  19. package/lib/types/src/file-content/preview.d.ts +1 -1
  20. package/lib/types/src/hash-store.d.ts +2 -2
  21. package/lib/types/src/hashline/alphabet.d.ts +16 -1
  22. package/lib/types/src/hashline/hash-identity.d.ts +10 -4
  23. package/lib/types/src/mutation-engine/engine.d.ts +31 -0
  24. package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
  25. package/lib/types/src/mutation-engine/types.d.ts +50 -40
  26. package/lib/types/src/payload-contract.d.ts +135 -0
  27. package/lib/types/src/served-session/serve-render.d.ts +0 -6
  28. package/lib/types/src/undo-store.d.ts +1 -17
  29. package/package.json +21 -10
  30. package/prompts/edit-guidelines.md +10 -0
  31. package/prompts/edit-snippet.md +1 -0
  32. package/prompts/edit.md +1 -0
  33. package/prompts/read-guidelines.md +4 -4
  34. package/prompts/read.md +1 -1
  35. package/prompts/undo-last-edit-guidelines.md +2 -2
  36. package/prompts/undo-last-edit-snippet.md +1 -1
  37. package/prompts/undo-last-edit.md +1 -1
  38. package/src/bash-classifier.ts +1626 -0
  39. package/src/bash-conversion/decide.ts +812 -0
  40. package/src/bash-display-transform.ts +50 -0
  41. package/src/bash-multifile-config.ts +239 -0
  42. package/src/bash-view-config.ts +158 -0
  43. package/src/cut-repair.ts +11 -77
  44. package/src/domain-errors.ts +3 -35
  45. package/src/edit-normalize.ts +8 -0
  46. package/src/edit-render.ts +161 -0
  47. package/src/edit-tool.ts +118 -0
  48. package/src/edit-undo.ts +25 -379
  49. package/src/edit.ts +136 -0
  50. package/src/file-content/index.ts +1 -1
  51. package/src/file-content/preview.ts +1 -1
  52. package/src/file-content/served-header.ts +1 -1
  53. package/src/hash-store.ts +1 -18
  54. package/src/hashline/alphabet.ts +42 -1
  55. package/src/hashline/hash-identity.ts +12 -3
  56. package/src/integrations/pi-lens/command.ts +712 -0
  57. package/src/integrations/pi-lens/config.ts +148 -0
  58. package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
  59. package/src/integrations/pi-lens/io-bridge.ts +112 -0
  60. package/src/integrations/pi-lens/settings-overlay.ts +272 -0
  61. package/src/lifecycle-hooks/index.ts +1992 -0
  62. package/src/lifecycle-hooks/types.ts +64 -0
  63. package/src/mutated-files.ts +75 -0
  64. package/src/mutation-engine/engine.ts +239 -0
  65. package/src/mutation-engine/index.ts +17 -0
  66. package/src/mutation-engine/pipeline.ts +27 -198
  67. package/src/mutation-engine/types.ts +57 -39
  68. package/src/mutation-queue.ts +1 -1
  69. package/src/payload-contract.ts +685 -0
  70. package/src/preview-controller.ts +84 -0
  71. package/src/prompts.ts +43 -10
  72. package/src/read.ts +865 -0
  73. package/src/search-hook.ts +41 -0
  74. package/src/served-session/serve-render.ts +2 -2
  75. package/src/store-config.ts +73 -0
  76. package/src/store-janitor-warning.ts +169 -0
  77. package/src/store-janitor.ts +539 -0
  78. package/src/store-kernel/settings.ts +233 -0
  79. package/src/store.ts +154 -0
  80. package/src/tui-presenter.ts +134 -0
  81. package/src/undo-store.ts +5 -74
  82. package/src/write-hook.ts +130 -0
  83. package/lib/types/dsh/hide.d.ts +0 -78
  84. package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
  85. package/lib/types/src/apply-patch/commit.d.ts +0 -49
  86. package/lib/types/src/apply-patch/grammar.d.ts +0 -29
  87. package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
  88. package/lib/types/src/apply-patch/plan.d.ts +0 -50
  89. package/lib/types/src/apply-patch/render.d.ts +0 -51
  90. package/lib/types/src/apply-patch/resolve.d.ts +0 -129
  91. package/lib/types/src/apply-patch/tool.d.ts +0 -54
  92. package/lib/types/src/apply-patch/types.d.ts +0 -362
  93. package/lib/types/src/fs-lifecycle.d.ts +0 -44
  94. package/lib/types/src/host-utils.d.ts +0 -63
  95. package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
  96. package/prompts/apply-patch-guidelines.md +0 -10
  97. package/prompts/apply-patch.md +0 -1
  98. package/src/apply-patch/commit.ts +0 -190
  99. package/src/apply-patch/grammar.ts +0 -620
  100. package/src/apply-patch/index.ts +0 -17
  101. package/src/apply-patch/lifecycle.ts +0 -195
  102. package/src/apply-patch/plan.ts +0 -800
  103. package/src/apply-patch/render.ts +0 -137
  104. package/src/apply-patch/resolve.ts +0 -470
  105. package/src/apply-patch/tool.ts +0 -257
  106. package/src/apply-patch/types.ts +0 -387
  107. package/src/fs-lifecycle.ts +0 -201
  108. package/src/host-utils.ts +0 -224
  109. package/src/mutation-engine/edit-request.ts +0 -39
@@ -0,0 +1,233 @@
1
+ /**
2
+ * SAFETY: store-kernel/settings — the janitor settings ladder: read, resolve per key, name the rung.
3
+ *
4
+ * WHY: this lives in the kernel rather than beside the writer because the resolved settings are what the
5
+ * WHY: maintenance pass runs on: cadence, batch width and enable/disable are open-gate policy, and a
6
+ * WHY: resolver that drifts from the store's own knobs would throttle the wrong sweep. The file's shape
7
+ * WHY: (keys, scopes, the atomic preserving write) stays in `store-config.ts`; this module only reads.
8
+ * WHY: Precedence is PER KEY — env, then project file, then global file, then the default — and a file
9
+ * WHY: that exists but cannot be used warns through `onWarn` while the values fall through.
10
+ */
11
+ import {
12
+ isRecord,
13
+ readScopeConfig,
14
+ type ConfigOnWarn,
15
+ type ConfigRung,
16
+ type ConfigScope,
17
+ } from "../json-config.js";
18
+
19
+ export type StoreJanitorMode = "on" | "off";
20
+
21
+ /** Environment overrides; each wins over both files for its OWN key. */
22
+ export const STORE_JANITOR_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR";
23
+ export const STORE_JANITOR_INTERVAL_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR_INTERVAL_MS";
24
+ export const STORE_JANITOR_BATCH_ENV_VAR = "PI_BETTER_EDIT_STORE_JANITOR_BATCH_SIZE";
25
+
26
+ /**
27
+ * WHY a day: the committed design in `src/hash-store.ts` bounds the sweep at about once a day, and
28
+ * this interval is the only thing that decides how often an open may pay for it.
29
+ */
30
+ export const STORE_JANITOR_INTERVAL_DEFAULT_MS = 24 * 60 * 60 * 1000;
31
+
32
+ /** WHY 64: the snapshot store already deletes in statement batches of that width (`STAT_BATCH`). */
33
+ export const STORE_JANITOR_BATCH_DEFAULT = 64;
34
+
35
+ /**
36
+ * One resolved setting: the value the store will use, and the rung of the ladder that supplied it.
37
+ * NOT exported on its own — the keyed shape below is the interface a caller reads, and a bare pair
38
+ * of fields has nothing to say without the key it belongs to.
39
+ */
40
+ interface ResolvedStoreJanitorSetting<T> {
41
+ readonly value: T;
42
+ readonly source: ConfigRung;
43
+ }
44
+
45
+ /**
46
+ * Every janitor setting with its rung, keyed by the same three names `StoreJanitorSettings` uses, so
47
+ * a caller that needs to say WHERE a value came from reads the ladder's answer instead of guessing
48
+ * it from the two files (“the project file does not have the key, so the global one must have won”
49
+ * is a second ladder, and a wrong one as soon as a value is outside its accepted shape).
50
+ */
51
+ export interface ResolvedStoreJanitorSettings {
52
+ readonly mode: ResolvedStoreJanitorSetting<StoreJanitorMode>;
53
+ readonly intervalMs: ResolvedStoreJanitorSetting<number>;
54
+ readonly batchSize: ResolvedStoreJanitorSetting<number>;
55
+ }
56
+
57
+ export interface StoreJanitorSettings {
58
+ readonly mode: StoreJanitorMode;
59
+ readonly intervalMs: number;
60
+ readonly batchSize: number;
61
+ }
62
+
63
+ /** The settings ONE scope's file carries — every key optional, because the ladder resolves per key. */
64
+ export type StoreJanitorScopeSettings = Partial<StoreJanitorSettings>;
65
+
66
+ /** The values an unconfigured store gets. */
67
+ export const STORE_JANITOR_DEFAULTS: StoreJanitorSettings = {
68
+ mode: "on",
69
+ intervalMs: STORE_JANITOR_INTERVAL_DEFAULT_MS,
70
+ batchSize: STORE_JANITOR_BATCH_DEFAULT,
71
+ };
72
+
73
+ // WHY: one place maps a setting to its file key, so the read path and the write path cannot spell a
74
+ // WHY: key differently — the failure a second hand-written literal would introduce.
75
+ const STORE_JANITOR_KEYS = {
76
+ mode: "janitor",
77
+ intervalMs: "janitorIntervalMs",
78
+ batchSize: "janitorBatchSize",
79
+ } as const satisfies Record<keyof StoreJanitorSettings, string>;
80
+
81
+ /**
82
+ * The key each setting is read from and written to under `store`, for a caller that has to NAME one
83
+ * — the `/pi-better-edit` status text labels a key with the same spelling the ladder reads it by,
84
+ * instead of a second copy of the literal that can drift from this one.
85
+ */
86
+ export const STORE_JANITOR_KEY_NAMES: Readonly<Record<keyof StoreJanitorSettings, string>> =
87
+ STORE_JANITOR_KEYS;
88
+
89
+ export function isStoreJanitorMode(value: unknown): value is StoreJanitorMode {
90
+ return value === "on" || value === "off";
91
+ }
92
+
93
+ /**
94
+ * A whole number of milliseconds at least 1: a fractional, non-finite, zero or negative interval
95
+ * cannot express a cadence, and admitting one would silently turn every open into a sweep.
96
+ */
97
+ export function isIntervalMs(value: unknown): value is number {
98
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
99
+ }
100
+
101
+ /** A whole number of rows at least 1: a batch of zero rows deletes nothing and reports success. */
102
+ export function isBatchSize(value: unknown): value is number {
103
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
104
+ }
105
+
106
+ /** A trimmed environment value, or `undefined` when the variable is unset. */
107
+ function envText(name: string): string | undefined {
108
+ const raw = process.env[name];
109
+ return raw === undefined ? undefined : raw.trim();
110
+ }
111
+
112
+ function readEnvMode(): StoreJanitorMode | undefined {
113
+ const mode = envText(STORE_JANITOR_ENV_VAR)?.toLowerCase();
114
+ return mode !== undefined && isStoreJanitorMode(mode) ? mode : undefined;
115
+ }
116
+
117
+ function readEnvIntervalMs(): number | undefined {
118
+ const raw = envText(STORE_JANITOR_INTERVAL_ENV_VAR);
119
+ if (raw === undefined) return undefined;
120
+ const value = Number(raw);
121
+ return isIntervalMs(value) ? value : undefined;
122
+ }
123
+
124
+ function readEnvBatchSize(): number | undefined {
125
+ const raw = envText(STORE_JANITOR_BATCH_ENV_VAR);
126
+ if (raw === undefined) return undefined;
127
+ const value = Number(raw);
128
+ return isBatchSize(value) ? value : undefined;
129
+ }
130
+
131
+ /** The keys one scope's file actually carries, read once so a bad file warns once per scope. */
132
+ function readScopeSettings(
133
+ scope: ConfigScope,
134
+ cwd: string,
135
+ onWarn?: ConfigOnWarn,
136
+ ): StoreJanitorScopeSettings {
137
+ const config = readScopeConfig(scope, cwd, onWarn);
138
+ if (config === undefined) return {};
139
+ const store = config.store;
140
+ if (!isRecord(store)) return {};
141
+ const mode = store[STORE_JANITOR_KEYS.mode];
142
+ const intervalMs = store[STORE_JANITOR_KEYS.intervalMs];
143
+ const batchSize = store[STORE_JANITOR_KEYS.batchSize];
144
+ // WHY: with `exactOptionalPropertyTypes` an unusable key is OMITTED rather than present as
145
+ // WHY: `undefined`, so "this scope said nothing about it" is one state, not two.
146
+ return {
147
+ ...(isStoreJanitorMode(mode) ? { mode } : {}),
148
+ ...(isIntervalMs(intervalMs) ? { intervalMs } : {}),
149
+ ...(isBatchSize(batchSize) ? { batchSize } : {}),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Both scopes' raw settings, each scope present only when its file carries at least one accepted
155
+ * key. Pure read: never creates the directory or the file, never throws. A warning names each file
156
+ * that exists but could not be used.
157
+ */
158
+ export function readStoreJanitorConfig(
159
+ cwd: string,
160
+ onWarn?: ConfigOnWarn,
161
+ ): { project?: StoreJanitorScopeSettings; global?: StoreJanitorScopeSettings } {
162
+ const project = readScopeSettings("project", cwd, onWarn);
163
+ const global = readScopeSettings("global", cwd, onWarn);
164
+ return {
165
+ ...(Object.keys(project).length > 0 ? { project } : {}),
166
+ ...(Object.keys(global).length > 0 ? { global } : {}),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * The first rung that answered, with its own name: the ladder's three ORDERS are one comparison made
172
+ * three times, so the rung labels and the precedence order cannot drift apart as two literals.
173
+ */
174
+ function fromRung<T>(
175
+ env: T | undefined,
176
+ project: T | undefined,
177
+ global: T | undefined,
178
+ fallback: T,
179
+ ): ResolvedStoreJanitorSetting<T> {
180
+ if (env !== undefined) return { value: env, source: "env" };
181
+ if (project !== undefined) return { value: project, source: "project" };
182
+ if (global !== undefined) return { value: global, source: "global" };
183
+ return { value: fallback, source: "default" };
184
+ }
185
+
186
+ /**
187
+ * The effective janitor settings WITH the rung each one came from. Precedence is PER KEY — env, then
188
+ * project, then global, then the default — so a file that tunes one knob does not have to restate the
189
+ * others, and a caller that reports the settings can name the rung it should blame for each one.
190
+ * Never throws. This is the ladder itself; `resolveStoreJanitorSettings` is its value half.
191
+ */
192
+ export function resolveStoreJanitorSettingsWithSources(
193
+ cwd: string,
194
+ onWarn?: ConfigOnWarn,
195
+ ): ResolvedStoreJanitorSettings {
196
+ const config = readStoreJanitorConfig(cwd, onWarn);
197
+ return {
198
+ mode: fromRung(
199
+ readEnvMode(),
200
+ config.project?.mode,
201
+ config.global?.mode,
202
+ STORE_JANITOR_DEFAULTS.mode,
203
+ ),
204
+ intervalMs: fromRung(
205
+ readEnvIntervalMs(),
206
+ config.project?.intervalMs,
207
+ config.global?.intervalMs,
208
+ STORE_JANITOR_DEFAULTS.intervalMs,
209
+ ),
210
+ batchSize: fromRung(
211
+ readEnvBatchSize(),
212
+ config.project?.batchSize,
213
+ config.global?.batchSize,
214
+ STORE_JANITOR_DEFAULTS.batchSize,
215
+ ),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * The effective janitor settings, without their provenance. Precedence is PER KEY — env, then
221
+ * project, then global, then the default. Never throws.
222
+ */
223
+ export function resolveStoreJanitorSettings(
224
+ cwd: string,
225
+ onWarn?: ConfigOnWarn,
226
+ ): StoreJanitorSettings {
227
+ const resolved = resolveStoreJanitorSettingsWithSources(cwd, onWarn);
228
+ return {
229
+ mode: resolved.mode.value,
230
+ intervalMs: resolved.intervalMs.value,
231
+ batchSize: resolved.batchSize.value,
232
+ };
233
+ }
package/src/store.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { withBusyRetry as hashWithBusyRetry } from "./store-kernel/index.js";
3
+
4
+ interface SnapshotStore {
5
+ get(path: string, checksum: string, lineCount: number): string[] | undefined;
6
+ put(
7
+ path: string,
8
+ checksum: string,
9
+ lineCount: number,
10
+ hashes: string[],
11
+ updatedAt?: number,
12
+ ): void;
13
+ delete(path: string): void;
14
+ allHashes(): Array<{ path: string; hashes: string }>;
15
+ allPaths(): Array<{ path: string }>;
16
+ }
17
+
18
+ // SAFETY: large-class — cohesive store owns DB and cache as single owner; split would scatter invariants.
19
+ export class SQLiteSnapshotStore implements SnapshotStore {
20
+ constructor(private readonly db: DatabaseSync) {}
21
+
22
+ get(path: string, checksum: string, lineCount: number): string[] | undefined {
23
+ const row = this.db
24
+ .prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?")
25
+ .get(path, checksum, lineCount) as { hashes?: string } | undefined;
26
+ if (!row?.hashes) return undefined;
27
+ try {
28
+ const parsed = JSON.parse(row.hashes) as unknown;
29
+ if (Array.isArray(parsed) && parsed.every((h) => typeof h === "string")) {
30
+ return parsed as string[];
31
+ }
32
+ return undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ put(
39
+ path: string,
40
+ checksum: string,
41
+ lineCount: number,
42
+ hashes: string[],
43
+ updatedAt: number = Date.now(),
44
+ ): void {
45
+ const stmt = this.db.prepare(
46
+ "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
47
+ "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
48
+ );
49
+ hashWithBusyRetry(() => stmt.run(path, checksum, lineCount, JSON.stringify(hashes), updatedAt));
50
+ }
51
+
52
+ delete(path: string): void {
53
+ const stmt = this.db.prepare("DELETE FROM snapshots WHERE path = ?");
54
+ hashWithBusyRetry(() => stmt.run(path));
55
+ }
56
+
57
+ allHashes(): Array<{ path: string; hashes: string }> {
58
+ const rows = this.db.prepare("SELECT path, hashes FROM snapshots").all() as Array<{
59
+ path: string;
60
+ hashes: string;
61
+ }>;
62
+ return rows;
63
+ }
64
+
65
+ allPaths(): Array<{ path: string }> {
66
+ const rows = this.db
67
+ .prepare(
68
+ "SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM file_undo UNION SELECT path FROM served",
69
+ )
70
+ .all() as Array<{ path: string }>;
71
+ return rows;
72
+ }
73
+ }
74
+
75
+ type SnapshotRow = {
76
+ checksum: string;
77
+ lineCount: number;
78
+ hashes: string;
79
+ updatedAt: number;
80
+ };
81
+
82
+ // SAFETY: large-class — in-memory store mirrors SQLite shape for test isolation; cohesive and not split.
83
+ export class MemorySnapshotStore implements SnapshotStore {
84
+ private readonly data = new Map<string, SnapshotRow>();
85
+ private readonly extraPaths = new Set<string>();
86
+
87
+ get(path: string, checksum: string, lineCount: number): string[] | undefined {
88
+ const row = this.data.get(path);
89
+ if (!row) return undefined;
90
+ if (row.checksum !== checksum || row.lineCount !== lineCount) return undefined;
91
+ try {
92
+ const parsed = JSON.parse(row.hashes) as unknown;
93
+ if (Array.isArray(parsed) && parsed.every((h) => typeof h === "string")) {
94
+ return parsed as string[];
95
+ }
96
+ return undefined;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ put(
103
+ path: string,
104
+ checksum: string,
105
+ lineCount: number,
106
+ hashes: string[],
107
+ updatedAt: number = Date.now(),
108
+ ): void {
109
+ this.data.set(path, {
110
+ checksum,
111
+ lineCount,
112
+ hashes: JSON.stringify(hashes),
113
+ updatedAt,
114
+ });
115
+ }
116
+
117
+ delete(path: string): void {
118
+ this.data.delete(path);
119
+ }
120
+
121
+ allHashes(): Array<{ path: string; hashes: string }> {
122
+ const out: Array<{ path: string; hashes: string }> = [];
123
+ for (const [path, row] of this.data) {
124
+ out.push({ path, hashes: row.hashes });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ allPaths(): Array<{ path: string }> {
130
+ const seen = new Set<string>();
131
+ const out: Array<{ path: string }> = [];
132
+ for (const p of this.data.keys()) {
133
+ seen.add(p);
134
+ out.push({ path: p });
135
+ }
136
+ for (const p of this.extraPaths) {
137
+ if (!seen.has(p)) out.push({ path: p });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ __seedPath(path: string): void {
143
+ this.extraPaths.add(path);
144
+ }
145
+
146
+ __getRow(path: string): SnapshotRow | undefined {
147
+ return this.data.get(path);
148
+ }
149
+
150
+ clear(): void {
151
+ this.data.clear();
152
+ this.extraPaths.clear();
153
+ }
154
+ }
@@ -0,0 +1,134 @@
1
+ /** SAFETY: TuiPresenter — Framework adapter owning the pi TUI boundary. Deep module EditTool never imports pi-tui; all Text/Markdown, DebouncedPreview wiring, and SAFETY casts live here. Two adapters justify the seam: TuiPresenter in prod, Headless in tests. Graded surface: public createTuiPresenter(preview) returns renderCall/renderResult ready for pi's ToolDefinition. */
2
+
3
+ import { Markdown, Text } from "@earendil-works/pi-tui";
4
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import type { TSchema } from "typebox";
6
+ import { DebouncedPreview, PREVIEW_DEBOUNCE_MS } from "./preview-controller.js";
7
+ import {
8
+ buildAppliedText,
9
+ mkMdTheme,
10
+ fmtCall,
11
+ fmtResultMd,
12
+ getPreviewInput,
13
+ getResultText,
14
+ isApplied,
15
+ type RPreview,
16
+ type RRState,
17
+ } from "./edit-render.js";
18
+ import type { EditDetails } from "./edit-response.js";
19
+
20
+ type TuiToolDef = ToolDefinition<TSchema, EditDetails, RRState> & {
21
+ renderShell?: "default" | "self";
22
+ };
23
+
24
+ export function reuseText(context: unknown, content: string): Text {
25
+ const ctx = context as { lastComponent: unknown };
26
+ const t = ctx.lastComponent instanceof Text ? ctx.lastComponent : new Text("", 0, 0);
27
+ t.setText(content);
28
+ return t;
29
+ }
30
+
31
+ export function reuseMarkdown(context: unknown, content: string, theme: unknown): Markdown {
32
+ const ctx = context as { lastComponent: unknown };
33
+ const m =
34
+ ctx.lastComponent instanceof Markdown
35
+ ? ctx.lastComponent
36
+ : new Markdown("", 0, 0, mkMdTheme(theme as never));
37
+ m.setText(content);
38
+ return m;
39
+ }
40
+
41
+ function isErrorContext(ctx: unknown): boolean {
42
+ // SAFETY: pi TUI context isError is untyped at boundary — cast isolated to adapter, validated by render error path
43
+ return (ctx as unknown as { isError: boolean }).isError;
44
+ }
45
+
46
+ function makeRenderCall(preview: DebouncedPreview) {
47
+ return (args: unknown, theme: unknown, context: unknown) => {
48
+ const ctx = context as {
49
+ lastComponent: unknown;
50
+ state: unknown;
51
+ expanded: unknown;
52
+ };
53
+ // SAFETY: pi TUI renderCall expects untyped context — cast isolates to adapter, validated by pi's runtime context shape
54
+ preview.renderCall(ctx as unknown as Parameters<DebouncedPreview["renderCall"]>[0], args);
55
+ const text = (ctx.lastComponent as Text | undefined) ?? new Text("", 0, 0);
56
+ // SAFETY: pi context carries untyped state/expanded — cast to RRState/boolean narrowed by getPreviewInput and theme contract
57
+ text.setText(
58
+ fmtCall(
59
+ getPreviewInput(args),
60
+ (ctx as unknown as { state: RRState }).state as RRState,
61
+ (ctx as unknown as { expanded: boolean }).expanded,
62
+ theme as never,
63
+ ),
64
+ );
65
+ return text;
66
+ };
67
+ }
68
+
69
+ function makeRenderResult(preview: DebouncedPreview) {
70
+ return (result: unknown, opts: { isPartial: boolean }, theme: unknown, context: unknown) => {
71
+ const ctx = context as {
72
+ lastComponent: unknown;
73
+ state: unknown;
74
+ isError: boolean;
75
+ };
76
+ if (opts.isPartial)
77
+ return reuseText(
78
+ ctx,
79
+ (theme as { fg: (a: string, b: string) => string }).fg("warning", "Editing..."),
80
+ );
81
+ const typedResult = result as {
82
+ content?: Array<{ type: string; text?: string }>;
83
+ details?: EditDetails;
84
+ };
85
+ const renderedText = getResultText(typedResult);
86
+ // SAFETY: pi TUI context state is untyped at boundary — cast to RRState narrowed by preview lifecycle (covers ctx and state casts)
87
+ const renderState = (ctx as unknown as { state: RRState | undefined }).state as
88
+ | RRState
89
+ | undefined;
90
+ if (renderState) preview.clearResult(renderState);
91
+ if (isErrorContext(ctx))
92
+ return renderedText
93
+ ? reuseText(
94
+ ctx,
95
+ `\n${(theme as { fg: (a: string, b: string) => string }).fg("error", renderedText)}`,
96
+ )
97
+ : new Text("", 0, 0);
98
+ if (isApplied(typedResult.details)) {
99
+ const appliedText = buildAppliedText(typedResult.details, theme as never);
100
+ return appliedText ? reuseText(ctx, appliedText) : new Text("", 0, 0);
101
+ }
102
+ if (!renderedText) return new Text("", 0, 0);
103
+ return reuseMarkdown(ctx, fmtResultMd(renderedText), theme as never);
104
+ };
105
+ }
106
+
107
+ export interface TuiPresenter {
108
+ renderCall: ReturnType<typeof makeRenderCall>;
109
+ renderResult: ReturnType<typeof makeRenderResult>;
110
+ preview: DebouncedPreview;
111
+ /** SAFETY: Returns pi ToolDefinition-typed renders — adapter owns the SAFETY casts */
112
+ asToolDef(): Pick<TuiToolDef, "renderCall" | "renderResult">;
113
+ }
114
+
115
+ export function createTuiPresenter(
116
+ previewFn: (request: unknown, cwd: string) => Promise<RPreview>,
117
+ getSessionId?: () => string | undefined,
118
+ ): TuiPresenter {
119
+ const preview = new DebouncedPreview(previewFn, PREVIEW_DEBOUNCE_MS, getSessionId);
120
+ const renderCall = makeRenderCall(preview);
121
+ const renderResult = makeRenderResult(preview);
122
+ return {
123
+ preview,
124
+ renderCall,
125
+ renderResult,
126
+ asToolDef() {
127
+ // SAFETY: ToolDef renderCall/renderResult typed strictly by pi — casts validated by ToolDef contract, owned by framework adapter seam. `NonNullable` names the always-defined renders this adapter owns (`ToolDefinition` declares them optional).
128
+ return {
129
+ renderCall: renderCall as unknown as NonNullable<TuiToolDef["renderCall"]>,
130
+ renderResult: renderResult as unknown as NonNullable<TuiToolDef["renderResult"]>,
131
+ };
132
+ },
133
+ };
134
+ }
package/src/undo-store.ts CHANGED
@@ -29,25 +29,8 @@ export interface UndoRecord {
29
29
  * `null` is a row from before this column, or an ordinary single-file edit.
30
30
  */
31
31
  rawPre?: string | null;
32
- /**
33
- * The lifecycle action this row reverses. Absent/null is an ordinary edit (back-compatible).
34
- * add = the file did not exist before; delete = the file does not exist after; move = a pure
35
- * rename with an optional content edit.
36
- */
37
- lifecycle?: UndoLifecycle | null;
38
- /** Move: the absolute path the file had before the transaction. */
39
- fromPath?: string | null;
40
- /** Move: the absolute path the file has after the transaction. */
41
- toPath?: string | null;
42
- /** The RAW post-transaction text when the commit wrote non-canonical bytes (pure move). */
43
- rawPost?: string | null;
44
- /** Add: the directories the add created, outermost first, JSON-encoded in the column. */
45
- createdDirs?: readonly string[] | null;
46
32
  }
47
33
 
48
- /** The lifecycle action one undo row reverses. */
49
- export type UndoLifecycle = "edit" | "add" | "delete" | "move";
50
-
51
34
  export type CutIntentDirection = "revert" | null;
52
35
 
53
36
  export interface CutIntent {
@@ -67,11 +50,6 @@ export interface UndoStmts {
67
50
  snapshotHash: string | null,
68
51
  transactionId: string | null,
69
52
  rawPre: string | null,
70
- lifecycle: string | null,
71
- fromPath: string | null,
72
- toPath: string | null,
73
- rawPost: string | null,
74
- createdDirs: string | null,
75
53
  anchorGeneration: number,
76
54
  updatedAt: number,
77
55
  ) => void;
@@ -97,15 +75,15 @@ export function undoStmts(db: DatabaseSync): UndoStmts {
97
75
 
98
76
  function buildStmts(db: DatabaseSync): UndoStmts {
99
77
  const undoUpsertStmt = db.prepare(
100
- "INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
101
- "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre, lifecycle = excluded.lifecycle, from_path = excluded.from_path, to_path = excluded.to_path, raw_post = excluded.raw_post, created_dirs = excluded.created_dirs, anchor_generation = excluded.anchor_generation, updated_at = excluded.updated_at",
78
+ "INSERT INTO file_undo (path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
79
+ "ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, snapshot_hash = excluded.snapshot_hash, transaction_id = excluded.transaction_id, raw_pre = excluded.raw_pre, anchor_generation = excluded.anchor_generation, updated_at = excluded.updated_at",
102
80
  );
103
81
  const undoGetStmt = db.prepare(
104
- "SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation FROM file_undo WHERE path = ?",
82
+ "SELECT content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE path = ?",
105
83
  );
106
84
  const undoDelStmt = db.prepare("DELETE FROM file_undo WHERE path = ?");
107
85
  const undoGetTransactionStmt = db.prepare(
108
- "SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, lifecycle, from_path, to_path, raw_post, created_dirs, anchor_generation FROM file_undo WHERE transaction_id = ? ORDER BY path",
86
+ "SELECT path, content, bom, ending, hashes, result_content, snapshot_hash, transaction_id, raw_pre, anchor_generation FROM file_undo WHERE transaction_id = ? ORDER BY path",
109
87
  );
110
88
  const undoDelTransactionStmt = db.prepare("DELETE FROM file_undo WHERE transaction_id = ?");
111
89
  const intentUpsertStmt = db.prepare(
@@ -127,11 +105,6 @@ function buildStmts(db: DatabaseSync): UndoStmts {
127
105
  snapshotHash,
128
106
  transactionId,
129
107
  rawPre,
130
- lifecycle,
131
- fromPath,
132
- toPath,
133
- rawPost,
134
- createdDirs,
135
108
  anchorGeneration,
136
109
  updatedAt,
137
110
  ) => {
@@ -146,11 +119,6 @@ function buildStmts(db: DatabaseSync): UndoStmts {
146
119
  snapshotHash,
147
120
  transactionId,
148
121
  rawPre,
149
- lifecycle,
150
- fromPath,
151
- toPath,
152
- rawPost,
153
- createdDirs,
154
122
  anchorGeneration,
155
123
  updatedAt,
156
124
  );
@@ -196,25 +164,12 @@ onStoreOpen((db) => {
196
164
  ensureFileUndoSchema(db);
197
165
  });
198
166
 
199
- /** Parse the JSON-encoded created-directory list; a corrupt value degrades to an empty list. */
200
- function parseCreatedDirs(value: unknown): string[] | null {
201
- if (typeof value !== "string" || value.length === 0) return null;
202
- try {
203
- const parsed: unknown = JSON.parse(value);
204
- return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")
205
- ? (parsed as string[])
206
- : null;
207
- } catch {
208
- return null;
209
- }
210
- }
211
-
212
167
  function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | undefined {
213
168
  if (!row) return undefined;
214
169
  try {
215
170
  const parsed = JSON.parse(row.hashes as string);
216
171
  if (!isValidHashList(parsed)) return undefined;
217
- const record: UndoRecord = {
172
+ return {
218
173
  content: row.content as string,
219
174
  bom: row.bom as string,
220
175
  ending: row.ending as string,
@@ -225,23 +180,6 @@ function parseUndoRow(row: Record<string, unknown> | undefined): UndoRecord | un
225
180
  rawPre: (row.raw_pre as string | null) ?? null,
226
181
  anchorGeneration: (row.anchor_generation as number | null) ?? 0,
227
182
  };
228
- // WHY: the lifecycle fields are additive; an ordinary row omits them entirely so its
229
- // WHY: pre-extension shape stays byte-for-byte what existing consumers assert.
230
- if (row.lifecycle !== null && row.lifecycle !== undefined) {
231
- record.lifecycle = row.lifecycle as UndoLifecycle;
232
- }
233
- if (row.from_path !== null && row.from_path !== undefined) {
234
- record.fromPath = row.from_path as string;
235
- }
236
- if (row.to_path !== null && row.to_path !== undefined) {
237
- record.toPath = row.to_path as string;
238
- }
239
- if (row.raw_post !== null && row.raw_post !== undefined) {
240
- record.rawPost = row.raw_post as string;
241
- }
242
- const createdDirs = parseCreatedDirs(row.created_dirs);
243
- if (createdDirs !== null) record.createdDirs = createdDirs;
244
- return record;
245
183
  } catch {
246
184
  return undefined;
247
185
  }
@@ -258,13 +196,6 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
258
196
  entry.snapshotHash ?? null,
259
197
  entry.transactionId ?? null,
260
198
  entry.rawPre ?? null,
261
- entry.lifecycle ?? null,
262
- entry.fromPath ?? null,
263
- entry.toPath ?? null,
264
- entry.rawPost ?? null,
265
- entry.createdDirs === null || entry.createdDirs === undefined
266
- ? null
267
- : JSON.stringify(entry.createdDirs),
268
199
  // WHY: ADR-0031 §4 (refuse, do not launder) — the stamp comes from the payload, never
269
200
  // WHY: hard-coded: a replay of a pre-generation row preserves its generation so the
270
201
  // WHY: restore gate re-derives, while a fresh write (absent generation) defaults current.