thincoder 0.8.13 → 0.10.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 +16 -0
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +20 -2
- package/src/agent-tools/goal.mjs +38 -1
- package/src/config.mjs +2 -0
- package/src/context.mjs +18 -0
- package/src/hooks.mjs +93 -0
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- package/src/tools/codemode.mjs +178 -0
- package/src/tools/index.mjs +4 -2
- package/src/tools/lsp.mjs +317 -0
- package/src/tui/ansi.mjs +1 -0
- 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 +2 -0
- package/src/tui/render-frame.mjs +35 -4
- package/src/tui/slash-commands.mjs +6 -0
package/README.md
CHANGED
|
@@ -205,6 +205,22 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
205
|
|
|
206
206
|
## Changelog
|
|
207
207
|
|
|
208
|
+
### 0.10.0 (2026-07)
|
|
209
|
+
- **LSP tool** — `lsp` tool provides code intelligence via Language Server Protocol: go-to-definition, find-references, hover info, document symbols, diagnostics. Zero-dependency JSON-RPC 2.0 over stdio client. Lazy-starts language servers on first call. Configurable via `lsp.servers` in config.json (defaults: `typescript-language-server` for JS/TS, `pyright-langserver` for Python).
|
|
210
|
+
- **Smart context: compaction checkpoint** — `compressIfNeeded` now auto-creates a git checkpoint before compaction. A checkpoint reference is injected after compaction so the model can reconstruct context from git diff + recent messages + task progress. Prevents information loss during long sessions.
|
|
211
|
+
- **CodeMode: sandboxed JS execution** — `execute` tool backed by `vm.Script.runInNewContext`. Compose multiple file operations (read/write/glob/grep/log) into a single script, reducing API round-trips and keeping intermediate results out of context. Sandbox strips all Node APIs, limits output to 50KB, enforces 30s timeout, and blocks private IPs in fetch. Script size capped at 50KB.
|
|
212
|
+
|
|
213
|
+
### 0.9.0 (2026-07)
|
|
214
|
+
- **Config JSON Schema** — `saveConfig` auto-injects `$schema` reference; `docs/schemas/config.schema.json` provides editor autocompletion/validation for all config fields including the new `hooks` section.
|
|
215
|
+
- **Lifecycle Hooks** — `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `Notification` events. User-defined shell commands in config, with per-tool regex matching, timeout control, and `block`/`allow`/`notify` actions. Implemented in `src/hooks.mjs`, integrated into tool dispatch.
|
|
216
|
+
- **Built-in Skills (5)** — `pdf-create`, `xlsx-create`, `frontend-design`, `code-review`, `api-design` ship with the installation. Each is a standalone markdown instruction file using zero-dependency approaches (Chrome headless for PDF, PowerShell for Excel, etc.).
|
|
217
|
+
- **Conversation message folding** — Long tool result blocks (>8 consecutive dim lines) auto-collapse to first 2 lines + "… N more lines — Enter to expand". `/fold on|off` toggles globally.
|
|
218
|
+
- **Tree-shaped tasks** — `checklist` tool now supports hierarchical task IDs (`T1`, `T1.1`, `T1.2.1`) with auto-assigned numbering. `add` accepts `parent` parameter for subtree positioning. Indentation-based persistence in `checklist.md`.
|
|
219
|
+
- **AI-native MCP config** — `/mcp add` picker now includes "Describe with AI" option. Describe a server in natural language → model generates config JSON → preview + confirm → save and connect.
|
|
220
|
+
- **Goal judge model** — `goal complete` now runs an independent LLM check: goal criteria + recent agent activity → judge model verifies YES/NO. Prevents false completion claims during autonomous work.
|
|
221
|
+
- **`/undo` command** — Tracks up to 50 write/edit/delete operations in `agent._undoStack`. `/undo` opens a picker showing each operation's file and original content size. Select to revert.
|
|
222
|
+
- **Roadmap**: LSP tool, intelligent context management (checkpoint-based reconstruction), and CodeMode sandboxed JS are planned for 0.10.0.
|
|
223
|
+
|
|
208
224
|
### 0.8.13 (2026-07)
|
|
209
225
|
- **TUI: incremental rendering** — panel-level cache (`panelCache`) with sync-update bracketing (`DECSET 2026`). Only redraws changed panels, eliminating flicker. `saveCursor`/`restoreCursor` for efficient cursor positioning. Panel order reorganized: `header → conversation → subagent → output → todo → picker → permission → queue → input → status`.
|
|
210
226
|
- **Ctrl+I inject resume** — Ctrl+I (or Tab during processing) now properly interrupts, injects the message, and *resumes* the agent loop. Controller is recreated after abort. Added active signal check in SSE read loop for faster abort on Windows.
|
package/package.json
CHANGED
package/src/agent/dispatch.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* agent/dispatch.mjs — two-phase tool call execution
|
|
3
3
|
*/
|
|
4
4
|
import { offloadToolResult } from "./helpers.mjs"
|
|
5
|
+
import { runHooks } from "../hooks.mjs"
|
|
6
|
+
import { snapshotForUndo } from "../tui/cmd-undo.mjs"
|
|
5
7
|
import { writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
6
8
|
import { join } from "node:path"
|
|
7
9
|
import { homedir } from "node:os"
|
|
@@ -80,6 +82,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
callbacks.onToolCall?.(toolCall.name, args)
|
|
85
|
+
|
|
86
|
+
// PreToolUse hooks: allow user scripts to gate tool execution
|
|
87
|
+
if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
|
|
88
|
+
prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
prepared.push({ toolCall, tool, args })
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -91,11 +100,17 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
91
100
|
? "Error: plan mode is active — only read-only tools are allowed. Exit plan mode first."
|
|
92
101
|
: item.reason === "denied by user"
|
|
93
102
|
? "Error: permission denied by user"
|
|
94
|
-
:
|
|
103
|
+
: item.reason === "blocked by PreToolUse hook"
|
|
104
|
+
? "Error: blocked by PreToolUse hook"
|
|
105
|
+
: "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
|
|
95
106
|
return { ...item, result: reason, ok: false }
|
|
96
107
|
}
|
|
97
108
|
try {
|
|
98
|
-
|
|
109
|
+
// Snapshot for undo before side-effect tools
|
|
110
|
+
if (item.tool?.outputPanel) callbacks.setupOutputPanel?.(item.toolCall.name)
|
|
111
|
+
if (!item.tool?.readonly && item.args) {
|
|
112
|
+
snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
|
|
113
|
+
}
|
|
99
114
|
const rawResult = await item.tool.execute(item.args, {
|
|
100
115
|
cwd: agent.cwd,
|
|
101
116
|
agent,
|
|
@@ -110,10 +125,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
110
125
|
const raw = String(rawResult)
|
|
111
126
|
const result = item.toolCall.name === "read_image" ? raw : await offloadToolResult(raw, item.toolCall.id)
|
|
112
127
|
callbacks.onToolResult?.(item.toolCall.name, result)
|
|
128
|
+
// PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
|
|
129
|
+
runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
|
|
113
130
|
return { ...item, result, ok: true }
|
|
114
131
|
} catch (error) {
|
|
115
132
|
// Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
|
|
116
133
|
logToolError(item.toolCall.name, item.args, error)
|
|
134
|
+
runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
|
|
117
135
|
return { ...item, result: `Error: ${error.message}`, ok: false }
|
|
118
136
|
}
|
|
119
137
|
}
|
package/src/agent-tools/goal.mjs
CHANGED
|
@@ -52,8 +52,45 @@ export const goalTool = {
|
|
|
52
52
|
if (agent._mutatedThisRun && !agent._verifiedThisRun) {
|
|
53
53
|
return "Error: files were modified but verify has not run. Run the check your criteria names AND the verify tool before marking the goal complete — false completion is the worst outcome of autonomous work."
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
// Independent judge: verify the goal was actually achieved
|
|
57
|
+
// Only applies when the agent is at depth 0 (not a subagent) and has history to review
|
|
58
|
+
if (ctx.depth === 0 && agent.history.length > 2) {
|
|
59
|
+
try {
|
|
60
|
+
// Extract recent activity: last 4 assistant messages (summarizing what was done)
|
|
61
|
+
const recent = agent.history.filter(m => m.role === "assistant").slice(-4)
|
|
62
|
+
const activity = recent.map(m => (m.content ?? "").slice(0, 500)).join("\n---\n")
|
|
63
|
+
const { chat } = await import("../provider/index.mjs")
|
|
64
|
+
const judgeRes = await chat(agent.provider, {
|
|
65
|
+
messages: [{
|
|
66
|
+
role: "user",
|
|
67
|
+
content: `You are an independent goal judge. Evaluate whether this goal has been achieved based on the agent's activity.
|
|
68
|
+
|
|
69
|
+
Goal: ${agent.goal.objective}
|
|
70
|
+
Success criteria: ${agent.goal.criteria}
|
|
71
|
+
|
|
72
|
+
Recent agent activity:
|
|
73
|
+
${activity || "(no activity recorded)"}
|
|
74
|
+
|
|
75
|
+
Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentence reason.`,
|
|
76
|
+
}],
|
|
77
|
+
tools: [],
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
})
|
|
80
|
+
const verdict = (judgeRes.content ?? "").trim()
|
|
81
|
+
if (verdict.toUpperCase().startsWith("NO")) {
|
|
82
|
+
return `Goal NOT complete (judge says NO): ${verdict.slice(2).trim()}\n\nContinue working or report blocked if this is a true impasse.`
|
|
83
|
+
}
|
|
84
|
+
if (!verdict.toUpperCase().startsWith("YES")) {
|
|
85
|
+
return `Goal completion unverified — judge response ambiguous: "${verdict.slice(0, 200)}". Re-check your criteria and try again with clear evidence.`
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Judge unavailable — allow completion but note it
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
agent.goal.status = "complete"
|
|
56
|
-
return `Goal
|
|
93
|
+
return `Goal verified complete ✓: ${agent.goal.objective}\nIn your next message, summarize the evidence (what check ran, what it showed) — the user should be able to audit this claim.`
|
|
57
94
|
}
|
|
58
95
|
if (args.action === "blocked") {
|
|
59
96
|
if (!args.reason) return "Error: 'reason' required for 'blocked' action."
|
package/src/config.mjs
CHANGED
|
@@ -218,6 +218,8 @@ export function loadConfig() {
|
|
|
218
218
|
*/
|
|
219
219
|
export function saveConfig(config) {
|
|
220
220
|
mkdirSync(configDir, { recursive: true })
|
|
221
|
+
// Inject $schema for editor autocompletion/validation (strip on load)
|
|
222
|
+
config.$schema = "https://thincoder.dev/schemas/config.json"
|
|
221
223
|
// 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
|
|
222
224
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
223
225
|
try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
|
package/src/context.mjs
CHANGED
|
@@ -178,7 +178,25 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
|
178
178
|
onReasoning: callbacks?.onReasoning,
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
// Auto-checkpoint before compaction: snapshot current state so the model can
|
|
182
|
+
// reconstruct context from git diff + recent messages + task progress later.
|
|
183
|
+
let cpId = null
|
|
184
|
+
try {
|
|
185
|
+
const { createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
186
|
+
const cp = await createCheckpoint(agent.cwd)
|
|
187
|
+
cpId = cp?.id
|
|
188
|
+
} catch { /* checkpoint might fail — compaction itself should not be blocked */ }
|
|
189
|
+
|
|
181
190
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
191
|
+
|
|
192
|
+
// Inject checkpoint reference after compaction so the model knows it can use /restore
|
|
193
|
+
if (cpId) {
|
|
194
|
+
agent.history.splice(split.headEnd, 0, {
|
|
195
|
+
role: "user",
|
|
196
|
+
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.]`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
return true
|
|
183
201
|
}
|
|
184
202
|
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks.mjs — Lifecycle hook engine
|
|
3
|
+
*
|
|
4
|
+
* Hooks are user-defined shell commands executed at key agent lifecycle points.
|
|
5
|
+
* Configured in ~/.thincoder/config.json under "hooks".
|
|
6
|
+
*
|
|
7
|
+
* Event types:
|
|
8
|
+
* PreToolUse — before each tool call (can block execution)
|
|
9
|
+
* PostToolUse — after successful tool execution
|
|
10
|
+
* PostToolUseFailure — after failed tool execution
|
|
11
|
+
* Notification — generic notification (triggered by agent)
|
|
12
|
+
*
|
|
13
|
+
* Each hook: { matcher?, command, args?, timeout?, action? }
|
|
14
|
+
* matcher: regex against tool name (default: match all)
|
|
15
|
+
* command: executable path/name
|
|
16
|
+
* args: optional CLI args; without args, stdin receives JSON payload
|
|
17
|
+
* timeout: ms (default 10000)
|
|
18
|
+
* action: "allow" | "block" | "notify" (default "notify")
|
|
19
|
+
*
|
|
20
|
+
* "block" hooks: exit code 0 = allow, non-zero = block.
|
|
21
|
+
* "allow"/"notify": exit code ignored.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process"
|
|
25
|
+
|
|
26
|
+
/** @param {string} event @param {object} ctx @returns {Promise<boolean>} false if blocked */
|
|
27
|
+
export async function runHooks(event, ctx) {
|
|
28
|
+
const hooks = ctx.agent?.config?.hooks?.[event]
|
|
29
|
+
if (!hooks?.length) return true
|
|
30
|
+
|
|
31
|
+
for (const hook of hooks) {
|
|
32
|
+
if (hook.matcher) {
|
|
33
|
+
try {
|
|
34
|
+
if (!new RegExp(hook.matcher).test(ctx.toolName ?? "")) continue
|
|
35
|
+
} catch { /* invalid regex → skip */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allowed = await runOneHook(event, hook, ctx)
|
|
39
|
+
if (hook.action === "block" && !allowed) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runOneHook(event, hook, ctx) {
|
|
45
|
+
const payload = JSON.stringify({
|
|
46
|
+
event,
|
|
47
|
+
toolName: ctx.toolName ?? null,
|
|
48
|
+
toolArgs: ctx.toolArgs ?? null,
|
|
49
|
+
result: ctx.result ?? null,
|
|
50
|
+
error: ctx.error?.message ?? null,
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const timeout = hook.timeout ?? 10_000
|
|
56
|
+
let settled = false
|
|
57
|
+
const done = (code) => {
|
|
58
|
+
if (settled) return
|
|
59
|
+
settled = true
|
|
60
|
+
resolve(code === 0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let proc
|
|
64
|
+
try {
|
|
65
|
+
if (hook.args?.length) {
|
|
66
|
+
proc = spawn(hook.command, hook.args, {
|
|
67
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
68
|
+
timeout,
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
})
|
|
71
|
+
} else {
|
|
72
|
+
proc = spawn(hook.command, [], {
|
|
73
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
74
|
+
timeout,
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// command not found or spawn failure — don't block, don't crash
|
|
80
|
+
return resolve(true)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
proc.on("error", () => done(0)) // spawn failure → allow
|
|
84
|
+
proc.on("close", (code) => done(code ?? 0))
|
|
85
|
+
proc.on("exit", (code) => done(code ?? 0))
|
|
86
|
+
|
|
87
|
+
// Send payload via stdin
|
|
88
|
+
try { proc.stdin?.end(payload) } catch { /* */ }
|
|
89
|
+
|
|
90
|
+
// Timeout guard
|
|
91
|
+
setTimeout(() => done(0), timeout + 1000)
|
|
92
|
+
})
|
|
93
|
+
}
|
package/src/tools/checklist.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
Manage the task checklist in .thincoder/checklist.md. Use at these points: after requirements are confirmed — add one entry per requirement point; when starting work — mark in_progress; when verified complete — mark done. Checklist entries map to requirement/design points — project-level tracking across sessions. For in-session subtask breakdown of a single checklist item, use the `task` tool instead. Completed items are auto-archived to .thincoder/checklist-done.md.
|
|
2
2
|
|
|
3
|
+
Items support tree hierarchy via indentation (2 spaces per level) and auto-assigned IDs (T1, T1.1, T1.2.1). Use the `parent` parameter to add a child under an existing task.
|
|
4
|
+
|
|
3
5
|
Parameters:
|
|
4
6
|
- action: "add" | "mark" | "list"
|
|
5
7
|
- item: text for new item (with "add")
|
|
6
|
-
- index: 1-based index (with "mark")
|
|
8
|
+
- index: 1-based index (with "mark")
|
|
7
9
|
- status: "pending" | "in_progress" | "done" (with "mark")
|
|
10
|
+
- parent: parent task ID for hierarchical tasks, e.g. "T1" (with "add")
|
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}'`
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/codemode.mjs — CodeMode: sandboxed JS execution tool
|
|
3
|
+
*
|
|
4
|
+
* Gives the model an `execute` tool backed by Node.js vm.Script.runInNewContext.
|
|
5
|
+
* Multiple tool calls can be composed into a single script, reducing API round-trips
|
|
6
|
+
* and keeping large intermediate results out of context.
|
|
7
|
+
*
|
|
8
|
+
* Sandbox API (all sync, no callbacks):
|
|
9
|
+
* readFile(path) — read a file relative to cwd, return string
|
|
10
|
+
* writeFile(path, c) — write content to a file (auto-creates parent dirs)
|
|
11
|
+
* glob(pattern) — return array of matching paths
|
|
12
|
+
* grep(pattern, file) — return array of matching lines
|
|
13
|
+
* log(...args) — append to output buffer
|
|
14
|
+
* fetch(url) — HTTP GET, return string (SSRF-protected)
|
|
15
|
+
*
|
|
16
|
+
* Not available: require, import, process, child_process, setTimeout, any Node API.
|
|
17
|
+
*
|
|
18
|
+
* Limits:
|
|
19
|
+
* timeout: 30s (configurable via timeoutMs param)
|
|
20
|
+
* maxOutput: 50000 bytes
|
|
21
|
+
* maxScriptSize: 50000 bytes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Script, createContext } from "node:vm"
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
26
|
+
import { join, dirname, relative, resolve } from "node:path"
|
|
27
|
+
import { globToRegex, normalizeEOL } from "./shared.mjs"
|
|
28
|
+
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
30
|
+
const MAX_SCRIPT = 50_000
|
|
31
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
32
|
+
|
|
33
|
+
/** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout */
|
|
34
|
+
async function sandboxFetch(url) {
|
|
35
|
+
const parsed = new URL(url)
|
|
36
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
37
|
+
throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
|
|
38
|
+
}
|
|
39
|
+
// Block private/internal IPs
|
|
40
|
+
const hostname = parsed.hostname.toLowerCase()
|
|
41
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ||
|
|
42
|
+
hostname.startsWith("192.168.") || hostname.startsWith("10.") ||
|
|
43
|
+
hostname.startsWith("172.16.") || hostname.startsWith("172.17.") ||
|
|
44
|
+
hostname.startsWith("172.18.") || hostname.startsWith("172.19.") ||
|
|
45
|
+
hostname.startsWith("172.20.") || hostname.startsWith("172.21.") ||
|
|
46
|
+
hostname.startsWith("172.22.") || hostname.startsWith("172.23.") ||
|
|
47
|
+
hostname.startsWith("172.24.") || hostname.startsWith("172.25.") ||
|
|
48
|
+
hostname.startsWith("172.26.") || hostname.startsWith("172.27.") ||
|
|
49
|
+
hostname.startsWith("172.28.") || hostname.startsWith("172.29.") ||
|
|
50
|
+
hostname.startsWith("172.30.") || hostname.startsWith("172.31.") ||
|
|
51
|
+
hostname === "0.0.0.0" || hostname.endsWith(".local")) {
|
|
52
|
+
throw new Error(`CodeMode fetch: private/internal host not allowed: ${hostname}`)
|
|
53
|
+
}
|
|
54
|
+
const ctrl = new AbortController()
|
|
55
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(url, { signal: ctrl.signal })
|
|
58
|
+
const text = await res.text()
|
|
59
|
+
return text.slice(0, 100_000)
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const codeModeTool = {
|
|
66
|
+
name: "execute",
|
|
67
|
+
description:
|
|
68
|
+
"Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — " +
|
|
69
|
+
"read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
code: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "JavaScript code to execute in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).",
|
|
76
|
+
},
|
|
77
|
+
timeoutMs: {
|
|
78
|
+
type: "integer",
|
|
79
|
+
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["code"],
|
|
83
|
+
},
|
|
84
|
+
readonly: false,
|
|
85
|
+
|
|
86
|
+
async execute(args, ctx) {
|
|
87
|
+
const cwd = ctx.cwd
|
|
88
|
+
const code = args.code ?? ""
|
|
89
|
+
|
|
90
|
+
if (code.length > MAX_SCRIPT) {
|
|
91
|
+
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const output = []
|
|
95
|
+
const timeoutMs = Math.min(args.timeoutMs ?? DEFAULT_TIMEOUT, 60_000)
|
|
96
|
+
|
|
97
|
+
// File path guard: ensure paths are within cwd
|
|
98
|
+
function safePath(p) {
|
|
99
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
100
|
+
// Normalize and resolve
|
|
101
|
+
const abs = resolve(cwd, p)
|
|
102
|
+
// Check containment
|
|
103
|
+
const rel = relative(cwd, abs)
|
|
104
|
+
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
105
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
106
|
+
}
|
|
107
|
+
return abs
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sandbox = createContext({
|
|
111
|
+
readFile: (p) => {
|
|
112
|
+
const abs = safePath(p)
|
|
113
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
114
|
+
const st = statSync(abs)
|
|
115
|
+
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
116
|
+
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
117
|
+
},
|
|
118
|
+
writeFile: (p, content) => {
|
|
119
|
+
const abs = safePath(p)
|
|
120
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
121
|
+
writeFileSync(abs, String(content), "utf8")
|
|
122
|
+
},
|
|
123
|
+
glob: (pattern) => {
|
|
124
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
125
|
+
const regex = globToRegex(pattern)
|
|
126
|
+
const results = []
|
|
127
|
+
function walk(dir, rel) {
|
|
128
|
+
let entries
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
132
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
133
|
+
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
134
|
+
else if (regex.test(relPath)) results.push(relPath)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
walk(cwd, "")
|
|
138
|
+
return results.slice(0, 200)
|
|
139
|
+
},
|
|
140
|
+
grep: (pattern, file) => {
|
|
141
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
142
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
143
|
+
const abs = safePath(file)
|
|
144
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
145
|
+
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
146
|
+
const regex = new RegExp(pattern)
|
|
147
|
+
const lines = content.split("\n")
|
|
148
|
+
const matches = []
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
151
|
+
}
|
|
152
|
+
return matches.slice(0, 100)
|
|
153
|
+
},
|
|
154
|
+
log: (...args) => {
|
|
155
|
+
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
156
|
+
output.push(line)
|
|
157
|
+
if (output.join("\n").length > MAX_OUTPUT) {
|
|
158
|
+
output.push("... (output truncated)")
|
|
159
|
+
throw new Error("CodeMode output limit exceeded")
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
fetch: sandboxFetch,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const script = new Script(code, {
|
|
167
|
+
filename: "codemode.js",
|
|
168
|
+
timeout: timeoutMs,
|
|
169
|
+
})
|
|
170
|
+
script.runInContext(sandbox)
|
|
171
|
+
return output.join("\n") || "(no output)"
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const out = output.join("\n")
|
|
174
|
+
const prefix = out ? `${out}\n\n` : ""
|
|
175
|
+
return `${prefix}Error: ${err.message}`
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
package/src/tools/index.mjs
CHANGED
|
@@ -8,13 +8,15 @@ import { websearchTool, fetchTool } from "./web.mjs";
|
|
|
8
8
|
import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
10
|
import { linterTool } from "./linter.mjs";
|
|
11
|
+
import { lspTool } from "./lsp.mjs";
|
|
12
|
+
import { codeModeTool } from "./codemode.mjs";
|
|
11
13
|
|
|
12
14
|
export const builtinTools = [
|
|
13
15
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
14
16
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
15
17
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
16
18
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
17
|
-
checklistTool, linterTool,
|
|
19
|
+
checklistTool, linterTool, lspTool, codeModeTool,
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
export {
|
|
@@ -22,5 +24,5 @@ export {
|
|
|
22
24
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
23
25
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
24
26
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
25
|
-
checklistTool, linterTool,
|
|
27
|
+
checklistTool, linterTool, lspTool, codeModeTool,
|
|
26
28
|
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/lsp.mjs — LSP (Language Server Protocol) code intelligence tool
|
|
3
|
+
* Zero-dependency JSON-RPC 2.0 over stdio client.
|
|
4
|
+
*
|
|
5
|
+
* Provides: go-to-definition, find-references, hover info, document symbols, diagnostics.
|
|
6
|
+
* Lazy-starts language servers on first call. Configurable via config.json lsp.servers.
|
|
7
|
+
*
|
|
8
|
+
* Config format:
|
|
9
|
+
* "lsp": {
|
|
10
|
+
* "servers": {
|
|
11
|
+
* "typescript": { "command": "typescript-language-server", "args": ["--stdio"] },
|
|
12
|
+
* "python": { "command": "pyright-langserver", "args": ["--stdio"] }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process"
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
19
|
+
import { join, extname } from "node:path"
|
|
20
|
+
|
|
21
|
+
// ---- JSON-RPC transport over stdio ----
|
|
22
|
+
|
|
23
|
+
/** Send a JSON-RPC request to the server via stdin */
|
|
24
|
+
function send(proc, message) {
|
|
25
|
+
const body = JSON.stringify(message)
|
|
26
|
+
const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`
|
|
27
|
+
proc.stdin.write(header + body)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read one JSON-RPC message from stdout. Returns parsed JSON, or null on EOF. */
|
|
31
|
+
function readMessage(proc) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let header = ""
|
|
34
|
+
let contentLength = -1
|
|
35
|
+
|
|
36
|
+
const onData = (chunk) => {
|
|
37
|
+
if (contentLength < 0) {
|
|
38
|
+
header += chunk.toString()
|
|
39
|
+
const match = header.match(/Content-Length: (\d+)\r\n\r\n/)
|
|
40
|
+
if (match) {
|
|
41
|
+
contentLength = parseInt(match[1])
|
|
42
|
+
const bodyStart = header.indexOf("\r\n\r\n") + 4
|
|
43
|
+
const remaining = header.slice(bodyStart)
|
|
44
|
+
header = ""
|
|
45
|
+
if (remaining.length >= contentLength) {
|
|
46
|
+
proc.stdout.removeListener("data", onData)
|
|
47
|
+
try { resolve(JSON.parse(remaining.slice(0, contentLength))) } catch { resolve(null) }
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
// Need more data — leave remaining in a buffer-like state
|
|
51
|
+
proc.stdout.removeListener("data", onData)
|
|
52
|
+
readBody(proc, remaining, contentLength).then(resolve)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
proc.stdout.on("data", onData)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readBody(proc, buf, targetLen) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const onData = (chunk) => {
|
|
64
|
+
buf += chunk.toString()
|
|
65
|
+
if (buf.length >= targetLen) {
|
|
66
|
+
proc.stdout.removeListener("data", onData)
|
|
67
|
+
try { resolve(JSON.parse(buf.slice(0, targetLen))) } catch { resolve(null) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
proc.stdout.on("data", onData)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Send a request and wait for the matching response */
|
|
75
|
+
async function request(proc, method, params, id) {
|
|
76
|
+
send(proc, { jsonrpc: "2.0", id, method, params })
|
|
77
|
+
while (true) {
|
|
78
|
+
const msg = await readMessage(proc)
|
|
79
|
+
if (!msg) return null
|
|
80
|
+
if (msg.id === id) return msg
|
|
81
|
+
// Store notifications for later retrieval (diagnostics)
|
|
82
|
+
if (msg.method === "textDocument/publishDiagnostics") {
|
|
83
|
+
proc._diagnostics = proc._diagnostics || {}
|
|
84
|
+
proc._diagnostics[msg.params.uri] = msg.params.diagnostics
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Send a notification (no response expected) */
|
|
90
|
+
function notify(proc, method, params) {
|
|
91
|
+
send(proc, { jsonrpc: "2.0", method, params })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Language server process management ----
|
|
95
|
+
|
|
96
|
+
const servers = new Map() // ext → { proc, rootUri, ready }
|
|
97
|
+
|
|
98
|
+
/** Convert file path to file:// URI */
|
|
99
|
+
function toUri(absPath) {
|
|
100
|
+
return "file:///" + absPath.replace(/\\/g, "/").replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + "%3A")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve which language server to use for a file extension */
|
|
104
|
+
function resolveServerConfig(config, ext) {
|
|
105
|
+
const map = {
|
|
106
|
+
".js": "typescript", ".mjs": "typescript", ".cjs": "typescript",
|
|
107
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
108
|
+
".py": "python", ".pyi": "python",
|
|
109
|
+
".rs": "rust",
|
|
110
|
+
".go": "go",
|
|
111
|
+
}
|
|
112
|
+
const key = map[ext] || ext.slice(1)
|
|
113
|
+
const servers = config?.lsp?.servers ?? {}
|
|
114
|
+
return servers[key] || null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Start or reuse a language server for the given file */
|
|
118
|
+
async function getServer(cwd, filePath, config) {
|
|
119
|
+
const ext = extname(filePath).toLowerCase()
|
|
120
|
+
const srvConfig = resolveServerConfig(config, ext)
|
|
121
|
+
if (!srvConfig) return null
|
|
122
|
+
|
|
123
|
+
const abs = join(cwd, ...filePath.split("/"))
|
|
124
|
+
if (!existsSync(abs)) return null
|
|
125
|
+
|
|
126
|
+
const rootUri = toUri(cwd)
|
|
127
|
+
const key = `${ext}:${cwd}`
|
|
128
|
+
let entry = servers.get(key)
|
|
129
|
+
|
|
130
|
+
if (entry && entry.ready) return entry
|
|
131
|
+
|
|
132
|
+
// Start new server
|
|
133
|
+
const proc = spawn(srvConfig.command, srvConfig.args || [], {
|
|
134
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
proc.stderr.on("data", () => {}) // suppress stderr noise
|
|
139
|
+
|
|
140
|
+
entry = { proc, rootUri, ready: false }
|
|
141
|
+
servers.set(key, entry)
|
|
142
|
+
|
|
143
|
+
// Initialize handshake
|
|
144
|
+
const initResult = await request(proc, "initialize", {
|
|
145
|
+
processId: null,
|
|
146
|
+
rootUri,
|
|
147
|
+
capabilities: {
|
|
148
|
+
textDocument: {
|
|
149
|
+
definition: { linkSupport: false },
|
|
150
|
+
references: {},
|
|
151
|
+
hover: { contentFormat: ["plaintext"] },
|
|
152
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
workspace: {},
|
|
156
|
+
}, 1)
|
|
157
|
+
|
|
158
|
+
if (!initResult) {
|
|
159
|
+
proc.kill()
|
|
160
|
+
servers.delete(key)
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
notify(proc, "initialized", {})
|
|
165
|
+
entry.ready = true
|
|
166
|
+
return entry
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Notify the server that a file is open (required before queries) */
|
|
170
|
+
async function ensureOpen(proc, uri, ext) {
|
|
171
|
+
const langMap = { ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript", ".py": "python", ".rs": "rust", ".go": "go" }
|
|
172
|
+
const languageId = langMap[ext] || ext.slice(1)
|
|
173
|
+
notify(proc, "textDocument/didOpen", {
|
|
174
|
+
textDocument: {
|
|
175
|
+
uri,
|
|
176
|
+
languageId,
|
|
177
|
+
version: 1,
|
|
178
|
+
text: readFileSync(uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":").replace(/\//g, "\\"), "utf8"),
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Tool definition ----
|
|
184
|
+
|
|
185
|
+
export const lspTool = {
|
|
186
|
+
name: "lsp",
|
|
187
|
+
description:
|
|
188
|
+
"LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. " +
|
|
189
|
+
"Use this to understand code structure without grep-guessing function locations or type shapes.",
|
|
190
|
+
parameters: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
subcommand: {
|
|
194
|
+
type: "string",
|
|
195
|
+
enum: ["definition", "references", "hover", "symbols", "diagnostics"],
|
|
196
|
+
description: "LSP operation to perform",
|
|
197
|
+
},
|
|
198
|
+
uri: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Target file path (relative to project root)",
|
|
201
|
+
},
|
|
202
|
+
line: {
|
|
203
|
+
type: "integer",
|
|
204
|
+
description: "1-based line number (for definition/references/hover)",
|
|
205
|
+
},
|
|
206
|
+
character: {
|
|
207
|
+
type: "integer",
|
|
208
|
+
description: "1-based character offset (for definition/references/hover)",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ["subcommand", "uri"],
|
|
212
|
+
},
|
|
213
|
+
readonly: true,
|
|
214
|
+
|
|
215
|
+
async execute(args, ctx) {
|
|
216
|
+
const config = ctx.agent?.config ?? {}
|
|
217
|
+
const cwd = ctx.cwd
|
|
218
|
+
const filePath = args.uri
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const entry = await getServer(cwd, filePath, config)
|
|
222
|
+
if (!entry) {
|
|
223
|
+
const ext = extname(filePath).toLowerCase()
|
|
224
|
+
return `No LSP server configured for "${ext}" files. Add one to config.json:\n"lsp": { "servers": { "${ext.slice(1)}": { "command": "...", "args": ["--stdio"] } } }`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { proc } = entry
|
|
228
|
+
const abs = join(cwd, ...filePath.split("/"))
|
|
229
|
+
const uri = toUri(abs)
|
|
230
|
+
const ext = extname(filePath).toLowerCase()
|
|
231
|
+
await ensureOpen(proc, uri, ext)
|
|
232
|
+
|
|
233
|
+
// Wait briefly for diagnostics to arrive
|
|
234
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
235
|
+
|
|
236
|
+
switch (args.subcommand) {
|
|
237
|
+
case "definition": {
|
|
238
|
+
if (!args.line || !args.character) return "Error: line and character required for definition"
|
|
239
|
+
const res = await request(proc, "textDocument/definition", {
|
|
240
|
+
textDocument: { uri },
|
|
241
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
242
|
+
}, 10)
|
|
243
|
+
if (!res?.result) return "No definition found."
|
|
244
|
+
const locs = Array.isArray(res.result) ? res.result : [res.result]
|
|
245
|
+
return locs.map((l) => {
|
|
246
|
+
const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
|
|
247
|
+
return `${path}:${l.range.start.line + 1}:${l.range.start.character + 1}`
|
|
248
|
+
}).join("\n")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "references": {
|
|
252
|
+
if (!args.line || !args.character) return "Error: line and character required for references"
|
|
253
|
+
const res = await request(proc, "textDocument/references", {
|
|
254
|
+
textDocument: { uri },
|
|
255
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
256
|
+
context: { includeDeclaration: false },
|
|
257
|
+
}, 10)
|
|
258
|
+
if (!res?.result?.length) return "No references found."
|
|
259
|
+
return res.result.slice(0, 50).map((l) => {
|
|
260
|
+
const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
|
|
261
|
+
return `${path}:${l.range.start.line + 1}:${l.range.start.character + 1}`
|
|
262
|
+
}).join("\n") + (res.result.length > 50 ? `\n... and ${res.result.length - 50} more` : "")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case "hover": {
|
|
266
|
+
if (!args.line || !args.character) return "Error: line and character required for hover"
|
|
267
|
+
const res = await request(proc, "textDocument/hover", {
|
|
268
|
+
textDocument: { uri },
|
|
269
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
270
|
+
}, 10)
|
|
271
|
+
if (!res?.result?.contents) return "No hover info available."
|
|
272
|
+
const contents = res.result.contents
|
|
273
|
+
if (typeof contents === "string") return contents
|
|
274
|
+
if (Array.isArray(contents)) return contents.map((c) => typeof c === "string" ? c : c.value).join("\n")
|
|
275
|
+
if (contents.value) return contents.value
|
|
276
|
+
return JSON.stringify(contents)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "symbols": {
|
|
280
|
+
const res = await request(proc, "textDocument/documentSymbol", {
|
|
281
|
+
textDocument: { uri },
|
|
282
|
+
}, 10)
|
|
283
|
+
if (!res?.result?.length) return "No symbols found."
|
|
284
|
+
function render(nodes, depth) {
|
|
285
|
+
const lines = []
|
|
286
|
+
for (const n of nodes) {
|
|
287
|
+
const kind = n.kind != null ? ` [${symbolKind(n.kind)}]` : ""
|
|
288
|
+
lines.push(`${" ".repeat(depth)}${n.name}${kind} — L${n.range.start.line + 1}`)
|
|
289
|
+
if (n.children?.length) lines.push(...render(n.children, depth + 1))
|
|
290
|
+
}
|
|
291
|
+
return lines
|
|
292
|
+
}
|
|
293
|
+
return render(res.result, 0).join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "diagnostics": {
|
|
297
|
+
const diags = proc._diagnostics?.[uri]
|
|
298
|
+
if (!diags?.length) return "No diagnostics."
|
|
299
|
+
return diags.slice(0, 30).map((d) => {
|
|
300
|
+
const sev = { 1: "ERROR", 2: "WARN", 3: "INFO", 4: "HINT" }[d.severity] || "?"
|
|
301
|
+
return `L${d.range.start.line + 1}: ${sev}: ${d.message}${d.code ? ` [${d.code}]` : ""}`
|
|
302
|
+
}).join("\n") + (diags.length > 30 ? `\n... and ${diags.length - 30} more` : "")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
return `Unknown subcommand: ${args.subcommand}`
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return `LSP error: ${err.message}`
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function symbolKind(k) {
|
|
315
|
+
const kinds = { 1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class", 6: "method", 7: "property", 8: "field", 9: "constructor", 10: "enum", 11: "interface", 12: "function", 13: "variable", 14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object", 20: "key", 21: "null", 22: "enumMember", 23: "struct", 24: "event", 25: "operator", 26: "typeParameter" }
|
|
316
|
+
return kinds[k] || `kind-${k}`
|
|
317
|
+
}
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -80,6 +80,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
80
80
|
status: "Ready",
|
|
81
81
|
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
82
82
|
interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
|
|
83
|
+
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
84
|
+
foldEnabled: true, // global fold toggle — /fold on|off
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function renderHeader(agent, cols) {
|
|
|
51
51
|
*/
|
|
52
52
|
export function convCacheKey(state) {
|
|
53
53
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
54
|
-
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}`
|
|
54
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Conversation panel (scrollable, variable height). Returns exactly `visibleH` lines. */
|
|
@@ -311,10 +311,12 @@ function buildConvLines(state, cols) {
|
|
|
311
311
|
for (const l of state.lines) {
|
|
312
312
|
for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
|
|
313
313
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
314
|
-
convLines.push({ text: wrapped, color: l.color })
|
|
314
|
+
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
|
+
// Messages after the conversation (streaming / thinking / tool output):
|
|
319
|
+
// appended after history lines so they appear at the bottom.
|
|
318
320
|
if (state.reasoning) {
|
|
319
321
|
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
320
322
|
convLines.push({ text: wrapped, color: C.reason })
|
|
@@ -334,8 +336,37 @@ function buildConvLines(state, cols) {
|
|
|
334
336
|
convLines.push({ text: wrapped, color: C.dim })
|
|
335
337
|
}
|
|
336
338
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
+
|
|
340
|
+
// ---- Fold long blocks (> 8 consecutive dim lines) ----
|
|
341
|
+
const FOLD_LINES = 8
|
|
342
|
+
let foldCounter = 0
|
|
343
|
+
const folded = []
|
|
344
|
+
let i = 0
|
|
345
|
+
while (i < convLines.length) {
|
|
346
|
+
const line = convLines[i]
|
|
347
|
+
// Only fold dim-colored lines (tool results, subagent previews)
|
|
348
|
+
if (line.color === C.dim) {
|
|
349
|
+
let j = i
|
|
350
|
+
while (j < convLines.length && convLines[j].color === C.dim) j++
|
|
351
|
+
const blockLen = j - i
|
|
352
|
+
if (blockLen > FOLD_LINES) {
|
|
353
|
+
const foldKey = `fold-${foldCounter++}`
|
|
354
|
+
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
355
|
+
// Show first 2 lines + fold hint
|
|
356
|
+
folded.push(convLines[i])
|
|
357
|
+
if (blockLen > 2) folded.push(convLines[i + 1])
|
|
358
|
+
folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
|
|
359
|
+
i = j
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
folded.push(line)
|
|
365
|
+
i++
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
_convCache = { key, cols, lines: folded }
|
|
369
|
+
return folded
|
|
339
370
|
}
|
|
340
371
|
|
|
341
372
|
function inputBoxStyle(state) {
|
|
@@ -28,6 +28,8 @@ import { handleConfigCommand } from "./cmd-config.mjs"
|
|
|
28
28
|
import { handleExtractCommand } from "./cmd-extract.mjs"
|
|
29
29
|
import { handleHelpCommand } from "./cmd-help.mjs"
|
|
30
30
|
import { handleUpgradeCommand } from "./cmd-upgrade.mjs"
|
|
31
|
+
import { handleFoldCommand } from "./cmd-fold.mjs"
|
|
32
|
+
import { handleUndoCommand } from "./cmd-undo.mjs"
|
|
31
33
|
|
|
32
34
|
export const SLASH_COMMANDS = [
|
|
33
35
|
{ name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
|
|
@@ -38,6 +40,7 @@ export const SLASH_COMMANDS = [
|
|
|
38
40
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
39
41
|
{ name: "/config", group: "Agent", desc: "config management (embedding / agent)" },
|
|
40
42
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
43
|
+
{ name: "/fold", group: "System", desc: "toggle result folding on/off" },
|
|
41
44
|
{ name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
|
|
42
45
|
{ name: "/session", group: "Session", desc: "list/switch archived sessions" },
|
|
43
46
|
{ name: "/clear", group: "Session", desc: "clear screen" },
|
|
@@ -47,6 +50,7 @@ export const SLASH_COMMANDS = [
|
|
|
47
50
|
{ name: "/mcp", group: "Project", desc: "manage MCP servers" },
|
|
48
51
|
{ name: "/reindex", group: "Project", desc: "rebuild memory index" },
|
|
49
52
|
{ name: "/restore", group: "Project", desc: "restore checkpoint" },
|
|
53
|
+
{ name: "/undo", group: "Project", desc: "undo recent file modifications" },
|
|
50
54
|
{ name: "/exit", group: "System", desc: "exit" },
|
|
51
55
|
{ name: "/help", group: "System", desc: "this list" },
|
|
52
56
|
]
|
|
@@ -70,6 +74,8 @@ const HANDLERS = {
|
|
|
70
74
|
"/model": handleModelCommand,
|
|
71
75
|
"/config": handleConfigCommand,
|
|
72
76
|
"/upgrade": handleUpgradeCommand,
|
|
77
|
+
"/fold": handleFoldCommand,
|
|
78
|
+
"/undo": handleUndoCommand,
|
|
73
79
|
"/extract": handleExtractCommand,
|
|
74
80
|
"/help": handleHelpCommand,
|
|
75
81
|
}
|