thincoder 0.8.13 → 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 +11 -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/hooks.mjs +93 -0
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- 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,17 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
205
|
|
|
206
206
|
## Changelog
|
|
207
207
|
|
|
208
|
+
### 0.9.0 (2026-07)
|
|
209
|
+
- **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.
|
|
210
|
+
- **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.
|
|
211
|
+
- **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.).
|
|
212
|
+
- **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.
|
|
213
|
+
- **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`.
|
|
214
|
+
- **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.
|
|
215
|
+
- **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.
|
|
216
|
+
- **`/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.
|
|
217
|
+
- **Roadmap**: LSP tool, intelligent context management (checkpoint-based reconstruction), and CodeMode sandboxed JS are planned for 0.10.0.
|
|
218
|
+
|
|
208
219
|
### 0.8.13 (2026-07)
|
|
209
220
|
- **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
221
|
- **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/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}'`
|
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
|
}
|