thincoder 0.12.5 → 0.12.6

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 CHANGED
@@ -174,7 +174,8 @@ src/
174
174
  config.mjs config loading
175
175
  tui/ bare-ANSI terminal UI — index.mjs (startTUI), render.mjs (drawing primitives),
176
176
  render-frame.mjs (frame layout), render-conversation.mjs (conversation panel),
177
- markdown.mjs (lightweight inline markdown → ANSI), ansi.mjs
177
+ markdown.mjs (lightweight inline markdown → ANSI), mouse.mjs (SGR clicks),
178
+ clipboard.mjs (paste/copy), ansi.mjs
178
179
  tui.mjs re-export shim → src/tui/index.mjs
179
180
  tui-render.mjs re-export shim → src/tui/render.mjs
180
181
  prompts/ prompt texts — system.md (core), discipline.md (coding/testing rules),
@@ -208,6 +209,13 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
208
209
 
209
210
  ## Changelog
210
211
 
212
+ ### 0.12.6 (2026-08)
213
+ - **Checkpoint v2 — full-file-copy snapshots** — snapshots now store complete copies of changed files (tracked + untracked) instead of a git diff patch: rollback works even after commits happened post-snapshot. New `versions` checkpoint action lists a file's historical copies across snapshots (time / size / content hash) and restores a specific version. **Full rollback is disabled** (as dangerous as a working-tree reset — silently discards post-snapshot work); oversized files (>5MB) are skipped with an explicit notice; files created after a snapshot are never deleted by a restore.
214
+ - **Git destructive-command protection** — `checkout --` / `restore` / `reset --hard` / `clean -f` (including bypass variants like `checkout HEAD -- .`) auto-snapshot every uncommitted file **before** running, then execute without blocking — a model rollback can no longer destroy uncommitted work. Snapshot triggers slimmed to: destructive-git guard, pre-restore, manual.
215
+ - **Fix: bash no longer hangs on background processes** — `start /b`, `&`, `nohup` children that hold the output pipe no longer stall the tool until timeout (resolves after a short grace with a notice).
216
+ - **Mouse support** (SGR-protocol terminals) — click a picker option to select it, click a folded-block hint to expand it, click a message line for an action menu (copy to clipboard / load into the input box). **Long messages (>12 wrapped lines) auto-fold** to first/hint/last with click-to-expand — folding now has real objects in everyday sessions.
217
+ - **Fix: legacy session hash migration** — the 12→40 char hash migration now tries every historical algorithm (CLI 12-char, VS Code 16-char, both drive-letter cases): Windows sessions from older versions are found and migrated on next startup instead of being stranded.
218
+
211
219
  ### 0.12.5 (2026-08)
212
220
  - **Fix: inline code styling** — markdown code spans now render with underline instead of reverse video (less harsh on the eyes)
213
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.5",
3
+ "version": "0.12.6",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/context.mjs CHANGED
@@ -211,25 +211,8 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {})
211
211
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
212
212
  })
213
213
 
214
- // Auto-checkpoint before compaction: snapshot current state so the model can
215
- // reconstruct context from git diff + recent messages + task progress later.
216
- let cpId = null
217
- try {
218
- const { createCheckpoint } = await import("../git/checkpoint.mjs")
219
- const cp = await createCheckpoint(agent.cwd)
220
- cpId = cp?.id
221
- } catch { /* checkpoint might fail — compaction itself should not be blocked */ }
222
-
223
214
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
224
215
 
225
- // Inject checkpoint reference after compaction so the model knows it can use /restore
226
- if (cpId) {
227
- agent.history.splice(split.headEnd, 0, {
228
- role: "user",
229
- content: `[System: context compacted. A checkpoint (id: ${cpId}) was auto-created before compaction. Use the checkpoint tool to review pre-compaction state if needed. File changes since then are tracked in git diff.]`,
230
- })
231
- }
232
-
233
216
  return true
234
217
  }
235
218
 
@@ -1,13 +1,16 @@
1
1
  /**
2
- * checkpoint.mjs — workspace snapshot and rollback
3
- * Snapshot = git diff HEAD patch + untracked file copies (respects .gitignore).
2
+ * checkpoint.mjs — workspace snapshot and rollback (v2: full-file copies)
3
+ * Snapshot = full copies of changed tracked files + untracked file copies (respects .gitignore).
4
+ * v1 stored only a git diff patch — rewind depended on HEAD being unchanged (a commit after the
5
+ * snapshot made `git apply` fail and the failure recovery chain collapse). v2 copies files, so
6
+ * rewind works regardless of later commits.
4
7
  * Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
5
- * rewind supports a path parameter for per-file restore (restores only the specified file).
8
+ * rewind supports a path parameter for per-file restore.
6
9
  */
7
10
  import { execFileSync } from "node:child_process"
8
11
  import { createHash } from "node:crypto"
9
- import { existsSync, readFileSync } from "node:fs"
10
- import { cp, mkdir, readFile, readdir, rm, writeFile, copyFile } from "node:fs/promises"
12
+ import { existsSync, readFileSync, statSync } from "node:fs"
13
+ import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
11
14
  import { dirname, join } from "node:path"
12
15
  import { configDir } from "../config.mjs"
13
16
 
@@ -15,6 +18,12 @@ const CWD_HASH_LEN = 12
15
18
 
16
19
  const MAX_CHECKPOINTS = 20
17
20
 
