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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.8.13",
3
+ "version": "0.9.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
- : "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)"
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
- if (item.tool.outputPanel) callbacks.setupOutputPanel?.(item.toolCall.name)
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
  }
@@ -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 marked 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.`
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
+ }
@@ -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")
@@ -8,38 +8,109 @@ const DONE = "checklist-done.md"
8
8
  function checklistPath(cwd) { return join(cwd, ".thincoder", CHECKLIST) }
9
9
  function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
10
10
 
11
- /** Parse checklist file into array of { index, status, text } */
11
+ /**
12
+ * Parse checklist file into tree-structured items.
13
+ * Indentation (2 spaces per level) determines parent-child relationships.
14
+ * Each item: { id, index, depth, status, text, children[] }
15
+ * "index" is the 1-based position in the flat markdown list.
16
+ */
12
17
  function parse(filePath) {
13
18
  if (!existsSync(filePath)) return []
14
19
  const lines = readFileSync(filePath, "utf-8").split("\n")
15
20
  const items = []
16
- let idx = 0
21
+ let flatIdx = 0
22
+ const stack = [{ children: items, depth: -1 }] // virtual root
23
+
17
24
  for (const line of lines) {
18
- const m = line.match(/^- \[(.)\] (.+)$/)
19
- if (m) {
20
- idx++
21
- const raw = m[1]
22
- const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
23
- items.push({ index: idx, status, text: m[2].trim() })
25
+ const m = line.match(/^(\s*)- \[(.)\] (.+)$/)
26
+ if (!m) continue
27
+ flatIdx++
28
+ const indent = m[1]
29
+ const depth = Math.floor(indent.length / 2) // 2 spaces = 1 level
30
+ const raw = m[2]
31
+ const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
+ const text = m[3].trim()
33
+
34
+ // Extract explicit ID if present (e.g. "T1:", "T1.1:")
35
+ const idMatch = text.match(/^(T[\d.]+):/)
36
+ const node = {
37
+ id: idMatch ? idMatch[1] : null,
38
+ index: flatIdx,
39
+ depth,
40
+ status,
41
+ text,
42
+ children: [],
24
43
  }
44
+
45
+ // Find parent by popping stack until we find a node at depth-1
46
+ while (stack.length > 1 && stack.at(-1).depth >= depth) stack.pop()
47
+ const parent = stack.at(-1)
48
+ parent.children.push(node)
49
+ // Auto-assign ID if not explicit
50
+ if (!node.id) {
51
+ const siblingCount = parent.children.length
52
+ const base = parent.id ? `${parent.id}` : "T"
53
+ if (parent.id) {
54
+ node.id = `${base}.${siblingCount}`
55
+ } else {
56
+ // Root level: T1, T2, T3...
57
+ let rootIdx = 0
58
+ for (const c of items) {
59
+ if (c.id?.match(/^T\d+$/)) rootIdx = Math.max(rootIdx, parseInt(c.id.slice(1)))
60
+ }
61
+ node.id = `T${rootIdx + 1}`
62
+ }
63
+ }
64
+ stack.push({ children: node.children, depth, id: node.id })
25
65
  }
26
66
  return items
27
67
  }
28
68
 
29
- /** Write items back to file */
30
- function write(filePath, items) {
31
- mkdirSync(dirname(filePath), { recursive: true })
69
+ /** Write items back to file, preserving tree structure */
70
+ function write(filePath, items, _depth = 0) {
71
+ if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
32
72
  const lines = []
73
+ const indent = " ".repeat(_depth)
33
74
  for (const item of items) {
34
75
  const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
35
- lines.push(`- [${mark}] ${item.text}`)
76
+ const label = item.id ? `${item.id}: ${item.text}` : item.text
77
+ lines.push(`${indent}- [${mark}] ${label}`)
78
+ if (item.children?.length) {
79
+ lines.push(...write(filePath, item.children, _depth + 1).split("\n").filter(Boolean))
80
+ }
81
+ }
82
+ if (_depth === 0) {
83
+ writeFileSync(filePath, lines.join("\n") + "\n")
84
+ return ""
85
+ }
86
+ return lines.join("\n")
87
+ }
88
+
89
+ /** Find a node by ID in the tree */
90
+ function findById(items, id) {
91
+ for (const item of items) {
92
+ if (item.id === id) return { parent: items, item, idx: items.indexOf(item) }
93
+ if (item.children?.length) {
94
+ const found = findById(item.children, id)
95
+ if (found) return found
96
+ }
36
97
  }
37
- writeFileSync(filePath, lines.join("\n") + "\n")
98
+ return null
99
+ }
100
+
101
+ /** Flatten tree for mark action (index-based) */
102
+ function flatten(items, out = []) {
103
+ for (const item of items) {
104
+ out.push(item)
105
+ if (item.children?.length) flatten(item.children, out)
106
+ }
107
+ return out
38
108
  }
39
109
 
40
110
  /** Parse pending items only (for context injection) */
41
111
  export function pendingItems(cwd) {
42
- return parse(checklistPath(cwd)).filter(i => i.status !== "done")
112
+ const flat = flatten(parse(checklistPath(cwd)))
113
+ return flat.filter(i => i.status !== "done")
43
114
  }
44
115
 
45
116
  export const checklistTool = {
@@ -66,6 +137,10 @@ export const checklistTool = {
66
137
  enum: ["pending", "in_progress", "done"],
67
138
  description: "New status (required for mark)"
68
139
  },
140
+ parent: {
141
+ type: "string",
142
+ description: "Parent task ID for tree-structured tasks (e.g. 'T1')"
143
+ },
69
144
  },
70
145
  required: ["action"],
71
146
  },
@@ -75,9 +150,33 @@ export const checklistTool = {
75
150
  case "add": {
76
151
  if (!args.item || typeof args.item !== "string") return "Error: 'item' is required for add"
77
152
  const items = parse(checklistPath(ctx.cwd))
78
- items.push({ index: items.length + 1, status: "pending", text: args.item })
153
+
154
+ let target = items
155
+ let parentId = null
156
+ if (args.parent) {
157
+ const found = findById(items, args.parent)
158
+ if (!found) return `Error: parent '${args.parent}' not found. Use 'list' to see all task IDs.`
159
+ target = found.item.children
160
+ parentId = found.item.id
161
+ }
162
+
163
+ // Auto-assign ID
164
+ let id
165
+ if (parentId) {
166
+ id = `${parentId}.${target.length + 1}`
167
+ } else {
168
+ let maxIdx = 0
169
+ for (const c of items) {
170
+ const m = c.id?.match(/^T(\d+)$/)
171
+ if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
172
+ }
173
+ id = `T${maxIdx + 1}`
174
+ }
175
+
176
+ const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
177
+ target.push(node)
79
178
  write(checklistPath(ctx.cwd), items)
80
- return `Added: [ ] ${args.item}`
179
+ return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
81
180
  }
82
181
  case "mark": {
83
182
  if (args.index == null) return "Error: 'index' is required for mark"
@@ -85,8 +184,9 @@ export const checklistTool = {
85
184
  if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
86
185
  const cp = checklistPath(ctx.cwd)
87
186
  const items = parse(cp)
88
- if (args.index < 1 || args.index > items.length) return `Error: index ${args.index} out of range (1-${items.length})`
89
- const item = items[args.index - 1]
187
+ const flat = flatten(items)
188
+ if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
189
+ const item = flat[args.index - 1]
90
190
  const old = item.status
91
191
  if (old === status) return `Already ${status}: ${item.text}`
92
192
  item.status = status
@@ -94,18 +194,30 @@ export const checklistTool = {
94
194
  // Move to done file
95
195
  const dp = donePath(ctx.cwd)
96
196
  const doneItems = parse(dp)
97
- doneItems.push(item)
197
+ doneItems.push({ id: item.id, index: 0, depth: 0, status: "done", text: item.text, children: [] })
98
198
  write(dp, doneItems)
99
- items.splice(args.index - 1, 1)
199
+ // Remove from tree
200
+ const found = findById(items, item.id)
201
+ if (found) found.parent.splice(found.idx, 1)
100
202
  }
101
203
  write(cp, items)
102
- return `Marked #${args.index} ${old} → ${status}: ${item.text}`
204
+ return `Marked #${args.index} ${old} → ${status}: ${item.id}: ${item.text}`
103
205
  }
