thincoder 0.8.2 → 0.8.4

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.
Files changed (98) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.mjs +25 -25
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +17 -12
  5. package/src/agent/helpers.mjs +22 -11
  6. package/src/agent/setup.mjs +32 -10
  7. package/src/agent-tools/goal.mjs +12 -11
  8. package/src/agent-tools/plan.mjs +5 -6
  9. package/src/agent-tools/recent-changes.mjs +3 -3
  10. package/src/agent-tools/skill.mjs +6 -6
  11. package/src/agent-tools/subagent.mjs +19 -18
  12. package/src/agent-tools/task.mjs +7 -26
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +82 -92
  16. package/src/cli/distill-command.mjs +2 -2
  17. package/src/cli/make-agent.mjs +9 -9
  18. package/src/cli/memory-command.mjs +1 -1
  19. package/src/cli/permission.mjs +3 -3
  20. package/src/cli/setup-wizard.mjs +15 -15
  21. package/src/config.mjs +56 -53
  22. package/src/context.mjs +59 -61
  23. package/src/distill.mjs +35 -35
  24. package/src/embedding.mjs +17 -17
  25. package/src/git/checkpoint.mjs +211 -38
  26. package/src/git/gitmem.mjs +21 -20
  27. package/src/markdown.mjs +13 -13
  28. package/src/mcp/helpers.mjs +6 -1
  29. package/src/mcp/transport-http.mjs +2 -1
  30. package/src/mcp/transport-stdio.mjs +3 -2
  31. package/src/mcp/transport-ws.mjs +3 -2
  32. package/src/mcp.mjs +5 -2
  33. package/src/memory/code-index.mjs +16 -14
  34. package/src/memory/code-sync.mjs +36 -26
  35. package/src/memory/core.mjs +42 -35
  36. package/src/memory/docs.mjs +24 -15
  37. package/src/memory/schema.mjs +28 -27
  38. package/src/memory.mjs +2 -2
  39. package/src/prompts/coder.md +0 -6
  40. package/src/prompts/discipline.md +9 -0
  41. package/src/prompts/main.md +24 -24
  42. package/src/prompts/system.md +21 -16
  43. package/src/provider/core.mjs +11 -6
  44. package/src/provider/index.mjs +2 -2
  45. package/src/provider/rate.mjs +11 -11
  46. package/src/session.mjs +73 -42
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +20 -14
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/ls.md +1 -1
  53. package/src/tools/patch.mjs +20 -18
  54. package/src/tools/repomap-parse.mjs +17 -17
  55. package/src/tools/repomap.mjs +29 -29
  56. package/src/tools/shared.mjs +55 -28
  57. package/src/tools/system.mjs +163 -118
  58. package/src/tools/web.mjs +6 -6
  59. package/src/tui/agent-turn.mjs +82 -26
  60. package/src/tui/ansi.mjs +5 -3
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +3 -12
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +108 -23
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +11 -3
  67. package/src/tui/cmd-goal.mjs +3 -12
  68. package/src/tui/cmd-help.mjs +2 -2
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +38 -25
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +33 -13
  73. package/src/tui/cmd-plan.mjs +3 -12
  74. package/src/tui/cmd-reindex.mjs +2 -2
  75. package/src/tui/cmd-restore.mjs +1 -1
  76. package/src/tui/cmd-session.mjs +1 -1
  77. package/src/tui/cmd-skills.mjs +1 -1
  78. package/src/tui/cmd-think.mjs +4 -11
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +4 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +34 -26
  84. package/src/tui/layout.mjs +22 -17
  85. package/src/tui/pickers.mjs +19 -19
  86. package/src/tui/render-frame.mjs +37 -11
  87. package/src/tui/render.mjs +25 -24
  88. package/src/tui/slash-commands.mjs +22 -22
  89. package/src/tui/startup.mjs +15 -14
  90. package/src/tui/wizard.mjs +11 -11
  91. package/src/tui.mjs +2 -2
  92. package/bin/thincoder.js +0 -4
  93. package/src/tools/bash.mjs +0 -144
  94. package/src/tools/glob.mjs +0 -51
  95. package/src/tools/grep.mjs +0 -100
  96. package/src/tools/ls.mjs +0 -36
  97. package/src/tools.mjs +0 -2
  98. package/src/tui-render.mjs +0 -4