21
+ /** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
22
+ const MAX_FILE_BYTES = 5 * 1024 * 1024
23
+
24
+ /** meta.version 2 = full-copy snapshots; 1 = legacy patch-only snapshots */
25
+ const META_VERSION = 2
26
+
18
27
  function git(cwd, args, { allowFail = false } = {}) {
19
28
  try {
20
29
  return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
@@ -56,7 +65,28 @@ export function isGitRepo(cwd) {
56
65
  }
57
66
 
58
67
  /**
59
- * Create a snapshot. Returns { id, time, files } or null (non-git repo).
68
+ * Copy a file into a snapshot directory, skipping oversized files.
69
+ * Returns { size, sha } (sha = SHA256 hex, 12 chars) or null when skipped/failed.
70
+ */
71
+ async function copyInto(dir, rel, src, skipped) {
72
+ let size
73
+ try { size = statSync(src).size } catch { return null }
74
+ if (size > MAX_FILE_BYTES) {
75
+ skipped.push(rel)
76
+ return null
77
+ }
78
+ const buf = await readFile(src).catch((e) => { console.error(`[checkpoint] skipping ${rel}: ${e.message}`); return null })
79
+ if (!buf) return null
80
+ const dst = join(dir, rel)
81
+ await mkdir(dirname(dst), { recursive: true })
82
+ await writeFile(dst, buf)
83
+ return { size: buf.length, sha: createHash("sha256").update(buf).digest("hex").slice(0, 12) }
84
+ }
85
+
86
+ /**
87
+ * Create a snapshot. Returns { id, time, files, tracked, untracked, skipped } or null (non-git repo).
88
+ * Per-file metadata ({ size, sha } per copied file) is stored in meta.json — this powers
89
+ * listFileVersions (per-file history across snapshots) without rescanning copies.
60
90
  */
61
91
  export async function createCheckpoint(cwd) {
62
92
  if (!isGitRepo(cwd)) return null
@@ -64,30 +94,42 @@ export async function createCheckpoint(cwd) {
64
94
  // Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
65
95
  const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
66
96
  const dir = join(checkpointRoot(cwd), id)
97
+ await mkdir(join(dir, "files"), { recursive: true })
67
98
  await mkdir(join(dir, "untracked"), { recursive: true })
68
99
 
69
- // Tracked file changes patch
100
+ // v2: full copies of changed tracked files (git apply of a stale patch is the v1 failure mode).
101
+ // The patch is still written for legacy tooling/debugging, but rewind uses the copies.
102
+ const head = git(cwd, ["rev-parse", "HEAD"], { allowFail: true }) ?? ""
103
+ const changedRaw = git(cwd, ["diff", "HEAD", "--name-only", "-z"], { allowFail: true }) ?? ""
104
+ const changed = changedRaw ? changedRaw.split("\0").filter(Boolean) : []
105
+ const trackedAllRaw = git(cwd, ["ls-files", "-z"], { allowFail: true }) ?? ""
106
+ const trackedAll = trackedAllRaw ? trackedAllRaw.split("\0").filter(Boolean) : []
70
107
  const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
71
108
  await writeFile(join(dir, "patch.diff"), patch, "utf8")
72
- const tracked = trackedFilesFromPatch(patch)
109
+
110
+ const skipped = []
111
+ const fileMeta = {}
112
+ for (const rel of changed) {
113
+ const m = await copyInto(join(dir, "files"), rel, join(cwd, rel), skipped)
114
+ if (m) fileMeta[rel] = m
115
+ }
73
116
 
74
117
  // Untracked files (respects .gitignore) → copy as-is
75
118
  const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
76
119
  const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
120
+ const untrackedMeta = {}
77
121
  for (const rel of untracked) {
78
- const src = join(cwd, rel)
79
- const dst = join(dir, "untracked", rel)
80
- await mkdir(dirname(dst), { recursive: true })
81
- // Copy failed (socket/device file etc.) — skip, but log in case it's unexpected
82
- await copyFile(src, dst).catch((e) => console.error(`[checkpoint] skipping ${rel}: ${e.message}`))
122
+ const m = await copyInto(join(dir, "untracked"), rel, join(cwd, rel), skipped)
123
+ if (m) untrackedMeta[rel] = m
83
124
  }
84
125
 
85
126
  await writeFile(join(dir, "meta.json"), JSON.stringify({
86
- id, time: Date.now(), untracked, tracked,
127
+ version: META_VERSION, id, time: Date.now(), untracked, tracked: changed, skipped,
128
+ head, trackedAll, fileMeta, untrackedMeta,
87
129
  }, null, 2), "utf8")
88
130
 
89
131
  await pruneCheckpoints(cwd)
90
- return { id, time: Date.now(), files: untracked.length + tracked.length, tracked, untracked }
132
+ return { id, time: Date.now(), files: changed.length + untracked.length, tracked: changed, untracked, skipped }
91
133
  }
92
134
 
93
135
  /** List checkpoints (newest→oldest), with file change summary */
@@ -108,6 +150,7 @@ export async function listCheckpoints(cwd) {
108
150
  out.push({
109
151
  id, time: meta.time,
110
152
  untracked: meta.untracked ?? [],
153
+ skipped: meta.skipped ?? [],
111
154
  tracked,
112
155
  })
113
156
  } catch {
@@ -119,49 +162,12 @@ export async function listCheckpoints(cwd) {
119
162
 
120
163
  // ---- restore core ----
121
164
 
122
- /** Full restore of tracked files from a checkpoint: reset to HEAD apply patch */
123
- async function fullRestoreTracked(cwd, dir) {
124
- git(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "."])
125
- const patch = await readFile(join(dir, "patch.diff"), "utf8")
126
- if (patch.trim()) {
127
- git(cwd, ["apply", "--whitespace=nowarn", join(dir, "patch.diff")])
128
- }
129
- return Boolean(patch.trim())
130
- }
131
-
132
- /** Full restore of untracked files from a checkpoint: delete new ones → restore from snapshot */
133
- async function fullRestoreUntracked(cwd, dir, meta) {
134
- const nowUntracked = (git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? "")
135
- .split("\n")
136
- .filter(Boolean)
137
- const checkpointSet = new Set(meta.untracked)
138
- let deleted = 0
139
- for (const rel of nowUntracked) {
140
- if (!checkpointSet.has(rel)) {
141
- await rm(join(cwd, rel), { force: true })
142
- deleted++
143
- }
144
- }
145
-
146
- let restored = 0
147
- for (const rel of meta.untracked) {
148
- const src = join(dir, "untracked", rel)
149
- if (existsSync(src)) {
150
- await mkdir(dirname(join(cwd, rel)), { recursive: true })
151
- await cp(src, join(cwd, rel), { force: true })
152
- restored++
153
- }
154
- }
155
- return { deleted, restored }
156
- }
157
-
158
- /** Restore a single tracked file: checkout HEAD → apply that file's patch hunks */
159
- async function partialRestoreTracked(cwd, patchContent, filePath) {
160
- // Reset the file to HEAD state first
165
+ /** v1 legacy fallback: reset file to HEAD then apply its patch hunks. */
166
+ async function partialRestoreTrackedViaPatch(cwd, root, patchContent, filePath) {
161
167
  git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
162
168
  const hunks = extractFileHunks(patchContent, filePath)
163
169
  if (!hunks) return false
164
- const tmpFile = join(checkpointRoot(cwd), ".tmp_partial.patch")
170
+ const tmpFile = join(root, ".tmp_partial.patch")
165
171
  await writeFile(tmpFile, hunks, "utf8")
166
172
  try {
167
173
  git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
@@ -171,85 +177,136 @@ async function partialRestoreTracked(cwd, patchContent, filePath) {
171
177
  }
172
178
  }
173
179
 
174
- /** Restore a single untracked file */
175
- async function partialRestoreUntracked(cwd, dir, filePath) {
176
- const src = join(dir, "untracked", filePath)
180
+ /** Restore a single file from a snapshot copy. Returns true when the copy existed. */
181
+ async function restoreFromCopy(snapshotDir, rel, cwd) {
182
+ const src = join(snapshotDir, rel)
177
183
  if (!existsSync(src)) return false
178
- await mkdir(dirname(join(cwd, filePath)), { recursive: true })
179
- await cp(src, join(cwd, filePath), { force: true })
184
+ await mkdir(dirname(join(cwd, rel)), { recursive: true })
185
+ await cp(src, join(cwd, rel), { force: true })
180
186
  return true
181
187
  }
182
188
 
183
- // ---- rewind ----
189
+ // ---- restore (single-file only) ----
184
190
 
185
191
  /**
186
- * Rewind to a specific snapshot (saves current state as a new snapshot first, making rewind reversible).
192
+ * Restore ONE file from a specific snapshot (saves current state as a new snapshot first,
193
+ * making the restore reversible).
187
194
  *
188
- * Options:
189
- * - path: restore only this single file (tracked or untracked); other files are left untouched.
190
- * Omit for a full rewind (all changes snapshot state).
195
+ * FULL restore is deliberately DISABLED: rolling the whole working tree back to a snapshot
196
+ * is exactly as dangerous as `git checkout -- .` it silently discards every change made
197
+ * after the snapshot. Restore files individually (rewind with path / restoreFile).
191
198
  *
192
- * Returns summary { patchApplied, deleted?, restored? }; in path mode includes { file, type }.
199
+ * Returns { path, type, restored }.
193
200
  */
194
201
  export async function rewind(cwd, id, { path } = {}) {
202
+ if (!path) {
203
+ throw new Error(
204
+ "Full rewind is disabled — it is as dangerous as `git checkout -- .` (silently discards all work after the snapshot). " +
205
+ "Restore files individually: rewind(cwd, id, { path }) or restoreFile(cwd, path, id)."
206
+ )
207
+ }
208
+
195
209
  const root = checkpointRoot(cwd)
196
210
  const dir = join(root, id)
197
211
  if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
198
212
  const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
213
+ const isV2 = meta.version === META_VERSION
199
214
  const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
200
215
 
201
- // Rewind is reversible: snapshot current state first
202
- const preRewindCp = await createCheckpoint(cwd)
216
+ // Restore is reversible: snapshot current state first
217
+ await createCheckpoint(cwd)
203
218
 
204
- // ---- per-file restore ----
205
- if (path) {
206
- // Determine whether it's a tracked or untracked file
207
- const isTracked = meta.tracked?.includes(path) ?? extractFileHunks(patchContent, path) !== ""
208
- const inUntracked = (meta.untracked ?? []).includes(path)
219
+ const tracked = meta.tracked ?? []
220
+ const trackedAll = meta.trackedAll ?? []
221
+ const isTracked = tracked.includes(path) || trackedAll.includes(path) || extractFileHunks(patchContent, path) !== ""
222
+ const inUntracked = (meta.untracked ?? []).includes(path)
209
223
 
210
- if (!isTracked && !inUntracked) {
211
- throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${(meta.tracked ?? []).join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
212
- }
224
+ // Oversized files were never copied — nothing to restore, say so explicitly.
225
+ if ((meta.skipped ?? []).includes(path)) {
226
+ throw new Error(`file "${path}" was NOT snapshotted in checkpoint ${id} (oversized, >5MB) — no copy exists to restore`)
227
+ }
213
228
 
214
- let ok = false
215
- if (isTracked) {
216
- ok = await partialRestoreTracked(cwd, patchContent, path)
217
- }
218
- if (inUntracked) {
219
- ok = await partialRestoreUntracked(cwd, dir, path) || ok
220
- }
229
+ if (!isTracked && !inUntracked) {
230
+ throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${tracked.join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
231
+ }
221
232
 
222
- return { path, type: isTracked ? "tracked" : "untracked", restored: ok, patchApplied: false }
233
+ let ok = false
234
+ if (inUntracked) {
235
+ ok = await restoreFromCopy(join(dir, "untracked"), path, cwd) || ok
236
+ }
237
+ if (isTracked) {
238
+ if (isV2 && existsSync(join(dir, "files", path))) {
239
+ ok = await restoreFromCopy(join(dir, "files"), path, cwd) || ok
240
+ } else if (isV2 && meta.head) {
241
+ // Untouched at snapshot time (content = snapshot HEAD) → checkout that commit's version.
242
+ // Works even if HEAD moved since — the commit object is immutable.
243
+ git(cwd, ["checkout", meta.head, "--", path], { allowFail: true })
244
+ ok = true
245
+ } else {
246
+ // v1 snapshot → patch fallback
247
+ ok = await partialRestoreTrackedViaPatch(cwd, root, patchContent, path) || ok
248
+ }
223
249
  }
224
250
 
225
- // ---- full rewind ----
226
- try {
227
- const patchApplied = await fullRestoreTracked(cwd, dir)
228
- const { deleted, restored } = await fullRestoreUntracked(cwd, dir, meta)
229
- return { deleted, restored, patchApplied }
230
- } catch (e) {
231
- // git apply failed: working tree may have been reset to HEAD by restore.
232
- // Restore from pre-rewind snapshot to ensure no data is lost on rewind failure
233
- const preDir = join(root, preRewindCp.id)
234
- try {
235
- await fullRestoreTracked(cwd, preDir)
236
- await fullRestoreUntracked(cwd, preDir, JSON.parse(await readFile(join(preDir, "meta.json"), "utf8")))
237
- } catch {
238
- // Double failure: pre-rewind may also be corrupt, stop trying
251
+ return { path, type: isTracked ? "tracked" : "untracked", restored: ok }
252
+ }
253
+
254
+ // ---- per-file history ----
255
+
256
+ /**
257
+ * List every historical version of a file across all snapshots (newest first).
258
+ * Distinguishing copies: each entry carries its snapshot id, time, byte size and
259
+ * content sha — the same file in different snapshots is a different version.
260
+ * @param {string} cwd
261
+ * @param {string} filePath — relative path as stored in snapshots
262
+ * @returns {Promise<Array<{snapshotId: string, time: number, size: number, sha: string, source: "tracked"|"untracked"}>>}
263
+ */
264
+ export async function listFileVersions(cwd, filePath) {
265
+ const root = checkpointRoot(cwd)
266
+ if (!existsSync(root)) return []
267
+ const ids = (await readdir(root)).sort().reverse() // newest → oldest
268
+ const out = []
269
+ for (const id of ids) {
270
+ let meta
271
+ try { meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8")) } catch { continue }
272
+ const trackedMeta = meta.fileMeta ?? {}
273
+ const untrackedMeta = meta.untrackedMeta ?? {}
274
+ let entry = null
275
+ if (trackedMeta[filePath]) {
276
+ entry = { snapshotId: id, time: meta.time, ...trackedMeta[filePath], source: "tracked" }
277
+ } else if (untrackedMeta[filePath]) {
278
+ entry = { snapshotId: id, time: meta.time, ...untrackedMeta[filePath], source: "untracked" }
279
+ } else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
280
+ // Legacy snapshot (no per-file meta): fall back to stat-ing the copy
281
+ const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
282
+ let size = null, sha = null
283
+ try {
284
+ const buf = await readFile(src)
285
+ size = buf.length
286
+ sha = createHash("sha256").update(buf).digest("hex").slice(0, 12)
287
+ } catch { continue }
288
+ entry = { snapshotId: id, time: meta.time, size, sha, source: (meta.tracked ?? []).includes(filePath) ? "tracked" : "untracked" }
239
289
  }
240
- throw new Error(
241
- `Rewind to ${id} failed: ${e.message}. ` +
242
- `The pre-rewind state was restored from checkpoint ${preRewindCp.id} — no work was lost.`
243
- )
290
+ if (entry) out.push(entry)
244
291
  }
292
+ return out
245
293
  }
246
294
 
295
+ /**
296
+ * Restore a single file's content from a specific snapshot (per-file restore —
297
+ * other files are left untouched). Thin wrapper over rewind path mode with clearer intent.
298
+ * @returns {Promise<{path: string, type: "tracked"|"untracked", restored: boolean}>}
299
+ */
300
+ export async function restoreFile(cwd, filePath, snapshotId) {
301
+ return rewind(cwd, snapshotId, { path: filePath })
302
+ }
303
+
304
+
247
305
  // ---- view ----
248
306
 
249
307
  /**
250
308
  * View a file's content from a checkpoint (does not modify the working tree).
251
- * Tracked files: temporarily restore via checkout HEAD + apply patch hunks, read, then restore original.
252
- * Untracked files: read the copy directly from the checkpoint directory.
309
+ * v2: read the snapshot copy directly. Legacy snapshots fall back to the temporary-restore path.
253
310
  * Returns the file content string.
254
311
  */
255
312
  export async function catFile(cwd, id, filePath) {
@@ -257,23 +314,31 @@ export async function catFile(cwd, id, filePath) {
257
314
  const dir = join(root, id)
258
315
  if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
259
316
  const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
317
+ const isV2 = meta.version === META_VERSION
260
318
  const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
261
319
 
262
- const isTracked = meta.tracked?.includes(filePath) ?? extractFileHunks(patchContent, filePath) !== ""
320
+ const tracked = meta.tracked ?? []
321
+ const isTracked = tracked.includes(filePath) || extractFileHunks(patchContent, filePath) !== ""
263
322
  const inUntracked = (meta.untracked ?? []).includes(filePath)
264
323
 
265
324
  if (!isTracked && !inUntracked) {
266
325
  throw new Error(`file "${filePath}" not in checkpoint ${id}`)
267
326
  }
268
327
 
269
- // Untracked file: read the copy directly
328
+ // v2: read the snapshot copy directly
329
+ const copy = join(inUntracked ? join(dir, "untracked") : join(dir, "files"), filePath)
330
+ if (isV2 && existsSync(copy)) {
331
+ return await readFile(copy, "utf8")
332
+ }
333
+
334
+ // Untracked legacy: read the copy if present
270
335
  if (inUntracked && !isTracked) {
271
336
  const src = join(dir, "untracked", filePath)
272
337
  if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
273
338
  return await readFile(src, "utf8")
274
339
  }
275
340
 
276
- // Tracked file: temporarily restore → read → restore working tree
341
+ // Tracked legacy: temporarily restore → read → restore working tree
277
342
  const abs = join(cwd, filePath)
278
343
  const existed = existsSync(abs)
279
344
  let saved = null
package/src/session.mjs CHANGED
@@ -35,19 +35,38 @@ function cwdHash(cwd) {
35
35
  return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
36
36
  }
37
37
 
38
- /** One-time migration: rename legacy 12-char-hash session files to the full 40-char hash.
39
- * Idempotent; runs on first access per cwd. */
38
+ /** One-time migration: rename legacy short-hash session files to the full 40-char hash.
39
+ * Idempotent; runs on first access per cwd.
40
+ * Historical hash algorithms (all sha1, none normalized the drive letter):
41
+ * - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
42
+ * - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
43
+ * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
44
+ * Every combination is tried — a migration that only checks one candidate misses real
45
+ * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
40
46
  function migrateHashLength(cwd, fullHash) {
41
47
  const dir = join(configDir, "sessions")
42
- const legacyBase = join(dir, `${fullHash.slice(0, 12)}.json`)
43
- if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) return
48
+ const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
49
+ const candidates = [
50
+ createHash("sha1").update(cwd).digest("hex").slice(0, 12),
51
+ createHash("sha1").update(cwd).digest("hex").slice(0, 16),
52
+ createHash("sha1").update(lower).digest("hex").slice(0, 12),
53
+ createHash("sha1").update(lower).digest("hex").slice(0, 16),
54
+ fullHash.slice(0, 12),
55
+ ]
44
56
  const newBase = join(dir, `${fullHash}.json`)
45
- try {
46
- for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
47
- const from = legacyBase + suffix
48
- if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
49
- }
50
- } catch { /* best-effort; leave files in place on failure */ }
57
+ let migrated = false
58
+ for (const short of new Set(candidates)) {
59
+ const legacyBase = join(dir, `${short}.json`)
60
+ if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
61
+ migrated = true
62
+ try {
63
+ for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
64
+ const from = legacyBase + suffix
65
+ if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
66
+ }
67
+ } catch { /* best-effort; leave files in place on failure */ }
68
+ }
69
+ return migrated
51
70
  }
52
71
 
53
72
  /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
package/src/tools/git.mjs CHANGED
@@ -16,12 +16,12 @@ export const gitTool = {
16
16
  action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
17
17
  // diff/log params
18
18
  staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
19
- path: { type: "string", description: "(diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to" },
19
+ path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind) File or directory to scope to" },
20
20
  ref: { type: "string", description: "(diff) Compare against this ref (default HEAD)" },
21
21
  count: { type: "number", description: "(log) Number of commits (default 10)" },
22
22
  oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
23
23
  // checkpoint params
24
- checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot" },
24
+ checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
25
25
  checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
26
26
  },
27
27
  required: ["action"],
@@ -81,23 +81,34 @@ export const gitTool = {
81
81
  return truncate(out || "(no commits)")
82
82
  }
83
83
  case "checkpoint": {
84
- const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
84
+ const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
85
85
  if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
86
86
 
87
87
  const sub = args.checkpointAction
88
- if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat"
88
+ if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
89
89
 
90
90
  if (sub === "create") {
91
91
  const cp = await createCheckpoint(ctx.cwd)
92
92
  return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
93
93
  }
94
+ if (sub === "versions") {
95
+ if (!args.path) throw new Error("path is required for versions — the file whose history you want")
96
+ const versions = await listFileVersions(ctx.cwd, args.path)
97
+ if (versions.length === 0) return `No snapshot copies of "${args.path}" found (it was never part of an auto/protection snapshot).`
98
+ return (
99
+ `Historical versions of "${args.path}" (${versions.length}, newest first):\n` +
100
+ versions.map((v) =>
101
+ ` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
102
+ (v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
103
+ ).join("\n") +
104
+ `\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${args.path}"`
105
+ )
106
+ }
94
107
  if (sub === "rewind") {
95
108
  if (!args.checkpointId) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
109
+ if (!args.path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
96
110
  const s = await rewind(ctx.cwd, args.checkpointId, { path: args.path })
97
- if (args.path) {
98
- return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
99
- }
100
- return `Rewound to checkpoint ${args.checkpointId}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
111
+ return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
101
112
  }
102
113
  if (sub === "cat") {
103
114
  if (!args.checkpointId) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
@@ -107,7 +118,7 @@ export const gitTool = {
107
118
  }
108
119
  if (sub === "list") {
109
120
  const cps = await listCheckpoints(ctx.cwd)
110
- if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
121
+ if (cps.length === 0) return "(no checkpoints yet)"
111
122
 
112
123
  // Specific id: show the file tree within that snapshot
113
124
  if (args.checkpointId) {
@@ -125,7 +136,7 @@ export const gitTool = {
125
136
  return parts.join(" ")
126
137
  }).join("\n")
127
138
  }
128
- throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat`)
139
+ throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
129
140
  }
130
141
  default:
131
142
  return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
@@ -7,10 +7,8 @@ import {
7
7
  IGNORED_DIRS,
8
8
  resolveInCwd,
9
9
  shellSegments,
10
- isDestructiveGitSegment,
11
10
  isDestructiveCommand,
12
11
  hasFileRedirection,
13
- insideGitRepo,
14
12
  globToRegex,
15
13
  normalizeEOL,
16
14
  } from "./shared.mjs";
@@ -27,8 +25,10 @@ const MAX_STREAM_BUF = 2_000_000
27
25
 
28
26
  /**
29
27
  * Pre-execution safety checks for bash commands.
30
- * Three layers: file redirection → destructive commands destructive git ops with uncommitted changes.
31
- * Throws with actionable guidance on failure.
28
+ * Layers: file redirection → destructive commands (rm -rf etc.).
29
+ * Git destructive ops are deliberately NOT rejected — the model would just find a
30
+ * way around the rejection; instead gitGuardSnapshot copies every uncommitted file
31
+ * and the command is ALLOWED (snapshot-then-proceed, never block).
32
32
  */
33
33
  function checkBashSafety(command, cwd) {
34
34
  if (hasFileRedirection(command)) {
@@ -40,21 +40,6 @@ function checkBashSafety(command, cwd) {
40
40
  "(If work was already destroyed, recover from auto-snapshot: checkpoint action=list then action=rewind.)"
41
41
  )
42
42
  }
43
- if (shellSegments(command).some(isDestructiveGitSegment)) {
44
- if (!insideGitRepo(cwd)) {
45
- throw new Error(`Refusing destructive git command: not a git repository: ${cwd}`)
46
- }
47
- const status = execFileSync("git", ["status", "--porcelain"], {
48
- cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
49
- }).trim()
50
- if (status) {
51
- throw new Error(
52
- `Refusing destructive git command: uncommitted changes exist.\n` +
53
- `First create a checkpoint (action=create) to protect current work, then commit or stash before the destructive operation.\n` +
54
- `(If uncommitted work was already lost, recover from the latest auto-snapshot: checkpoint action=list, then action=rewind.)\n\n${status}`
55
- )
56
- }
57
- }
58
43
  }
59
44
 
60
45
  /**
@@ -96,6 +81,43 @@ function killProcessTree(child) {
96
81
  *
97
82
  * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
98
83
  */
84
+
85
+ /**
86
+ * Detect destructive git commands and auto-snapshot BEFORE execution.
87
+ * The scenario: the model writes uncommitted code, then (after breaking things)
88
+ * runs `git checkout -- .` / `git restore` / `git reset --hard` / `git clean -f` to
89
+ * roll back — which silently DESTROYS all uncommitted work. The pre-task checkpoint
90
+ * cannot help (it was taken before the code was written); only a snapshot taken
91
+ * immediately before the destructive command can.
92
+ *
93
+ * This is layer 1 (defense in depth): a WIDE match that snapshots before the command
94
+ * runs. Layer 2 (checkBashSafety) then rejects the command when uncommitted changes
95
+ * exist — but a rejection alone is not enough: the model may retry a variant that
96
+ * slips through the exact matcher (e.g. `git checkout HEAD -- .`), or run git outside
97
+ * the bash tool. The snapshot taken here survives all of those paths.
98
+ *
99
+ * Matching is intentionally WIDE (false positives are harmless — one extra snapshot;
100
+ * a missed match is a data-loss disaster).
101
+ */
102
+ const GIT_DESTRUCTIVE_RE = /\bgit\s+(?:checkout\s+(?:[\w./-]+\s+)?--(?!\w)|checkout\s+\.|restore\s+(?!--help\b)(?!--staged\b(?!.*--worktree))|reset\s+--hard|clean\s+-(?=\S*f)(?!\S*n))/i
103
+
104
+ /** Returns { id, notice } when a snapshot was taken, else null. Never throws. */
105
+ async function gitGuardSnapshot(command, cwd) {
106
+ if (!GIT_DESTRUCTIVE_RE.test(command)) return null
107
+ try {
108
+ const { isGitRepo, createCheckpoint } = await import("../git/checkpoint.mjs")
109
+ if (!isGitRepo(cwd)) return null
110
+ const cp = await createCheckpoint(cwd)
111
+ if (!cp) return null
112
+ return {
113
+ id: cp.id,
114
+ notice: `[auto-protection] Destructive git command detected — snapshot ${cp.id} created BEFORE execution (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked). If this command destroyed uncommitted work, restore it: checkpoint action=checkpoint checkpointAction=rewind checkpointId=${cp.id}`,
115
+ }
116
+ } catch {
117
+ return null // protection is best-effort — never block the command
118
+ }
119
+ }
120
+
99
121
  function runBash(command, cwd, { timeout, signal, onOutput }) {
100
122
  return new Promise((resolve) => {
101
123
  const child = spawn(command, {
@@ -134,13 +156,40 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
134
156
  const timer = setTimeout(killTree, timeout)
135
157
  if (signal) signal.addEventListener("abort", killTree, { once: true })
136
158
 
137
- child.on("error", (error) => {
159
+ let settled = false
160
+ let graceTimer = null
161
+ const finish = (result) => {
162
+ if (settled) return
163
+ settled = true
138
164
  clearTimeout(timer)
139
- resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
165
+ clearTimeout(graceTimer)
166
+ resolve(result)
167
+ }
168
+
169
+ child.on("error", (error) => {
170
+ finish(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
171
+ })
172
+
173
+ // Shell exited. Normally 'close' follows within milliseconds — but a BACKGROUND
174
+ // child (start /b, &, nohup …) inherits the stdio pipes, so the pipe stays open
175
+ // and 'close' never fires: the tool would hang until the timeout. Resolve after
176
+ // a short grace period instead, returning whatever output was collected.
177
+ child.on("exit", (code, exitSignal) => {
178
+ graceTimer = setTimeout(() => {
179
+ const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
180
+ const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
181
+ if (outFlush) onOutput?.(outFlush)
182
+ if (errFlush) onOutput?.(errFlush)
183
+ const status = exitSignal ? `killed: ${exitSignal}` : `exit code ${code}`
184
+ const parts = [`[stdout]:\n${(outBuf + outFlush).trim() || "(empty)"}`]
185
+ if ((errBuf + errFlush).trim()) parts.push(`[stderr]:\n${(errBuf + errFlush).trim()}`)
186
+ parts.push(`(${status})`)
187
+ parts.push("[background] the shell exited but a child process still holds the output pipe — output may be incomplete; the process may still be running")
188
+ finish(truncate(parts.join("\n\n") + truncatedNote))
189
+ }, 1500)
140
190
  })
141
191
 
142
192
  child.on("close", (code, exitSignal) => {
143
- clearTimeout(timer)
144
193
  // Flush decoder tails — also push final bytes to panel
145
194
  const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
146
195
  const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
@@ -157,7 +206,7 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
157
206
  const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
158
207
  if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
159
208
  parts.push(`(${status})`)
160
- resolve(truncate(parts.join("\n\n") + truncatedNote))
209
+ finish(truncate(parts.join("\n\n") + truncatedNote))
161
210
  })
162
211
  })
163
212
  }
@@ -180,12 +229,18 @@ export const bashTool = {
180
229
  readonly: false,
181
230
  outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
182
231
  async execute(args, ctx) {
232
+ // Git destructive commands are NEVER rejected — the model would bypass the
233
+ // guard anyway. Instead: snapshot every uncommitted file first, then ALLOW
234
+ // the command. The snapshot makes the rollback reversible (defense in depth:
235
+ // the wide matcher also covers variants like `git checkout HEAD -- .`).
183
236
  checkBashSafety(args.command, ctx.cwd)
184
- return runBash(args.command, ctx.cwd, {
237
+ const guard = await gitGuardSnapshot(args.command, ctx.cwd)
238
+ const result = await runBash(args.command, ctx.cwd, {
185
239
  timeout: args.timeout ?? BASH_TIMEOUT_MS,
186
240
  signal: ctx.signal,
187
241
  onOutput: ctx.onOutput,
188
242
  })
243
+ return guard ? `${guard.notice}\n\n${result}` : result
189
244
  },
190
245
  }
191
246
 
@@ -32,14 +32,6 @@ export async function runAgentTurn(ctx, text) {
32
32
  pushLabel(`❯ You:`, ansi.bold + C.user)
33
33
  pushLine(text, C.text)
34
34
 
35
- // Auto-checkpoint before task (git repo only; failure is silent, doesn't block the task)
36
- try {
37
- const { createCheckpoint } = await import("../git/checkpoint.mjs")
38
- await createCheckpoint(agent.cwd)
39
- } catch {
40
- // Checkpoint failure doesn't block the task
41
- }
42
-
43
35
  ctx.assistantLabeled = false
44
36
  state.processing = true
45
37
  state.status = "Processing..."
@@ -18,6 +18,26 @@ export async function readClipboardText() {
18
18
  }
19
19
  }
20
20
 
21
+ /** Write text to the system clipboard (Set-Clipboard / pbcopy / xclip). Throws on failure. */
22
+ export async function copyToClipboard(text) {
23
+ const { execFile } = await import("node:child_process")
24
+ const isWin = process.platform === "win32"
25
+ const isMac = process.platform === "darwin"
26
+ await new Promise((resolve, reject) => {
27
+ if (isWin) {
28
+ const child = execFile("powershell", ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
29
+ child.stdin?.end(text)
30
+ } else if (isMac) {
31
+ const child = execFile("pbcopy", [], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
32
+ child.stdin?.end(text)
33
+ } else {
34
+ const child = execFile("xclip", ["-selection", "clipboard"], { timeout: 5000 }, (err) => err ? reject(err) : resolve())
35
+ child.stdin?.end(text)
36
+ }
37
+ })
38
+ }
39
+
40
+
21
41
  /** Insert pasted text into the active text target.
22
42
  * Free-text question active → append to its answer (single-line field: newlines stripped).
23
43
  * Options question active → ignore (no text field; must not leak into the input box).
package/src/tui/index.mjs CHANGED
@@ -28,6 +28,7 @@ import { createPickers } from "./pickers.mjs"
28
28
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
29
29
  import { createInteraction } from "./interaction.mjs"
30
30
  import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
31
+ import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
31
32
  import { runAgentTurn } from "./agent-turn.mjs"
32
33
  import { createKeyHandler } from "./key-handler.mjs"
33
34
  import { showStartup, backgroundIndex } from "./startup.mjs"
@@ -179,6 +180,16 @@ export async function startTUI(agent, opts = {}) {
179
180
  }
180
181
  }
181
182
 
183
+ // Left-click: \x1b[<0;col;rowM → picker selection / line action menu
184
+ for (const click of parseMouseClicks(text)) {
185
+ try {
186
+ onMouseClick(click.col, click.row)
187
+ } catch (e) {
188
+ pushLine(`[mouse] ${e.message || e}`, C.error)
189
+ render()
190
+ }
191
+ }
192
+
182
193
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
183
194
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
184
195
  const tail = text.match(/\x1b\[<[\d;]*$/)
@@ -375,6 +386,9 @@ export async function startTUI(agent, opts = {}) {
375
386
  }
376
387
  })
377
388
 
389
+ // Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + line action menu.
390
+ const onMouseClick = (col, row) => handleMouseClick({ state, render, showPicker, popPicker, pushLine }, col, row)
391
+
378
392
  // ---------------------------------------------------------- Startup screen + background indexing
379
393
 
380
394
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * mouse.mjs — SGR mouse support: click parsing + hit-testing + line actions.
3
+ *
4
+ * Protocol (enabled at startup via \x1b[?1000h\x1b[?1006h):
5
+ * press: \x1b[<b;col;rowM (b=0 left, 64/65 wheel up/down — wheel handled upstream)
6
+ * release: \x1b[<b;col;rowm (ignored — actions fire on press)
7
+ * Coordinates are 1-based; col comes FIRST in the sequence.
8
+ *
9
+ * Only left-click (button 0) is consumed. Everything else stays stripped
10
+ * upstream (sequence fragments must never leak into the input box).
11
+ */
12
+ import { computeLayout } from "./layout.mjs"
13
+ import { buildConvLines } from "./render-conversation.mjs"
14
+ import { sanitizeDisplay } from "./render.mjs"
15
+
16
+ /** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
17
+ export function parseMouseClicks(text) {
18
+ const out = []
19
+ for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
20
+ out.push({ col: Number(m[1]), row: Number(m[2]) })
21
+ }
22
+ return out
23
+ }
24
+
25
+ /** Map a 0-based screen row to a conversation line index (same math as renderConversation). */
26
+ export function convGlobalIndex(convLen, convH, scroll) {
27
+ const maxScroll = Math.max(0, convLen - convH)
28
+ const clamped = Math.min(scroll, maxScroll)
29
+ const end = convLen - clamped
30
+ const start = Math.max(0, end - convH) // content shorter than the panel: rows start at 0
31
+ return (localRow) => {
32
+ if (localRow < 0 || localRow >= convH) return null
33
+ const idx = start + localRow
34
+ return idx >= 0 && idx < convLen ? idx : null
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Handle a left-click at SGR (col, row) — 1-based terminal coordinates.
40
+ * ctx: { state, render, showPicker, popPicker, pushLine }
41
+ * Returns true when the click was consumed.
42
+ */
43
+ export function handleMouseClick(ctx, col, row) {
44
+ const { state, render } = ctx
45
+ const r = row - 1 // 0-based screen row
46
+ if (r < 0) return false
47
+ const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
48
+ const layout = computeLayout(state, dims)
49
+ const P = layout.panels
50
+
51
+ // ── Picker: click an option = select it (skip the title row) ──
52
+ if (state.picker && P.picker && r >= P.picker.y && r < P.picker.y + P.picker.h) {
53
+ const p = state.picker
54
+ const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
55
+ const winH = Math.max(1, P.picker.h - 1)
56
+ const start = Math.max(0, Math.min(p.scroll, Math.max(0, p.lines.length - winH)))
57
+ const localRow = r - P.picker.y - 1
58
+ const lineEl = p.lines[start + localRow]
59
+ if (lineEl && lineEl._row !== undefined && items[lineEl._row]) {
60
+ ctx.popPicker(items[lineEl._row])
61
+ }
62
+ return true
63
+ }
64
+
65
+ // ── Conversation: fold-toggle line expands; a message line opens the action menu ──
66
+ if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
67
+ const convLines = buildConvLines(state, dims.cols)
68
+ const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
69
+ if (gIdx === null) return false
70
+ const lineEl = convLines[gIdx]
71
+ if (!lineEl) return false
72
+
73
+ // Click on a folded-block hint → expand it
74
+ if (lineEl._foldToggle) {
75
+ state.expandedBlocks ??= new Set()
76
+ state.expandedBlocks.add(lineEl._foldToggle)
77
+ render()
78
+ return true
79
+ }
80
+ // Click on a message line → action menu
81
+ if (lineEl._src !== undefined) {
82
+ const src = state.lines[lineEl._src]
83
+ if (src) {
84
+ openLineMenu(ctx, src)
85
+ return true
86
+ }
87
+ }
88
+ }
89
+
90
+ return false
91
+ }
92
+
93
+ /** Line action menu: copy / edit in input / (fold toggle if the source line folds). */
94
+ async function openLineMenu(ctx, srcLine) {
95
+ const { state, render, showPicker, pushLine } = ctx
96
+ const text = sanitizeDisplay(srcLine.text)
97
+ const entries = [
98
+ { type: "item", text: `📋 Copy line (${text.length} chars)`, action: "copy" },
99
+ { type: "item", text: "✏️ Edit in input box", action: "edit" },
100
+ ]
101
+ const picked = await showPicker("Line actions", entries)
102
+ if (!picked) return
103
+ if (picked.action === "copy") {
104
+ try {
105
+ const { copyToClipboard } = await import("./clipboard.mjs")
106
+ await copyToClipboard(text)
107
+ pushLine(`[clipboard] copied ${text.length} chars`, (await import("./ansi.mjs")).C.dim)
108
+ } catch (e) {
109
+ pushLine(`[clipboard] copy failed: ${e.message}`, (await import("./ansi.mjs")).C.error)
110
+ }
111
+ render()
112
+ } else if (picked.action === "edit") {
113
+ state.input = [...text]
114
+ state.cursor = state.input.length
115
+ render()
116
+ }
117
+ }
@@ -63,7 +63,7 @@ export function createPickers(ctx) {
63
63
  const sel = row === p.index
64
64
  if (sel) selLine = lines.length
65
65
  const marker = e.marker ? ` ${e.marker}` : ""
66
- lines.push({ text: `${sel ? " ▸ " : " "}${e.text}${marker}`, color: sel ? ansi.bold + C.text : C.dim })
66
+ lines.push({ text: `${sel ? " ▸ " : " "}${e.text}${marker}`, color: sel ? ansi.bold + C.text : C.dim, _row: row })
67
67
  row++
68
68
  }
69
69
  }
@@ -10,7 +10,9 @@ let _convCache = { key: "", cols: 0, lines: [] }
10
10
 
11
11
  export function convCacheKey(state) {
12
12
  const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
13
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}`
13
+ // expandedBlocks participates: expanding/folding a block must invalidate the cache
14
+ const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
15
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
14
16
  }
15
17
 
16
18
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
@@ -50,13 +52,28 @@ function buildConvLines(state, cols) {
50
52
  text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
51
53
  }
52
54
 
55
+ // Long-message folding: a single line that wraps beyond LONG_FOLD_LINES display rows
56
+ // collapses to [first, "… N more — click/Enter to expand", last]. Keyed by the
57
+ // source-line index (`long-${i}`) so the toggle survives re-renders. This is the
58
+ // folding users actually see — tool outputs, long replies, big error blocks.
59
+ const LONG_FOLD_LINES = 12
60
+ const longKey = `long-${i}`
61
+ const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
62
+ const block = []
53
63
  for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
54
64
  for (const wrapped of wrapText(line, cols - 1)) {
55
65
  // Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
56
66
  // Runs AFTER wrapping so the ANSI it inserts never skews width math.
57
- convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId })
67
+ block.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId, _src: i })
58
68
  }
59
69
  }
70
+ if (folded && block.length > LONG_FOLD_LINES) {
71
+ convLines.push(block[0])
72
+ convLines.push({ text: ` … ${block.length - 2} more lines — click to expand`, color: C.fold, _foldToggle: longKey, _src: i })
73
+ convLines.push(block[block.length - 1])
74
+ } else {
75
+ convLines.push(...block)
76
+ }
60
77
  }
61
78
  if (state.reasoning) {
62
79
  for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
@@ -101,7 +118,7 @@ function buildConvLines(state, cols) {
101
118
  if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
102
119
  folded.push(convLines[i])
103
120
  if (blockLen > 2) folded.push(convLines[i + 1])
104
- folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
121
+ folded.push({ text: ` … ${blockLen - 2} more lines — click to expand`, color: C.fold, _foldToggle: foldKey })
105
122
  i = j
106
123
  continue
107
124
  }
@@ -119,6 +136,8 @@ export function countConvLines(state, cols) {
119
136
  return buildConvLines(state, cols).length
120
137
  }
121
138
 
139
+ export { buildConvLines }
140
+
122
141
  export function renderConversation(state, cols, visibleH, scroll) {
123
142
  const convLines = buildConvLines(state, cols)
124
143
  const maxScroll = Math.max(0, convLines.length - visibleH)