104
206
  case "list": {
105
207
  const items = parse(checklistPath(ctx.cwd))
106
208
  if (items.length === 0) return "(checklist is empty)"
107
209
  const marks = { pending: " ", in_progress: "~", done: "x" }
108
- return items.map(i => `- [${marks[i.status]}] ${i.text}`).join("\n")
210
+ const lines = []
211
+ function render(nodes, depth) {
212
+ const indent = " ".repeat(depth)
213
+ for (const n of nodes) {
214
+ const idTag = n.id ? `${n.id}: ` : ""
215
+ lines.push(`${indent}- [${marks[n.status]}] ${idTag}${n.text}`)
216
+ if (n.children?.length) render(n.children, depth + 1)
217
+ }
218
+ }
219
+ render(items, 0)
220
+ return lines.join("\n")
109
221
  }
110
222
  default:
111
223
  return `Error: unknown action '${args.action}'`
package/src/tui/ansi.mjs CHANGED
@@ -38,4 +38,5 @@ export const C = {
38
38
  dim: ansi.gray,
39
39
  warn: ansi.fg(3),
40
40
  advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
41
+ fold: `${ESC}[2m${ESC}[37m`, // dim white — fold hints
41
42
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * cmd-fold.mjs — /fold command: toggle conversation result folding
3
+ */
4
+ import { C } from "./ansi.mjs"
5
+
6
+ export async function handleFoldCommand(ctx) {
7
+ const { state } = ctx
8
+ const text = state.input.join("").trim()
9
+ const arg = text.split(/\s+/)[1]
10
+ if (arg === "on") {
11
+ state.foldEnabled = true
12
+ ctx.pushLine("Folding: on (long tool results are collapsed)", C.dim)
13
+ } else if (arg === "off") {
14
+ state.foldEnabled = false
15
+ ctx.pushLine("Folding: off (all results shown in full)", C.dim)
16
+ } else {
17
+ state.foldEnabled = !state.foldEnabled
18
+ ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.dim)
19
+ }
20
+ ctx.render()
21
+ }
@@ -124,12 +124,53 @@ export async function handleMcpCommand(ctx) {
124
124
  openPicker({
125
125
  title: "MCP Transport",
126
126
  entries: [
127
- { type: "header", text: "Select server transport" },
127
+ { type: "header", text: "Select transport or use AI assist" },
128
+ { type: "item", text: "🤖 Describe with AI — natural language → config", action: "ai" },
128
129
  { type: "item", text: "HTTP (https://…)", action: "http" },
129
130
  { type: "item", text: "WebSocket (ws://…)", action: "ws" },
130
131
  { type: "item", text: "stdio (local command)", action: "stdio" },
131
132
  ],
132
133
  onSelect: async (te) => {
134
+ if (te.action === "ai") {
135
+ const description = await askQuestion("Describe the MCP server you want to add (e.g. 'a filesystem server that gives access to /tmp'):")
136
+ if (!description) return
137
+ pushLine("[mcp] Generating config from description...", C.dim)
138
+ try {
139
+ const { chat } = await import("../provider/index.mjs")
140
+ const res = await chat(agent.provider, {
141
+ messages: [{
142
+ role: "user",
143
+ content: `Generate an MCP server configuration JSON from this description. Return ONLY the JSON object, no explanation.
144
+
145
+ Description: "${description}"
146
+
147
+ The JSON should have these fields:
148
+ - name: a short identifier
149
+ - One of: url (HTTP), wsUrl (WebSocket), or command + args (stdio)
150
+ - headers: optional key-value object
151
+
152
+ Example HTTP: {"name":"filesystem","url":"https://example.com/mcp","headers":{"Authorization":"Bearer xxx"}}
153
+ Example stdio: {"name":"filesystem","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}
154
+
155
+ Return ONLY the JSON object:`,
156
+ }],
157
+ tools: [],
158
+ signal: AbortSignal.timeout(15_000),
159
+ })
160
+ const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
161
+ if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
162
+ const srv = JSON.parse(jsonMatch[0])
163
+ if (!srv.name) { pushLine("[mcp] AI response missing 'name' field", C.error); return }
164
+ // Show preview and confirm
165
+ pushLine(`[mcp] Generated config: ${JSON.stringify(srv)}`, C.tool)
166
+ const confirm = await askQuestion("Add this server? (y/n):")
167
+ if (confirm?.toLowerCase() !== "y") { pushLine("[mcp] Cancelled", C.dim); return }
168
+ await addAndConnect(ctx, srv)
169
+ } catch (err) {
170
+ pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
171
+ }
172
+ return
173
+ }
133
174
  const name = await askQuestion("Server name:")
134
175
  if (!name) return
135
176
  const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
@@ -0,0 +1,91 @@
1
+ /**
2
+ * cmd-undo.mjs — /undo command: revert recent file modifications
3
+ *
4
+ * Tracks write/edit/delete/hashline_edit/apply_patch operations in agent._undoStack.
5
+ * /undo opens a picker to select and revert an operation.
6
+ */
7
+
8
+ import { existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { ansi, C } from "./ansi.mjs"
11
+
12
+ const MAX_UNDO = 50
13
+
14
+ /**
15
+ * Snapshot a file before a side-effect tool modifies it.
16
+ * Called from dispatch.mjs before each write/edit/delete/apply_patch/hashline_edit.
17
+ */
18
+ export function snapshotForUndo(agent, toolName, args, cwd) {
19
+ if (!agent._undoStack) agent._undoStack = []
20
+ const path = args.path ?? args.file
21
+ if (!path || typeof path !== "string") return
22
+
23
+ const abs = join(cwd, ...path.split("/"))
24
+ let backup = null
25
+ try {
26
+ if (existsSync(abs)) {
27
+ backup = readFileSync(abs, "utf8")
28
+ }
29
+ } catch {
30
+ // can't read — maybe binary, skip
31
+ return
32
+ }
33
+
34
+ agent._undoStack.push({
35
+ tool: toolName,
36
+ path,
37
+ backup,
38
+ timestamp: Date.now(),
39
+ })
40
+ if (agent._undoStack.length > MAX_UNDO) agent._undoStack.shift()
41
+ }
42
+
43
+ export async function handleUndoCommand(ctx) {
44
+ const { agent, pushLine, openPicker } = ctx
45
+ const stack = agent._undoStack ?? []
46
+
47
+ if (stack.length === 0) {
48
+ pushLine("[undo] Nothing to undo — no file modifications tracked yet.", C.dim)
49
+ return
50
+ }
51
+
52
+ const entries = [
53
+ { type: "header", text: `${stack.length} operation(s) available to undo (most recent first)` },
54
+ ...stack.map((item, i) => {
55
+ const relIdx = stack.length - i
56
+ const time = new Date(item.timestamp).toLocaleTimeString()
57
+ const preview = item.backup === null
58
+ ? "(was created — undo will delete)"
59
+ : `(${item.backup.split("\n").length} lines — undo will restore)`
60
+ return {
61
+ type: "item",
62
+ text: `#${relIdx} ${item.tool}: ${item.path} ${preview} — ${time}`,
63
+ idx: i,
64
+ }
65
+ }),
66
+ ]
67
+
68
+ openPicker({
69
+ title: "Undo",
70
+ entries,
71
+ onSelect: async (e) => {
72
+ const item = stack[e.idx]
73
+ const abs = join(agent.cwd, ...item.path.split("/"))
74
+
75
+ try {
76
+ if (item.backup === null) {
77
+ // File was created — undo deletes it
78
+ if (existsSync(abs)) unlinkSync(abs)
79
+ } else {
80
+ // File was modified — undo restores original
81
+ writeFileSync(abs, item.backup, "utf8")
82
+ }
83
+ // Remove this and all newer entries (can't undo out of order)
84
+ stack.splice(e.idx)
85
+ pushLine(`[undo] Reverted: ${item.tool} ${item.path}`, C.tool)
86
+ } catch (err) {
87
+ pushLine(`[undo] Failed to revert ${item.path}: ${err.message}`, C.error)
88
+ }
89
+ },
90
+ })
91
+ }
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)
@@ -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
- _convCache = { key, cols, lines: convLines }
338
- return convLines
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
  }