thincoder 0.8.12 → 0.9.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.
@@ -8,38 +8,109 @@ const DONE = "checklist-done.md"
8
8
  function checklistPath(cwd) { return join(cwd, ".thincoder", CHECKLIST) }
9
9
  function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
10
10
 
11
- /** Parse checklist file into array of { index, status, text } */
11
+ /**
12
+ * Parse checklist file into tree-structured items.
13
+ * Indentation (2 spaces per level) determines parent-child relationships.
14
+ * Each item: { id, index, depth, status, text, children[] }
15
+ * "index" is the 1-based position in the flat markdown list.
16
+ */
12
17
  function parse(filePath) {
13
18
  if (!existsSync(filePath)) return []
14
19
  const lines = readFileSync(filePath, "utf-8").split("\n")
15
20
  const items = []
16
- let idx = 0
21
+ let flatIdx = 0
22
+ const stack = [{ children: items, depth: -1 }] // virtual root
23
+
17
24
  for (const line of lines) {
18
- const m = line.match(/^- \[(.)\] (.+)$/)
19
- if (m) {
20
- idx++
21
- const raw = m[1]
22
- const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
23
- items.push({ index: idx, status, text: m[2].trim() })
25
+ const m = line.match(/^(\s*)- \[(.)\] (.+)$/)
26
+ if (!m) continue
27
+ flatIdx++
28
+ const indent = m[1]
29
+ const depth = Math.floor(indent.length / 2) // 2 spaces = 1 level
30
+ const raw = m[2]
31
+ const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
+ const text = m[3].trim()
33
+
34
+ // Extract explicit ID if present (e.g. "T1:", "T1.1:")
35
+ const idMatch = text.match(/^(T[\d.]+):/)
36
+ const node = {
37
+ id: idMatch ? idMatch[1] : null,
38
+ index: flatIdx,
39
+ depth,
40
+ status,
41
+ text,
42
+ children: [],
24
43
  }
44
+
45
+ // Find parent by popping stack until we find a node at depth-1
46
+ while (stack.length > 1 && stack.at(-1).depth >= depth) stack.pop()
47
+ const parent = stack.at(-1)
48
+ parent.children.push(node)
49
+ // Auto-assign ID if not explicit
50
+ if (!node.id) {
51
+ const siblingCount = parent.children.length
52
+ const base = parent.id ? `${parent.id}` : "T"
53
+ if (parent.id) {
54
+ node.id = `${base}.${siblingCount}`
55
+ } else {
56
+ // Root level: T1, T2, T3...
57
+ let rootIdx = 0
58
+ for (const c of items) {
59
+ if (c.id?.match(/^T\d+$/)) rootIdx = Math.max(rootIdx, parseInt(c.id.slice(1)))
60
+ }
61
+ node.id = `T${rootIdx + 1}`
62
+ }
63
+ }
64
+ stack.push({ children: node.children, depth, id: node.id })
25
65
  }
26
66
  return items
27
67
  }
28
68
 
29
- /** Write items back to file */
30
- function write(filePath, items) {
31
- mkdirSync(dirname(filePath), { recursive: true })
69
+ /** Write items back to file, preserving tree structure */
70
+ function write(filePath, items, _depth = 0) {
71
+ if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
32
72
  const lines = []
73
+ const indent = " ".repeat(_depth)
33
74
  for (const item of items) {
34
75
  const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
35
- lines.push(`- [${mark}] ${item.text}`)
76
+ const label = item.id ? `${item.id}: ${item.text}` : item.text
77
+ lines.push(`${indent}- [${mark}] ${label}`)
78
+ if (item.children?.length) {
79
+ lines.push(...write(filePath, item.children, _depth + 1).split("\n").filter(Boolean))
80
+ }
81
+ }
82
+ if (_depth === 0) {
83
+ writeFileSync(filePath, lines.join("\n") + "\n")
84
+ return ""
85
+ }
86
+ return lines.join("\n")
87
+ }
88
+
89
+ /** Find a node by ID in the tree */
90
+ function findById(items, id) {
91
+ for (const item of items) {
92
+ if (item.id === id) return { parent: items, item, idx: items.indexOf(item) }
93
+ if (item.children?.length) {
94
+ const found = findById(item.children, id)
95
+ if (found) return found
96
+ }
36
97
  }
37
- writeFileSync(filePath, lines.join("\n") + "\n")
98
+ return null
99
+ }
100
+
101
+ /** Flatten tree for mark action (index-based) */
102
+ function flatten(items, out = []) {
103
+ for (const item of items) {
104
+ out.push(item)
105
+ if (item.children?.length) flatten(item.children, out)
106
+ }
107
+ return out
38
108
  }
39
109
 
40
110
  /** Parse pending items only (for context injection) */
41
111
  export function pendingItems(cwd) {
42
- return parse(checklistPath(cwd)).filter(i => i.status !== "done")
112
+ const flat = flatten(parse(checklistPath(cwd)))
113
+ return flat.filter(i => i.status !== "done")
43
114
  }
44
115
 
45
116
  export const checklistTool = {
@@ -66,6 +137,10 @@ export const checklistTool = {
66
137
  enum: ["pending", "in_progress", "done"],
67
138
  description: "New status (required for mark)"
68
139
  },
140
+ parent: {
141
+ type: "string",
142
+ description: "Parent task ID for tree-structured tasks (e.g. 'T1')"
143
+ },
69
144
  },
70
145
  required: ["action"],
71
146
  },
@@ -75,9 +150,33 @@ export const checklistTool = {
75
150
  case "add": {
76
151
  if (!args.item || typeof args.item !== "string") return "Error: 'item' is required for add"
77
152
  const items = parse(checklistPath(ctx.cwd))
78
- items.push({ index: items.length + 1, status: "pending", text: args.item })
153
+
154
+ let target = items
155
+ let parentId = null
156
+ if (args.parent) {
157
+ const found = findById(items, args.parent)
158
+ if (!found) return `Error: parent '${args.parent}' not found. Use 'list' to see all task IDs.`
159
+ target = found.item.children
160
+ parentId = found.item.id
161
+ }
162
+
163
+ // Auto-assign ID
164
+ let id
165
+ if (parentId) {
166
+ id = `${parentId}.${target.length + 1}`
167
+ } else {
168
+ let maxIdx = 0
169
+ for (const c of items) {
170
+ const m = c.id?.match(/^T(\d+)$/)
171
+ if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
172
+ }
173
+ id = `T${maxIdx + 1}`
174
+ }
175
+
176
+ const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
177
+ target.push(node)
79
178
  write(checklistPath(ctx.cwd), items)
80
- return `Added: [ ] ${args.item}`
179
+ return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
81
180
  }
82
181
  case "mark": {
83
182
  if (args.index == null) return "Error: 'index' is required for mark"
@@ -85,8 +184,9 @@ export const checklistTool = {
85
184
  if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
86
185
  const cp = checklistPath(ctx.cwd)
87
186
  const items = parse(cp)
88
- if (args.index < 1 || args.index > items.length) return `Error: index ${args.index} out of range (1-${items.length})`
89
- const item = items[args.index - 1]
187
+ const flat = flatten(items)
188
+ if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
189
+ const item = flat[args.index - 1]
90
190
  const old = item.status
91
191
  if (old === status) return `Already ${status}: ${item.text}`
92
192
  item.status = status
@@ -94,18 +194,30 @@ export const checklistTool = {
94
194
  // Move to done file
95
195
  const dp = donePath(ctx.cwd)
96
196
  const doneItems = parse(dp)
97
- doneItems.push(item)
197
+ doneItems.push({ id: item.id, index: 0, depth: 0, status: "done", text: item.text, children: [] })
98
198
  write(dp, doneItems)
99
- items.splice(args.index - 1, 1)
199
+ // Remove from tree
200
+ const found = findById(items, item.id)
201
+ if (found) found.parent.splice(found.idx, 1)
100
202
  }
101
203
  write(cp, items)
102
- return `Marked #${args.index} ${old} → ${status}: ${item.text}`
204
+ return `Marked #${args.index} ${old} → ${status}: ${item.id}: ${item.text}`
103
205
  }
104
206
  case "list": {
105
207
  const items = parse(checklistPath(ctx.cwd))
106
208
  if (items.length === 0) return "(checklist is empty)"
107
209
  const marks = { pending: " ", in_progress: "~", done: "x" }
108
- return items.map(i => `- [${marks[i.status]}] ${i.text}`).join("\n")
210
+ const lines = []
211
+ function render(nodes, depth) {
212
+ const indent = " ".repeat(depth)
213
+ for (const n of nodes) {
214
+ const idTag = n.id ? `${n.id}: ` : ""
215
+ lines.push(`${indent}- [${marks[n.status]}] ${idTag}${n.text}`)
216
+ if (n.children?.length) render(n.children, depth + 1)
217
+ }
218
+ }
219
+ render(items, 0)
220
+ return lines.join("\n")
109
221
  }
110
222
  default:
111
223
  return `Error: unknown action '${args.action}'`
@@ -6,6 +6,7 @@ import {
6
6
  autoSyntaxCheck,
7
7
  resolveInCwd,
8
8
  resolveExternal,
9
+ normalizeEOL,
9
10
  } from "./shared.mjs";
10
11
  import { specForModel } from "../config.mjs";
11
12
  import { createHash } from "node:crypto";
@@ -39,7 +40,7 @@ export const readTool = {
39
40
  // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
40
41
  const st = await stat(abs).catch(() => null)
41
42
  if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
42
- const content = await readFile(abs, "utf8")
43
+ const content = normalizeEOL(await readFile(abs, "utf8"))
43
44
  const lines = content.split("\n")
44
45
  const offset = Math.max(1, args.offset ?? 1)
45
46
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
@@ -153,7 +154,7 @@ export const editTool = {
153
154
  if (!args.old_string) {
154
155
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
155
156
  }
156
- const content = await readFile(abs, "utf8")
157
+ const content = normalizeEOL(await readFile(abs, "utf8"))
157
158
  const occurrences = content.split(args.old_string).length - 1
158
159
  if (occurrences === 0) {
159
160
  // Give clues to help the model locate: first-line preview + common causes
@@ -195,7 +196,7 @@ export const insertAfterTool = {
195
196
  readonly: false,
196
197
  async execute(args, ctx) {
197
198
  const abs = resolveInCwd(ctx, args.path)
198
- const text = await readFile(abs, "utf8")
199
+ const text = normalizeEOL(await readFile(abs, "utf8"))
199
200
  const lines = text.split("\n")
200
201
 
201
202
  let targetLine
@@ -208,7 +209,12 @@ export const insertAfterTool = {
208
209
  throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
209
210
  }
210
211
  } else if (args.after_regex) {
211
- const regex = new RegExp(args.after_regex)
212
+ let regex
213
+ try {
214
+ regex = new RegExp(args.after_regex)
215
+ } catch (e) {
216
+ throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
217
+ }
212
218
  const matches = []
213
219
  for (let i = 0; i < lines.length; i++) {
214
220
  if (regex.test(lines[i])) matches.push(i + 1)
@@ -254,22 +260,24 @@ export const hashlineEditTool = {
254
260
  async execute(args, ctx) {
255
261
  const abs = resolveInCwd(ctx, args.path)
256
262
  if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
257
- const content = await readFile(abs, "utf8")
263
+ const content = normalizeEOL(await readFile(abs, "utf8"))
258
264
  const lines = content.split("\n")
259
265
  const fileHashes = lines.map((l) => hashLine(l))
260
266
  const target = args.old_hashes
261
267
 
262
- // Sliding-window match: find the exact sequence of hashes
263
- let pos = -1
268
+ // Sliding-window match: find all occurrences of the hash sequence.
269
+ // When multiple matches are found (e.g. empty lines), report positions so the
270
+ // model can include more context lines (adjacent lines with unique hashes).
271
+ const matches = []
264
272
  for (let i = 0; i <= fileHashes.length - target.length; i++) {
265
273
  let match = true
266
274
  for (let j = 0; j < target.length; j++) {
267
275
  if (fileHashes[i + j] !== target[j]) { match = false; break }
268
276
  }
269
- if (match) { pos = i; break }
277
+ if (match) matches.push(i)
270
278
  }
271
279
 
272
- if (pos === -1) {
280
+ if (matches.length === 0) {
273
281
  // Help the model recover: show the current file hashes for context
274
282
  const maxShow = Math.min(fileHashes.length, 50)
275
283
  const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
@@ -280,6 +288,26 @@ export const hashlineEditTool = {
280
288
  )
281
289
  }
282
290
 
291
+ if (matches.length > 1) {
292
+ const ctx = 2 // lines of surrounding context
293
+ const detail = matches.map((m) => {
294
+ const start = Math.max(0, m - ctx)
295
+ const end = Math.min(lines.length, m + target.length + ctx)
296
+ const preview = lines.slice(start, end).map((l, i) => {
297
+ const ln = start + i + 1
298
+ const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
299
+ return `${marker} L${ln}: ${l.slice(0, 80)}`
300
+ }).join("\n")
301
+ return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
302
+ }).join("\n\n")
303
+ throw new Error(
304
+ `Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
305
+ `Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
306
+ `All matches with surrounding context:\n\n${detail}`
307
+ )
308
+ }
309
+
310
+ const pos = matches[0]
283
311
  // Replace: remove old lines, insert new lines at the same position
284
312
  const newLines = args.new_content.split("\n")
285
313
  lines.splice(pos, target.length, ...newLines)
@@ -6,7 +6,7 @@ import {
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { mkdir } from "node:fs/promises";
8
8
  import { readFile } from "node:fs/promises";
9
- import { stat } from "node:fs/promises";
9
+ import { stat, lstat } from "node:fs/promises";
10
10
  import { writeFile } from "node:fs/promises";
11
11
  import { unlink } from "node:fs/promises";
12
12
  import { existsSync } from "node:fs";
@@ -206,8 +206,12 @@ export const deleteTool = {
206
206
  readonly: false,
207
207
  async execute(args, ctx) {
208
208
  const abs = resolveInCwd(ctx, args.path)
209
- if (!existsSync(abs)) throw new Error(`File not found: ${args.path}`)
210
- const s = await stat(abs)
209
+ let s
210
+ try {
211
+ s = await lstat(abs)
212
+ } catch {
213
+ throw new Error(`File not found: ${args.path}`)
214
+ }
211
215
  if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
212
216
  // git-tracked files: refuse direct deletion (safety net); untracked: allow
213
217
  // Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
@@ -1,11 +1,12 @@
1
1
  /**
2
- * repomap.mjs — repo dependency outline (zero dependencies, pure regex)
2
+ * repomap.mjs — repo dependency outline
3
3
  * Real-time import/export parsing, generates compact text for LLMs to understand code structure.
4
4
  * No index stored — reads and parses files on each call, ~50ms.
5
5
  */
6
6
  import { existsSync } from "node:fs"
7
- import { readFile } from "node:fs/promises"
7
+ import { readFile, stat } from "node:fs/promises"
8
8
  import { join } from "node:path"
9
+ import { normalizeEOL } from "./shared.mjs"
9
10
 
10
11
  /** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
11
12
  function parseImports(lines, ext) {
@@ -125,8 +126,11 @@ async function _buildDepGraph(db, cwd) {
125
126
  const rel = allFiles[i]
126
127
  const abs = join(cwd, ...rel.split("/"))
127
128
  if (!existsSync(abs)) continue
129
+ // Large file guard: skip files over 10MB to prevent OOM
130
+ const fst = await stat(abs).catch(() => null)
131
+ if (fst && fst.size > 10_000_000) continue
128
132
  let text
129
- try { text = await readFile(abs, "utf8") } catch { continue }
133
+ try { text = normalizeEOL(await readFile(abs, "utf8")) } catch { continue }
130
134
  const lines = text.split("\n")
131
135
  const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
132
136
 
@@ -20,6 +20,13 @@ export const BASH_TIMEOUT_MS = 120_000
20
20
  export const MAX_RESPONSE_BODY_BYTES = 5_000_000
21
21
  export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
22
22
 
23
+ /** Normalize Windows line endings to Unix: \r\n → \n.
24
+ * Applied on every text-file read so that edit/hash matching
25
+ * and hash computation are platform-consistent. */
26
+ export function normalizeEOL(text) {
27
+ return text.replace(/\r\n/g, "\n")
28
+ }
29
+
23
30
  /** Convert to OpenAI tools parameter format */
24
31
  export function toOpenAISchema(tool) {
25
32
  return {
@@ -11,7 +11,8 @@ import {
11
11
  isDestructiveCommand,
12
12
  hasFileRedirection,
13
13
  insideGitRepo,
14
- globToRegex
14
+ globToRegex,
15
+ normalizeEOL,
15
16
  } from "./shared.mjs";
16
17
  import { spawn, execFileSync } from "node:child_process";
17
18
  import { readFile, readdir, stat, lstat } from "node:fs/promises";
@@ -222,6 +223,8 @@ async function* walkFiles(dir, rel = "") {
222
223
  return
223
224
  }
224
225
  for (const e of entries) {
226
+ // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
227
+ if (e.isSymbolicLink()) continue
225
228
  if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
226
229
  const relPath = rel ? `${rel}/${e.name}` : e.name
227
230
  if (e.isDirectory()) {
@@ -253,7 +256,12 @@ export const grepTool = {
253
256
  readonly: true,
254
257
  async execute(args, ctx) {
255
258
  const base = resolveInCwd(ctx, args.path ?? ".")
256
- const regex = new RegExp(args.pattern)
259
+ let regex
260
+ try {
261
+ regex = new RegExp(args.pattern)
262
+ } catch (e) {
263
+ throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
264
+ }
257
265
  const fileFilter = args.glob ? globToRegex(args.glob) : null
258
266
  const before = Math.max(0, Math.floor(args.before ?? 0))
259
267
  const after = Math.max(0, Math.floor(args.after ?? 0))
@@ -267,7 +275,7 @@ export const grepTool = {
267
275
  // Large file guard: skip files over 10MB to prevent OOM
268
276
  const fst = await stat(file)
269
277
  if (fst.size > 10_000_000) return
270
- content = await readFile(file, "utf8")
278
+ content = normalizeEOL(await readFile(file, "utf8"))
271
279
  } catch {
272
280
  return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
273
281
  }
@@ -346,7 +354,13 @@ export const lsTool = {
346
354
  readonly: true,
347
355
  async execute(args, ctx) {
348
356
  const abs = resolveInCwd(ctx, args.path ?? ".")
349
- const entries = await readdir(abs, { withFileTypes: true })
357
+ let entries
358
+ try {
359
+ entries = await readdir(abs, { withFileTypes: true })
360
+ } catch (e) {
361
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`)
362
+ throw e
363
+ }
350
364
  const rows = await Promise.all(
351
365
  entries.slice(0, 500).map(async (e) => {
352
366
  const s = await stat(join(abs, e.name)).catch(() => null)
@@ -218,6 +218,14 @@ export async function runAgentTurn(ctx, text) {
218
218
  } catch (error) {
219
219
  flushStream()
220
220
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
221
+ // Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
222
+ // may have already injected it into history, but the aborted signal prevents retry.
223
+ // Recreate the controller and resume from the same context.
224
+ if (state.controller?.signal?.reason?.interrupt) {
225
+ state.controller = new AbortController()
226
+ resume = true
227
+ continue
228
+ }
221
229
  pushLine("[stopped]", C.warn)
222
230
  break
223
231
  }
package/src/tui/ansi.mjs CHANGED
@@ -17,6 +17,10 @@ export const ansi = {
17
17
  clearLine: `${ESC}[K`,
18
18
  clearToEnd: `${ESC}[J`,
19
19
  clearScreen: `${ESC}[2J`,
20
+ saveCursor: `${ESC}7`, // DECSC — save cursor position
21
+ restoreCursor: `${ESC}8`, // DECRC — restore cursor position
22
+ syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
23
+ syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
20
24
  reset: `${ESC}[0m`,
21
25
  dim: `${ESC}[2m`,
22
26
  bold: `${ESC}[1m`,
@@ -34,4 +38,5 @@ export const C = {
34
38
  dim: ansi.gray,
35
39
  warn: ansi.fg(3),
36
40
  advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
41
+ fold: `${ESC}[2m${ESC}[37m`, // dim white — fold hints
37
42
  }
@@ -56,11 +56,11 @@ async function openAdvisorModelPicker(ctx) {
56
56
  // Same as main — clear override (use main pool)
57
57
  delete cfg.provider
58
58
  delete cfg.model
59
- pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`)
59
+ pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
60
60
  } else {
61
61
  cfg.provider = e.provider
62
62
  cfg.model = e.model
63
- pushLine(`Advisor: ${e.provider}/${e.model}`)
63
+ pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
64
64
  }
65
65
  }
66
66
  },
@@ -0,0 +1,21 @@
1
+ /**
2
+ * cmd-fold.mjs — /fold command: toggle conversation result folding
3
+ */
4
+ import { C } from "./ansi.mjs"
5
+
6
+ export async function handleFoldCommand(ctx) {
7
+ const { state } = ctx
8
+ const text = state.input.join("").trim()
9
+ const arg = text.split(/\s+/)[1]
10
+ if (arg === "on") {
11
+ state.foldEnabled = true
12
+ ctx.pushLine("Folding: on (long tool results are collapsed)", C.dim)
13
+ } else if (arg === "off") {
14
+ state.foldEnabled = false
15
+ ctx.pushLine("Folding: off (all results shown in full)", C.dim)
16
+ } else {
17
+ state.foldEnabled = !state.foldEnabled
18
+ ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.dim)
19
+ }
20
+ ctx.render()
21
+ }
@@ -124,12 +124,53 @@ export async function handleMcpCommand(ctx) {
124
124
  openPicker({
125
125
  title: "MCP Transport",
126
126
  entries: [
127
- { type: "header", text: "Select server transport" },
127
+ { type: "header", text: "Select transport or use AI assist" },
128
+ { type: "item", text: "🤖 Describe with AI — natural language → config", action: "ai" },
128
129
  { type: "item", text: "HTTP (https://…)", action: "http" },
129
130
  { type: "item", text: "WebSocket (ws://…)", action: "ws" },
130
131
  { type: "item", text: "stdio (local command)", action: "stdio" },
131
132
  ],
132
133
  onSelect: async (te) => {
134
+ if (te.action === "ai") {
135
+ const description = await askQuestion("Describe the MCP server you want to add (e.g. 'a filesystem server that gives access to /tmp'):")
136
+ if (!description) return
137
+ pushLine("[mcp] Generating config from description...", C.dim)
138
+ try {
139
+ const { chat } = await import("../provider/index.mjs")
140
+ const res = await chat(agent.provider, {
141
+ messages: [{
142
+ role: "user",
143
+ content: `Generate an MCP server configuration JSON from this description. Return ONLY the JSON object, no explanation.
144
+
145
+ Description: "${description}"
146
+
147
+ The JSON should have these fields:
148
+ - name: a short identifier
149
+ - One of: url (HTTP), wsUrl (WebSocket), or command + args (stdio)
150
+ - headers: optional key-value object
151
+
152
+ Example HTTP: {"name":"filesystem","url":"https://example.com/mcp","headers":{"Authorization":"Bearer xxx"}}
153
+ Example stdio: {"name":"filesystem","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}
154
+
155
+ Return ONLY the JSON object:`,
156
+ }],
157
+ tools: [],
158
+ signal: AbortSignal.timeout(15_000),
159
+ })
160
+ const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
161
+ if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
162
+ const srv = JSON.parse(jsonMatch[0])
163
+ if (!srv.name) { pushLine("[mcp] AI response missing 'name' field", C.error); return }
164
+ // Show preview and confirm
165
+ pushLine(`[mcp] Generated config: ${JSON.stringify(srv)}`, C.tool)
166
+ const confirm = await askQuestion("Add this server? (y/n):")
167
+ if (confirm?.toLowerCase() !== "y") { pushLine("[mcp] Cancelled", C.dim); return }
168
+ await addAndConnect(ctx, srv)
169
+ } catch (err) {
170
+ pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
171
+ }
172
+ return
173
+ }
133
174
  const name = await askQuestion("Server name:")
134
175
  if (!name) return
135
176
  const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
@@ -0,0 +1,91 @@
1
+ /**
2
+ * cmd-undo.mjs — /undo command: revert recent file modifications
3
+ *
4
+ * Tracks write/edit/delete/hashline_edit/apply_patch operations in agent._undoStack.
5
+ * /undo opens a picker to select and revert an operation.
6
+ */
7
+
8
+ import { existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { ansi, C } from "./ansi.mjs"
11
+
12
+ const MAX_UNDO = 50
13
+
14
+ /**
15
+ * Snapshot a file before a side-effect tool modifies it.
16
+ * Called from dispatch.mjs before each write/edit/delete/apply_patch/hashline_edit.
17
+ */
18
+ export function snapshotForUndo(agent, toolName, args, cwd) {
19
+ if (!agent._undoStack) agent._undoStack = []
20
+ const path = args.path ?? args.file
21
+ if (!path || typeof path !== "string") return
22
+
23
+ const abs = join(cwd, ...path.split("/"))
24
+ let backup = null
25
+ try {
26
+ if (existsSync(abs)) {
27
+ backup = readFileSync(abs, "utf8")
28
+ }
29
+ } catch {
30
+ // can't read — maybe binary, skip
31
+ return
32
+ }
33
+
34
+ agent._undoStack.push({
35
+ tool: toolName,
36
+ path,
37
+ backup,
38
+ timestamp: Date.now(),
39
+ })
40
+ if (agent._undoStack.length > MAX_UNDO) agent._undoStack.shift()
41
+ }
42
+
43
+ export async function handleUndoCommand(ctx) {
44
+ const { agent, pushLine, openPicker } = ctx
45
+ const stack = agent._undoStack ?? []
46
+
47
+ if (stack.length === 0) {
48
+ pushLine("[undo] Nothing to undo — no file modifications tracked yet.", C.dim)
49
+ return
50
+ }
51
+
52
+ const entries = [
53
+ { type: "header", text: `${stack.length} operation(s) available to undo (most recent first)` },
54
+ ...stack.map((item, i) => {
55
+ const relIdx = stack.length - i
56
+ const time = new Date(item.timestamp).toLocaleTimeString()
57
+ const preview = item.backup === null
58
+ ? "(was created — undo will delete)"
59
+ : `(${item.backup.split("\n").length} lines — undo will restore)`
60
+ return {
61
+ type: "item",
62
+ text: `#${relIdx} ${item.tool}: ${item.path} ${preview} — ${time}`,
63
+ idx: i,
64
+ }
65
+ }),
66
+ ]
67
+
68
+ openPicker({
69
+ title: "Undo",
70
+ entries,
71
+ onSelect: async (e) => {
72
+ const item = stack[e.idx]
73
+ const abs = join(agent.cwd, ...item.path.split("/"))
74
+
75
+ try {
76
+ if (item.backup === null) {
77
+ // File was created — undo deletes it
78
+ if (existsSync(abs)) unlinkSync(abs)
79
+ } else {
80
+ // File was modified — undo restores original
81
+ writeFileSync(abs, item.backup, "utf8")
82
+ }
83
+ // Remove this and all newer entries (can't undo out of order)
84
+ stack.splice(e.idx)
85
+ pushLine(`[undo] Reverted: ${item.tool} ${item.path}`, C.tool)
86
+ } catch (err) {
87
+ pushLine(`[undo] Failed to revert ${item.path}: ${err.message}`, C.error)
88
+ }
89
+ },
90
+ })
91
+ }