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.
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +20 -2
- package/src/agent/setup.mjs +0 -3
- package/src/agent-tools/goal.mjs +38 -1
- package/src/agent.mjs +6 -7
- package/src/auto-think.mjs +1 -1
- package/src/config.mjs +2 -0
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/hooks.mjs +93 -0
- package/src/mcp/transport-http.mjs +3 -2
- package/src/memory/docs.mjs +1 -2
- package/src/provider/core.mjs +8 -1
- package/src/session.mjs +8 -2
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- package/src/tools/file.mjs +37 -9
- package/src/tools/patch.mjs +7 -3
- package/src/tools/repomap.mjs +7 -3
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +8 -0
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +2 -2
- package/src/tui/cmd-fold.mjs +21 -0
- package/src/tui/cmd-mcp.mjs +42 -1
- package/src/tui/cmd-undo.mjs +91 -0
- package/src/tui/index.mjs +167 -53
- package/src/tui/key-handler.mjs +2 -2
- package/src/tui/layout.mjs +3 -3
- package/src/tui/render-frame.mjs +266 -171
- package/src/tui/slash-commands.mjs +6 -0
package/src/tools/checklist.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}'`
|
package/src/tools/file.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
263
|
-
|
|
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)
|
|
277
|
+
if (match) matches.push(i)
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
if (
|
|
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)
|
package/src/tools/patch.mjs
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
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
|
package/src/tools/repomap.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repomap.mjs — repo dependency outline
|
|
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
|
|
package/src/tools/shared.mjs
CHANGED
|
@@ -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 {
|
package/src/tools/system.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -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
|
}
|
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/cmd-mcp.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|