@@ -13,14 +13,11 @@ import {
13
13
  insideGitRepo,
14
14
  globToRegex
15
15
  } from "./shared.mjs";
16
- import { spawn } from "node:child_process";
17
- import { execFileSync } from "node:child_process";
18
- import { readFile } from "node:fs/promises";
19
- import { readdir } from "node:fs/promises";
20
- import { stat, lstat } from "node:fs/promises";
16
+ import { spawn, execFileSync } from "node:child_process";
17
+ import { readFile, readdir, stat, lstat } from "node:fs/promises";
21
18
  import { join } from "node:path";
22
19
 
23
- /** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
20
+ /** Child process environment variable whitelist: only pass through safe variables, isolate sensitive info like API keys */
24
21
  const SAFE_ENV_KEYS = new Set([
25
22
  "PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
26
23
  "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
@@ -30,6 +27,152 @@ const SAFE_ENV_KEYS = new Set([
30
27
  "GIT_PAGER", "PAGER", "TERM",
31
28
  ])
32
29
 
30
+ /** Maximum buffer size per stream (stdout / stderr) before truncation */
31
+ const MAX_STREAM_BUF = 2_000_000
32
+
33
+ // ====================================================================
34
+ // bash — command execution with safety gates
35
+ // ====================================================================
36
+
37
+ /**
38
+ * Pre-execution safety checks for bash commands.
39
+ * Three layers: file redirection → destructive commands → destructive git ops with uncommitted changes.
40
+ * Throws with actionable guidance on failure.
41
+ */
42
+ function checkBashSafety(command, cwd) {
43
+ if (hasFileRedirection(command)) {
44
+ throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
45
+ }
46
+ if (shellSegments(command).some(isDestructiveCommand)) {
47
+ throw new Error(
48
+ "Destructive command blocked — use specific tools or confirm with the user first. " +
49
+ "(If work was already destroyed, recover from auto-snapshot: checkpoint action=list then action=rewind.)"
50
+ )
51
+ }
52
+ if (shellSegments(command).some(isDestructiveGitSegment)) {
53
+ if (!insideGitRepo(cwd)) {
54
+ throw new Error(`Refusing destructive git command: not a git repository: ${cwd}`)
55
+ }
56
+ const status = execFileSync("git", ["status", "--porcelain"], {
57
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
58
+ }).trim()
59
+ if (status) {
60
+ throw new Error(
61
+ `Refusing destructive git command: uncommitted changes exist.\n` +
62
+ `First create a checkpoint (action=create) to protect current work, then commit or stash before the destructive operation.\n` +
63
+ `(If uncommitted work was already lost, recover from the latest auto-snapshot: checkpoint action=list, then action=rewind.)\n\n${status}`
64
+ )
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Build a sanitized environment for the child process.
71
+ * Whitelists safe variables, forces non-interactive defaults (EDITOR/PAGER/TERM),
72
+ * and sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
73
+ */
74
+ function buildBashEnv() {
75
+ const winCmd = process.platform === "win32"
76
+ return {
77
+ ...Object.fromEntries(
78
+ Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
79
+ ),
80
+ GIT_EDITOR: "true",
81
+ EDITOR: "true",
82
+ VISUAL: "true",
83
+ GIT_PAGER: "cat",
84
+ PAGER: "cat",
85
+ TERM: "dumb",
86
+ ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Platform-aware process tree kill.
92
+ * POSIX: kill process group (spawned with detached=true).
93
+ * Windows: taskkill /T to reach grandchildren (npm test's subprocesses, etc.).
94
+ */
95
+ function killProcessTree(child) {
96
+ if (process.platform === "win32") {
97
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
98
+ } else {
99
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
100
+ try { child.kill("SIGKILL") } catch {} // fallback: kill directly if group kill fails
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
106
+ * enforce timeout and signal abort, return formatted result.
107
+ *
108
+ * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
109
+ */
110
+ function runBash(command, cwd, { timeout, signal, onOutput }) {
111
+ return new Promise((resolve) => {
112
+ const child = spawn(command, {
113
+ cwd,
114
+ shell: true,
115
+ windowsHide: true,
116
+ detached: process.platform !== "win32",
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ env: buildBashEnv(),
119
+ })
120
+
121
+ const killTree = () => killProcessTree(child)
122
+
123
+ // Separate decoders for stdout / stderr (same encoding in practice, but separate
124
+ // collection is cleaner and lets the model locate errors via stderr quickly)
125
+ const outDecoder = makeDecoder()
126
+ const errDecoder = makeDecoder()
127
+ let outBuf = ""
128
+ let errBuf = ""
129
+ let truncatedNote = ""
130
+
131
+ child.stdout.on("data", (d) => {
132
+ const s = sanitizeOutput(outDecoder(d))
133
+ if (s) {
134
+ onOutput?.(s)
135
+ if (outBuf.length < MAX_STREAM_BUF) outBuf += s
136
+ else if (!truncatedNote) truncatedNote =
137
+ "\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
138
+ }
139
+ })
140
+
141
+ child.stderr.on("data", (d) => {
142
+ const s = sanitizeOutput(errDecoder(d))
143
+ if (errBuf.length < MAX_STREAM_BUF) errBuf += s
144
+ })
145
+
146
+ const timer = setTimeout(killTree, timeout)
147
+ if (signal) signal.addEventListener("abort", killTree, { once: true })
148
+
149
+ child.on("error", (error) => {
150
+ clearTimeout(timer)
151
+ resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
152
+ })
153
+
154
+ child.on("close", (code, exitSignal) => {
155
+ clearTimeout(timer)
156
+ // Flush decoder tails (trailing multi-byte sequences that incomplete buffers left pending)
157
+ outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
158
+ errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
159
+
160
+ const status = exitSignal
161
+ ? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
162
+ : `exit code ${code}`
163
+
164
+ const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
165
+ if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
166
+ parts.push(`(${status})`)
167
+ resolve(truncate(parts.join("\n\n") + truncatedNote))
168
+ })
169
+ })
170
+ }
171
+
172
+ // ====================================================================
173
+ // tool definitions
174
+ // ====================================================================
175
+
33
176
  export const bashTool = {
34
177
  name: "bash",
35
178
  description: DESC("bash"),
@@ -42,111 +185,13 @@ export const bashTool = {
42
185
  required: ["command"],
43
186
  },
44
187
  readonly: false,
188
+ outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
45
189
  async execute(args, ctx) {
46
- // 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
47
- if (hasFileRedirection(args.command)) {
48
- throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
49
- }
50
- // 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
51
- if (shellSegments(args.command).some(isDestructiveCommand)) {
52
- throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
53
- }
54
- // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
55
- if (shellSegments(args.command).some(isDestructiveGitSegment)) {
56
- if (!insideGitRepo(ctx.cwd)) {
57
- throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
58
- }
59
- const status = execFileSync("git", ["status", "--porcelain"], {
60
- cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
61
- }).trim()
62
- if (status) {
63
- throw new Error(
64
- `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
65
- `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
66
- )
67
- }
68
- }
69
-
70
- return new Promise((resolve) => {
71
- // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
72
- // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
73
- const killTree = () => {
74
- if (process.platform === "win32") {
75
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
76
- } else {
77
- try { process.kill(-child.pid, "SIGKILL") } catch {}
78
- try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
79
- }
80
- }
81
- // Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
82
- // chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
83
- // 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
84
- // 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
85
- const winCmd = process.platform === "win32"
86
- const child = spawn(args.command, {
87
- cwd: ctx.cwd,
88
- shell: true,
89
- windowsHide: true,
90
- detached: process.platform !== "win32",
91
- stdio: ["ignore", "pipe", "pipe"],
92
- env: {
93
- ...Object.fromEntries(
94
- Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
95
- ),
96
- GIT_EDITOR: "true",
97
- EDITOR: "true",
98
- VISUAL: "true",
99
- GIT_PAGER: "cat",
100
- PAGER: "cat",
101
- TERM: "dumb",
102
- ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
103
- },
104
- })
105
- // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
106
- // 且允许模型按 stderr 快速定位错误)
107
- const outDecoder = makeDecoder()
108
- const errDecoder = makeDecoder()
109
- let outBuf = ""
110
- let errBuf = ""
111
- let truncatedNote = ""
112
-
113
- const onStdout = (d) => {
114
- const s = sanitizeOutput(outDecoder(d))
115
- if (s) {
116
- ctx.onOutput?.(s)
117
- if (outBuf.length < 2_000_000) outBuf += s
118
- else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
119
- }
120
- }
121
- const onStderr = (d) => {
122
- const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
123
- if (errBuf.length < 2_000_000) errBuf += s
124
- }
125
-
126
- child.stdout.on("data", onStdout)
127
- child.stderr.on("data", onStderr)
128
-
129
- const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
130
- if (ctx.signal) {
131
- ctx.signal.addEventListener("abort", killTree, { once: true })
132
- }
133
- child.on("error", (error) => {
134
- clearTimeout(timer)
135
- resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
136
- })
137
- child.on("close", (code, signal) => {
138
- clearTimeout(timer)
139
- // 冲刷解码器尾部
140
- outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
141
- errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
142
- const status = signal
143
- ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
144
- : `exit code ${code}`
145
- const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
146
- if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
147
- parts.push(`(${status})`)
148
- resolve(truncate(parts.join("\n\n") + truncatedNote))
149
- })
190
+ checkBashSafety(args.command, ctx.cwd)
191
+ return runBash(args.command, ctx.cwd, {
192
+ timeout: args.timeout ?? BASH_TIMEOUT_MS,
193
+ signal: ctx.signal,
194
+ onOutput: ctx.onOutput,
150
195
  })
151
196
  },
152
197
  }
@@ -180,7 +225,7 @@ export const globTool = {
180
225
  },
181
226
  }
182
227
 
183
- /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS */
228
+ /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
184
229
  async function* walkFiles(dir, rel = "") {
185
230
  let entries
186
231
  try {
@@ -199,7 +244,7 @@ async function* walkFiles(dir, rel = "") {
199
244
  }
200
245
  }
201
246
 
202
- /** glob 转正则:**\/ 匹配零或多级目录,** 跨目录,* 段内,? 段内单字符 */
247
+ /** Glob to regex: **\/ matches zero or more directory levels, ** crosses directories, * within segment, ? single char within segment */
203
248
 
204
249
  // ---------------------------------------------------------------- grep
205
250
 
@@ -226,17 +271,17 @@ export const grepTool = {
226
271
  const after = Math.max(0, Math.floor(args.after ?? 0))
227
272
  const wantCtx = before > 0 || after > 0
228
273
  const hits = [] // { file, line(1-based), text }
229
- const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
274
+ const fileLines = new Map() // file -> string[] (cached only when context lines requested)
230
275
 
231
276
  async function search(file) {
232
277
  let content
233
278
  try {
234
- // 大文件保护:超 10MB 跳过,防 OOM
279
+ // Large file guard: skip files over 10MB to prevent OOM
235
280
  const fst = await stat(file)
236
281
  if (fst.size > 10_000_000) return
237
282
  content = await readFile(file, "utf8")
238
283
  } catch {
239
- return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
284
+ return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
240
285
  }
241
286
  const lines = content.split("\n")
242
287
  if (wantCtx) fileLines.set(file, lines)
@@ -250,7 +295,7 @@ export const grepTool = {
250
295
 
251
296
  async function walk(target) {
252
297
  if (hits.length >= 200) return
253
- // lstat 不跟随符号链接——防 ./evil → /etc grep 读遍 /etc
298
+ // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
254
299
  let s
255
300
  try { s = await lstat(target) } catch { return }
256
301
  if (!s.isDirectory()) {
@@ -272,12 +317,12 @@ export const grepTool = {
272
317
  await walk(base)
273
318
  if (hits.length === 0) return "(no matches)"
274
319
 
275
- // 无上下文:保持原 path:line: content 格式
320
+ // No context: keep original path:line: content format
276
321
  if (!wantCtx) {
277
322
  return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
278
323
  }
279
324
 
280
- // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
325
+ // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
281
326
  const fileMatched = new Map() // file -> Set<line>
282
327
  for (const h of hits) {
283
328
  if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
package/src/tools/web.mjs CHANGED
@@ -36,7 +36,7 @@ export const websearchTool = {
36
36
  throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
37
37
  }
38
38
 
39
- // 结果块 <li class="b_algo">:<h2><a href>标题</a></h2> + <p>摘要</p>
39
+ // Result block <li class="b_algo">: <h2><a href>title</a></h2> + <p>snippet</p>
40
40
  const blocks = html.split('<li class="b_algo"').slice(1)
41
41
  const results = []
42
42
  for (const block of blocks) {
@@ -60,17 +60,17 @@ export const websearchTool = {
60
60
 
61
61
  // ---------------------------------------------------------------- ls
62
62
 
63
- /** SSRF 防护:拒绝内网私有段/metadata 端点(localhost 放行——用户本机,测试也依赖) */
63
+ /** SSRF protection: block internal private-network/metadata endpoints (localhost allowed — user's dev server, tests depend on it) */
64
64
  function isPrivateUrl(urlStr) {
65
65
  let u
66
66
  try { u = new URL(urlStr) } catch { return true }
67
67
  const host = u.hostname.toLowerCase()
68
- // localhost / 127.x 放行(用户本机开发服务器、测试 mock server
68
+ // localhost / 127.x allowed (user's dev server on local machine, test mock server)
69
69
  if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
70
70
  if (host === "127.0.0.1" || host.startsWith("127.")) return false
71
- // metadata 端点
71
+ // cloud metadata endpoint
72
72
  if (host === "169.254.169.254" || host === "metadata.google.internal") return true
73
- // IPv4 私有段
73
+ // IPv4 private ranges
74
74
  const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
75
75
  if (m) {
76
76
  const [a, b] = [Number(m[1]), Number(m[2])]
@@ -80,7 +80,7 @@ function isPrivateUrl(urlStr) {
80
80
  if (a === 169 && b === 254) return true
81
81
  if (a === 0) return true
82
82
  }
83
- // IPv6 环回 / 链路本地 / 唯一本地地址
83
+ // IPv6 loopback / link-local / unique local addresses
84
84
  if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
85
85
  return false
86
86
  }
@@ -3,8 +3,8 @@ import { saveSession } from "../session.mjs"
3
3
  import { sliceByWidth } from "./render.mjs"
4
4
  import { ansi, C } from "./ansi.mjs"
5
5
 
6
- /** 执行一轮 agent 对话(从 submit 或队列取出调用)。
7
- * index.mjs 抽出:agent 循环 + 回调构建 + 错误处理 + 队列处理。
6
+ /** Execute one agent conversation turn (triggered by submit or queue).
7
+ * Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
8
8
  * ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
9
9
  * ensureAssistantLabel, askPermission, askQuestion,
10
10
  * handleSlash, summarize } */
@@ -13,12 +13,12 @@ export async function runAgentTurn(ctx, text) {
13
13
  pushLabel(`❯ You:`, ansi.bold + C.user)
14
14
  pushLine(text, C.text)
15
15
 
16
- // 任务开始前自动打存档点 (git 仓库内;失败静默,不挡任务)
16
+ // Auto-checkpoint before task (git repo only; failure is silent, doesn't block the task)
17
17
  try {
18
18
  const { createCheckpoint } = await import("../git/checkpoint.mjs")
19
19
  await createCheckpoint(agent.cwd)
20
20
  } catch {
21
- // 存档失败不影响任务
21
+ // Checkpoint failure doesn't block the task
22
22
  }
23
23
 
24
24
  ctx.assistantLabeled = false
@@ -30,7 +30,7 @@ export async function runAgentTurn(ctx, text) {
30
30
  state.currentTool = null
31
31
  state.processingStarted = Date.now()
32
32
  state.controller = new AbortController()
33
- // 处理中每秒刷新一次状态栏 (运行计时)
33
+ // Refresh status bar every second during processing (elapsed timer)
34
34
  const ticker = setInterval(() => {
35
35
  if (state.processing) render()
36
36
  }, 1000)
@@ -49,7 +49,7 @@ export async function runAgentTurn(ctx, text) {
49
49
 
50
50
  const callbacks = {
51
51
  onToken: (t) => {
52
- // agent 流式输出:前缀格式 role#id/ → 提取 id,更新对应 subTask 的流式文本
52
+ // Subagent streaming: prefix format role#id/ → extract id, update subTask streaming text
53
53
  const subMatch = t.match(/^(\w+)#(\d+)\//)
54
54
  if (subMatch) {
55
55
  const key = `${subMatch[1]}#${subMatch[2]}`
@@ -69,7 +69,7 @@ export async function runAgentTurn(ctx, text) {
69
69
  scheduleRender()
70
70
  },
71
71
  onReasoning: (t) => {
72
- // agent 的思考 token 同样带 role#id/ 前缀,进 subTasks 面板
72
+ // Subagent reasoning tokens also carry role#id/ prefix, go into subTasks panel
73
73
  const subMatch = t.match(/^(\w+)#(\d+)\//)
74
74
  if (subMatch) {
75
75
  const key = `${subMatch[1]}#${subMatch[2]}`
@@ -84,7 +84,7 @@ export async function runAgentTurn(ctx, text) {
84
84
  scheduleRender()
85
85
  },
86
86
  onToolCall: (name, args) => {
87
- // agent 工具调用:前缀 role#id/toolName → 更新对应 subTask 的当前工具
87
+ // Subagent tool call: prefix role#id/toolName → update subTask current tool
88
88
  const subMatch = name.match(/^(\w+)#(\d+)\//)
89
89
  if (subMatch) {
90
90
  const key = `${subMatch[1]}#${subMatch[2]}`
@@ -105,7 +105,7 @@ export async function runAgentTurn(ctx, text) {
105
105
  },
106
106
  onToolResult: (name, result) => {
107
107
  state.currentTool = null
108
- // agent 结束:标记最早创建的 running subTask done
108
+ // Subagent complete: mark earliest running subTask as done
109
109
  const isSubagent = name === "subagent"
110
110
  if (isSubagent) {
111
111
  const running = Object.entries(state.subTasks)
@@ -116,12 +116,12 @@ export async function runAgentTurn(ctx, text) {
116
116
  state.subTasks[key].done = true
117
117
  state.subTasks[key].tool = null
118
118
  }
119
- // agent 报告摘要 (最多 8 行)直接展示在对话区
119
+ // Subagent report preview (max 8 lines) displayed directly in conversation
120
120
  const lines = result.split("\n")
121
121
  const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
122
122
  if (preview) pushLine(preview, C.dim)
123
123
  if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
124
- // 3 秒后清除面板中 done 的条目
124
+ // Clear done entries from panel after 3 seconds
125
125
  setTimeout(() => {
126
126
  for (const key of Object.keys(state.subTasks)) {
127
127
  if (state.subTasks[key].done) delete state.subTasks[key]
@@ -130,22 +130,47 @@ export async function runAgentTurn(ctx, text) {
130
130
  }, 3000)
131
131
  }
132
132
  const stream = state.toolStreams[name]
133
- if (stream) {
133
+ const panel = state.outputPanels[name]
134
+ if (panel) {
135
+ // Tool with output panel: mark panel as done, conversation gets only summary
136
+ panel.done = true
137
+ delete state.toolStreams[name]
138
+ const summary = formatPanelSummary(name, result)
139
+ if (summary) pushLine(` ${summary}`, C.dim)
140
+ // Clear panel after 3 seconds
141
+ setTimeout(() => {
142
+ delete state.outputPanels[name]
143
+ if (state.processing) render()
144
+ }, 3000)
145
+ } else if (stream) {
134
146
  const tail = stream.trimEnd().slice(-4000)
135
147
  if (tail) pushLine(tail, C.dim)
136
148
  delete state.toolStreams[name]
137
149
  }
138
- if (!isSubagent) {
150
+ if (!isSubagent && !panel) {
139
151
  const first = result.split("\n")[0]
140
152
  pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
141
153
  }
142
154
  },
143
155
  onToolOutput: (name, chunk) => {
144
- state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
156
+ // Route streaming output to a panel if one exists or was requested via outputPanel flag.
157
+ let panel = state.outputPanels[name]
158
+ if (!panel) {
159
+ // Lazy-create panel: defensive against race conditions where setupOutputPanel
160
+ // hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
161
+ state.outputPanels[name] = { text: "", done: false }
162
+ panel = state.outputPanels[name]
163
+ }
164
+ panel.text = (panel.text ?? "") + chunk
165
+ if (panel.text.length > 4000) panel.text = panel.text.slice(-4000)
145
166
  scheduleRender()
146
167
  },
147
168
  onPermissionRequest: (name, args) => askPermission(name, args),
148
169
  onQuestion: (text, options) => askQuestion(text, options),
170
+ setupOutputPanel: (name) => {
171
+ state.outputPanels[name] = { text: "", done: false }
172
+ scheduleRender()
173
+ },
149
174
  onCompress: () => {
150
175
  pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
151
176
  },
@@ -155,7 +180,7 @@ export async function runAgentTurn(ctx, text) {
155
180
  state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
156
181
  state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
157
182
  },
158
- // 节流等待 (主动闸门 / 429 退避):状态栏明示,防用户以为卡死
183
+ // Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
159
184
  onWait: ({ phase, seconds }) => {
160
185
  state.status = phase === "gate" ? `TPM throttle wait ~${seconds}s` : `Rate-limited 429, retry in ${seconds}s`
161
186
  render()
@@ -163,12 +188,12 @@ export async function runAgentTurn(ctx, text) {
163
188
  onTaskUpdate: (items) => {
164
189
  state.tasks = items
165
190
  const done = items.filter((i) => i.status === "done").length
166
- // 留痕带上current任务标题:回看历史时知道进行到哪一项
191
+ // Leave trace with current task title: reviewing history shows what was in progress
167
192
  const current = items.find((i) => i.status === "in_progress")
168
193
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
169
194
  render()
170
195
  },
171
- // 增量保存:每 5 个工具 turn 落一次盘,中途崩溃丢失窗口从一整轮缩到几轮
196
+ // Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
172
197
  onTurnEnd: (() => {
173
198
  let n = 0
174
199
  return () => {
@@ -182,7 +207,7 @@ export async function runAgentTurn(ctx, text) {
182
207
  try {
183
208
  await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
184
209
  flushStream()
185
- break // 正常完成,退出循环
210
+ break // Normal completion, exit loop
186
211
  } catch (error) {
187
212
  flushStream()
188
213
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
@@ -192,7 +217,7 @@ export async function runAgentTurn(ctx, text) {
192
217
  if (error instanceof ContinueError) {
193
218
  pushLabel(`❯ Continue`, ansi.bold + C.warn)
194
219
  pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
195
- // 暂停询问:复用 permission 机制
220
+ // Pause to ask: reuse permission mechanism
196
221
  const willContinue = await new Promise((resolve) => {
197
222
  state.permission = {
198
223
  name: "continue",
@@ -208,7 +233,7 @@ export async function runAgentTurn(ctx, text) {
208
233
  break
209
234
  }
210
235
  pushLine("[continuing…]", C.tool)
211
- // 重创新 AbortController:旧 signal 一旦 abort 过,resume 会立即失败 (防御性,current路径不可达但耦合紧)
236
+ // Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
212
237
  state.controller = new AbortController()
213
238
  continue
214
239
  }
@@ -222,26 +247,26 @@ export async function runAgentTurn(ctx, text) {
222
247
  state.subTasks = {}
223
248
  state.controller = null
224
249
  state.status = "Ready"
225
- // 全部完成时自动收起 todo 面板 (对齐 kimi-code TUIagent.tasks 本身保留)
250
+ // Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
226
251
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
227
252
  state.tasks = []
228
253
  }
229
- // 每轮结束后保存会话 (崩溃也不丢)
254
+ // Save session after every turn (survives crashes)
230
255
  try {
231
256
  saveSession(agent, state.lines)
232
257
  } catch {
233
- // 存失败不打断使用
258
+ // Save failure doesn't interrupt usage
234
259
  }
235
260
  render()
236
261
 
237
- // 队列里有待执行消息:自动取下一条执行
262
+ // Queued messages: auto-process next one
238
263
  if (state.queue.length > 0) {
239
264
  const next = state.queue.shift()
240
- // 队列里的斜杠命令直接执行
265
+ // Queued slash commands execute directly
241
266
  if (next.text.startsWith("/")) {
242
267
  await handleSlash(next.text)
243
268
  render()
244
- // 斜杠命令执行完也继续检查队列
269
+ // After slash command completes, continue checking queue
245
270
  if (state.queue.length > 0 && !state.processing) {
246
271
  const next2 = state.queue.shift()
247
272
  await runAgentTurn(ctx, next2.text)
@@ -252,3 +277,34 @@ export async function runAgentTurn(ctx, text) {
252
277
  }
253
278
  }
254
279
  }
280
+
281
+ /** Extract a one-line summary from a panel tool's output */
282
+ function formatPanelSummary(name, result) {
283
+ if (name === "verify") return _verifySummary(result)
284
+ // Default: first non-empty line
285
+ const first = result.split("\n").find((l) => l.trim())
286
+ return first ? `${name}: ${first.slice(0, 100)}` : null
287
+ }
288
+
289
+ function _verifySummary(result) {
290
+ const lines = result.split("\n")
291
+ const summary = []
292
+ // Changed files count
293
+ const changed = lines.find((l) => l.startsWith("Changed files:"))
294
+ if (changed) {
295
+ const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
296
+ summary.push(m)
297
+ }
298
+ // Syntax check results
299
+ const syntax = lines.filter((l) => l.startsWith(" ✗"))
300
+ if (syntax.length > 0) {
301
+ summary.push(`${syntax.length} syntax error(s)`)
302
+ }
303
+ // Test results
304
+ const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
305
+ if (testLine) summary.push(testLine.trim())
306
+ // Task list
307
+ const taskLine = lines.find((l) => l.startsWith("Task list:"))
308
+ if (taskLine) summary.push(taskLine)
309
+ return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
310
+ }
package/src/tui/ansi.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * tui-ansi.mjs — ANSI 转义序列与终端颜色常量
3
- * 零依赖,纯常量导出。
2
+ * tui-ansi.mjs — ANSI escape sequences and terminal color constants.
3
+ * Zero dependencies, pure constant exports.
4
4
  */
5
5
 
6
6
  export const ESC = "\x1b"
@@ -11,6 +11,8 @@ export const ansi = {
11
11
  mainBuffer: `${ESC}[?1049l`,
12
12
  mouseOn: `${ESC}[?1000h${ESC}[?1006h`,
13
13
  mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
14
+ bracketedPasteOn: `${ESC}[?2004h`,
15
+ bracketedPasteOff: `${ESC}[?2004l`,
14
16
  home: `${ESC}[H`,
15
17
  clearLine: `${ESC}[K`,
16
18
  reset: `${ESC}[0m`,
@@ -24,7 +26,7 @@ export const C = {
24
26
  user: ansi.fg(4),
25
27
  assistant: ansi.fg(2),
26
28
  text: ansi.fg(7),
27
- reason: `${ESC}[2m${ESC}[3m`,
29
+ reason: `${ESC}[2m`,
28
30
  tool: ansi.fg(6),
29
31
  error: ansi.fg(1),
30
32
  dim: ansi.gray,