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
@@ -1,7 +1,7 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
 
3
- /** 键盘事件分发:权限确认 / 问答 / picker / wizard / 编辑 / 翻页 / 历史 / 粘贴。
4
- * index.mjs 抽出。
3
+ /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
4
+ * Extracted from index.mjs.
5
5
  * ctx: { agent, state, render, renderPickerLines, closePicker,
6
6
  * handleSlash, handleTab, submit, pasteClipboardImage,
7
7
  * wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
@@ -10,7 +10,7 @@ export function createKeyHandler(ctx) {
10
10
  const { agent, state, render, closePicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
11
11
 
12
12
  return function onKeypress(str, key = {}) {
13
- // 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
13
+ // permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
14
14
  if (state.permission) {
15
15
  const answer = (str || "").toLowerCase()
16
16
  const isContinue = state.permission.name === "continue"
@@ -27,7 +27,7 @@ export function createKeyHandler(ctx) {
27
27
  pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
28
28
  }
29
29
  const approved = answer === "y" || (answer === "a" && !isContinue)
30
- // 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
30
+ // leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
31
31
  if (!isContinue) {
32
32
  pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
33
33
  }
@@ -37,13 +37,13 @@ export function createKeyHandler(ctx) {
37
37
  return
38
38
  }
39
39
 
40
- // question 工具回调:自由文本 / 选项选择
40
+ // question tool callback: free text / option selection
41
41
  if (state.question) {
42
42
  const q = state.question
43
43
  if (q.options.length > 0) {
44
- // 选项模式:↑↓ 选择,Enter 确认,Esc 取消
44
+ // options mode: ↑↓ select, Enter confirm, Esc cancel
45
45
  if (key.name === "escape") {
46
- q.resolve("(cancelled)")
46
+ q.resolve("")
47
47
  state.question = null
48
48
  state.status = "Processing..."
49
49
  render()
@@ -62,15 +62,15 @@ export function createKeyHandler(ctx) {
62
62
  render()
63
63
  }
64
64
  } else {
65
- // 自由文本:键入答案,Enter 提交,Esc 取消
65
+ // free text: type answer, Enter submit, Esc cancel
66
66
  if (key.name === "escape") {
67
- q.resolve("(cancelled)")
67
+ q.resolve("")
68
68
  state.question = null
69
69
  state.status = "Processing..."
70
70
  render()
71
71
  } else if (key.name === "return") {
72
72
  const answer = (q.answer ?? "").trim()
73
- q.resolve(answer || "(empty answer)")
73
+ q.resolve(answer || "")
74
74
  state.question = null
75
75
  state.status = "Processing..."
76
76
  pushLine(` → ${answer || "(empty)"}`, C.tool)
@@ -97,7 +97,7 @@ export function createKeyHandler(ctx) {
97
97
  setTimeout(() => process.exit(0), 100)
98
98
  }
99
99
 
100
- // 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
100
+ // generic list picker: ↑↓ move, Enter confirm, Esc cancel
101
101
  if (state.picker) {
102
102
  const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
103
103
  if (key.name === "escape") {
@@ -111,8 +111,8 @@ export function createKeyHandler(ctx) {
111
111
  } else if (key.name === "return" && items.length) {
112
112
  const selected = items[state.picker.index]
113
113
  const handler = state.picker.onSelect
114
- state.picker = null // 先关 picker,避免 onSelect 内部 render picker 还在
115
- // onSelect async(如删 provider 要写文件),用 catch 兜住错误不被吞
114
+ state.picker = null // close picker first, avoid picker still being present during onSelect render
115
+ // onSelect is async (e.g. removing a provider writes file), catch errors so they aren't swallowed
116
116
  Promise.resolve(handler?.(selected)).catch((err) => {
117
117
  pushLine(`[error] ${err.message}`, C.error)
118
118
  }).finally(() => render())
@@ -120,7 +120,7 @@ export function createKeyHandler(ctx) {
120
120
  return
121
121
  }
122
122
 
123
- // 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
123
+ // initial config wizard: menu step ↑↓/Enter/Esc; text step Enter submit, Esc cancel, edit keys fall through to normal input
124
124
  if (state.wizard) {
125
125
  const w = state.wizard
126
126
  if (key.name === "escape") {
@@ -144,11 +144,11 @@ export function createKeyHandler(ctx) {
144
144
  wizardSubmitText()
145
145
  return
146
146
  }
147
- // 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
147
+ // text steps: block scroll/history, remaining edit keys fall through to normal input logic below
148
148
  if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
149
149
  }
150
150
 
151
- // 翻页
151
+ // page scroll
152
152
  if (key.name === "pageup") {
153
153
  state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
154
154
  render()
@@ -161,9 +161,9 @@ export function createKeyHandler(ctx) {
161
161
  }
162
162
 
163
163
  if (state.processing) {
164
- // 处理中允许输入(排队),但屏蔽方向键历史和 Tab 补全
164
+ // during processing, allow input (queued), but block arrow-key history and Tab completion
165
165
  if (key.name === "tab" || key.name === "up" || key.name === "down") return
166
- // Ctrl+D:删除队列中最后一条
166
+ // Ctrl+D: remove last item from queue
167
167
  if (key.ctrl && key.name === "d") {
168
168
  if (state.queue.length > 0) {
169
169
  state.queue.pop()
@@ -171,16 +171,24 @@ export function createKeyHandler(ctx) {
171
171
  }
172
172
  return
173
173
  }
174
- // 其余可打印字符正常进入输入框
174
+ // remaining printable characters go into input box normally
175
175
  }
176
176
 
177
- // Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
177
+ // Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
178
178
  if (key.name === "tab") {
179
179
  handleTab()
180
180
  return
181
181
  }
182
182
 
183
- // 输入历史
183
+ // Ctrl+U: clear entire input box
184
+ if ((key.name === "u" && key.ctrl) || str === "\x15") {
185
+ state.input = []
186
+ state.cursor = 0
187
+ render()
188
+ return
189
+ }
190
+
191
+ // input history
184
192
  if (key.name === "up") {
185
193
  if (state.history.length) {
186
194
  state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
@@ -205,7 +213,7 @@ export function createKeyHandler(ctx) {
205
213
  return
206
214
  }
207
215
 
208
- // 光标移动
216
+ // cursor movement
209
217
  if (key.name === "left") {
210
218
  state.cursor = Math.max(0, state.cursor - 1)
211
219
  render()
@@ -227,7 +235,7 @@ export function createKeyHandler(ctx) {
227
235
  return
228
236
  }
229
237
 
230
- // 编辑
238
+ // editing
231
239
  if (key.name === "backspace") {
232
240
  if (state.cursor > 0) {
233
241
  state.input.splice(state.cursor - 1, 1)
@@ -248,15 +256,15 @@ export function createKeyHandler(ctx) {
248
256
  return
249
257
  }
250
258
 
251
- // Ctrl+V (Unix) / Alt+V (Windows):粘贴剪贴板图片存临时文件输入框插入 read_image
259
+ // Ctrl+V (Unix) / Alt+V (Windows): paste clipboard image save temp file insert read_image into input box
252
260
  const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
253
261
  if (isPasteImage) {
254
262
  pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
255
263
  return
256
264
  }
257
265
 
258
- // 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
259
- // \r\n Windows raw mode 下可能漏进来冲乱页面
266
+ // printable characters / paste (str may contain multiple chars at once); Tab always converted to two spaces (\t has variable display width, would blow up input box)
267
+ // \r\n may leak through in Windows raw mode and scramble the display
260
268
  if (str && !key.ctrl && !key.meta) {
261
269
  const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
262
270
  state.input.splice(state.cursor, 0, ...chars)
@@ -1,11 +1,11 @@
1
1
  /**
2
- * layout.mjs — TUI 布局引擎(纯函数)
3
- * state + 终端尺寸计算每个面板的位置和高度。
4
- * 不修改 state——副作用由调用方在渲染前执行。
2
+ * layout.mjs — TUI layout engine (pure function)
3
+ * Computes position and height of each panel from state + terminal dimensions.
4
+ * Does not modify state — side effects are performed by the caller before rendering.
5
5
  *
6
- * 面板布局(自上而下):
7
- * header → conversation → pickertodosubagent → permission → queue → input → status
8
- * 固定面板先扣除,条件面板按优先级分配,剩余空间全给 conversation
6
+ * Panel layout (top to bottom):
7
+ * header → conversation → subagentoutputtodo → permission → queue → input → status
8
+ * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
9
9
  */
10
10
  import { layoutInput, wrapText } from "./render.mjs"
11
11
 
@@ -15,13 +15,13 @@ export const MAX_SUB_LINES = 4
15
15
  const QWIN = 5
16
16
 
17
17
  /**
18
- * 计算布局。返回面板坐标 + 预计算的内容(影响高度的部分)。
19
- * 纯函数:不修改 state
18
+ * Computes layout. Returns panel coordinates + precomputed content (height-affecting parts).
19
+ * Pure function: does not modify state.
20
20
  */
21
21
  export function computeLayout(state, { cols, rows }) {
22
22
  const W = Math.max(20, cols - 1)
23
23
 
24
- // --- 输入框 ---
24
+ // --- input box ---
25
25
  const inputLayout = layoutInput(state.input, state.cursor, W - 4)
26
26
  let inputOffset = 0
27
27
  if (inputLayout.lines.length > MAX_INPUT_LINES) {
@@ -41,11 +41,11 @@ export function computeLayout(state, { cols, rows }) {
41
41
  }
42
42
  const inputBoxH = boxLines.length + 2
43
43
 
44
- // --- 固定面板 ---
44
+ // --- fixed panels ---
45
45
  const headerH = 1
46
46
  const statusH = 1
47
47
 
48
- // --- 条件面板 ---
48
+ // --- conditional panels ---
49
49
  const overlay = state.picker ?? state.wizard
50
50
  const pickerH = overlay ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12)) : 0
51
51
 
@@ -67,6 +67,10 @@ export function computeLayout(state, { cols, rows }) {
67
67
  ? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
68
68
  : 0
69
69
 
70
+ // Tool output panels: max 8 lines per panel, capped at reasonable total
71
+ const panels = Object.values(state.outputPanels).filter((p) => !p.done)
72
+ const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
73
+
70
74
  // Permission preview (height depends on wrapped content)
71
75
  let permPreviewLines = []
72
76
  let permPreviewH = 0
@@ -84,17 +88,18 @@ export function computeLayout(state, { cols, rows }) {
84
88
  // Queue preview (1 line when queue has items and processing)
85
89
  const queueH = state.queue.length > 0 && state.processing ? 1 : 0
86
90
 
87
- // --- 弹性面板:conversation 占剩余空间 ---
88
- const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + permPreviewH + queueH
91
+ // --- elastic panel: conversation takes remaining space ---
92
+ const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
89
93
  const convH = Math.max(1, rows - fixedH)
90
94
 
91
- // --- Y 坐标(0-indexed,ANSI 用时 +1)---
95
+ // --- Y coordinates (0-indexed, +1 when used with ANSI) ---
92
96
  let y = 0
93
97
  const header = { y, h: headerH }; y += headerH
94
98
  const conversation = { y, h: convH }; y += convH
99
+ const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
100
+ const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
95
101
  const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
96
102
  const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
97
- const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
98
103
  const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
99
104
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
100
105
  const inputBox = { y, h: inputBoxH }; y += inputBoxH
@@ -102,8 +107,8 @@ export function computeLayout(state, { cols, rows }) {
102
107
 
103
108
  return {
104
109
  W, cols, rows,
105
- panels: { header, conversation, picker, todo, subagent, permission, queue, inputBox, status },
106
- // 预计算内容(影响高度,渲染时复用)
110
+ panels: { header, conversation, picker, todo, subagent, output, permission, queue, inputBox, status },
111
+ // precomputed content (affects height, reused during render)
107
112
  inputLayout,
108
113
  inputOffset,
109
114
  boxLines,
@@ -1,16 +1,16 @@
1
1
  import { sliceByWidth } from "./render.mjs"
2
2
  import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
3
3
 
4
- /** 通用列表选择器 + 统一模型/provider 管理 picker
5
- * 通过 createPickers(ctx) 接收闭包依赖,
6
- * 返回 { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey } */
4
+ /** Generic list picker + unified model/provider management picker.
5
+ * Receives closure dependencies via createPickers(ctx),
6
+ * returns { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey }. */
7
7
  export function createPickers(ctx) {
8
8
  const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey } = ctx
9
9
 
10
10
  const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
11
11
 
12
- /** 打开通用列表选择器。entries { type: "header"|"item", text, note?, ...extra }
13
- * onSelect 拿到选中条目 ( extra 字段透传),onCancel Esc 时调。 */
12
+ /** Open a generic list picker. entries contain { type: "header"|"item", text, note?, ...extra },
13
+ * onSelect receives the selected entry (with extra fields forwarded), onCancel called on Esc. */
14
14
  function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
15
15
  state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
16
16
  renderPickerLines()
@@ -22,7 +22,7 @@ export function createPickers(ctx) {
22
22
  render()
23
23
  }
24
24
 
25
- /** entries 重建显示行并刷新 */
25
+ /** Rebuild display lines from entries and refresh */
26
26
  function renderPickerLines() {
27
27
  const p = state.picker
28
28
  if (!p) return
@@ -48,7 +48,7 @@ export function createPickers(ctx) {
48
48
  render()
49
49
  }
50
50
 
51
- // ========== 统一 picker:模型切换 + provider 管理 ==========
51
+ // ========== unified picker: model switch + provider management ==========
52
52
 
53
53
  async function openModelPicker() {
54
54
  const entries = buildModelEntries()
@@ -64,14 +64,14 @@ export function createPickers(ctx) {
64
64
  }
65
65
  }
66
66
  openPicker({ title: "Models & Providers", entries, onSelect })
67
- // 默认选中当前在用的模型
67
+ // default select the currently active model
68
68
  const current = pickerItems().findIndex(
69
69
  (e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model,
70
70
  )
71
71
  if (current >= 0) state.picker.index = current
72
72
  renderPickerLines()
73
73
 
74
- // 异步拉取远端模型列表,追加到对应 provider
74
+ // async fetch remote model list, append under each provider
75
75
  const { listModels } = await import("../provider/index.mjs")
76
76
  await Promise.all(
77
77
  agent.providers.map(async (p) => {
@@ -84,7 +84,7 @@ export function createPickers(ctx) {
84
84
  { baseURL: p.baseURL, apiKey: apiKey ?? "" },
85
85
  { signal: AbortSignal.timeout(10000) },
86
86
  )
87
- // 找到当前 provider entries 里的模型 item 位置,在其后插入新模型
87
+ // find the model item position for this provider in entries, insert new models after it
88
88
  const at = entries.findLastIndex(
89
89
  (e) => e.type === "item" && e.action === "switch" && e.provider === p.name,
90
90
  )
@@ -97,7 +97,7 @@ export function createPickers(ctx) {
97
97
  .map((m) => ({ type: "item", text: m, action: "switch", provider: p.name, model: m })),
98
98
  )
99
99
  }
100
- // 更新 header note 去掉 loading
100
+ // update header note to remove "loading..."
101
101
  const header = entries.find((e) => e.type === "header" && e.text === p.name)
102
102
  if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
103
103
  } catch (error) {
@@ -109,7 +109,7 @@ export function createPickers(ctx) {
109
109
  )
110
110
  }
111
111
 
112
- /** 构建 picker entries:每个 provider 一个 header + 模型列表,底部是管理操作 */
112
+ /** Build picker entries: each provider gets a header + model list, management actions at the bottom */
113
113
  function buildModelEntries() {
114
114
  const entries = []
115
115
  for (const p of agent.providers) {
@@ -128,7 +128,7 @@ export function createPickers(ctx) {
128
128
  marker: active ? "●" : "",
129
129
  })
130
130
  }
131
- // 管理操作
131
+ // management actions
132
132
  entries.push({ type: "header", text: "Provider Management" })
133
133
  entries.push({ type: "item", text: "Add provider…", action: "add" })
134
134
  if (agent.providers.length > 1) {
@@ -138,7 +138,7 @@ export function createPickers(ctx) {
138
138
  return entries
139
139
  }
140
140
 
141
- /** 切换 provider + 模型,持久化,阈值随模型走 */
141
+ /** Switch provider + model, persist, threshold follows model */
142
142
  async function selectModel(item) {
143
143
  closePicker()
144
144
  const target = agent.providers.find((pp) => pp.name === item.provider)
@@ -167,7 +167,7 @@ export function createPickers(ctx) {
167
167
  }
168
168
  }
169
169
 
170
- /** 添加 provider:预设菜单输入 key → 完成,或自定义逐项输入 */
170
+ /** Add provider: preset menu input key → done, or custom step-by-step input */
171
171
  async function addProviderFlow() {
172
172
  const presetEntries = [
173
173
  { type: "header", text: "Select a preset provider" },
@@ -204,7 +204,7 @@ export function createPickers(ctx) {
204
204
  openModelPicker().catch(() => {})
205
205
  return
206
206
  }
207
- // 预设
207
+ // preset
208
208
  if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
209
209
  const preset = PRESETS[se.name]
210
210
  const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
@@ -222,7 +222,7 @@ export function createPickers(ctx) {
222
222
  })
223
223
  }
224
224
 
225
- /** 删除 provider(不能删当前激活的) */
225
+ /** Remove provider (cannot remove the currently active one) */
226
226
  async function removeProviderFlow() {
227
227
  const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
228
228
  if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
@@ -243,7 +243,7 @@ export function createPickers(ctx) {
243
243
  })
244
244
  }
245
245
 
246
- /** 设置/更改 API key:选 provider → 输入 key */
246
+ /** Set/change API key: select provider → enter key */
247
247
  async function setKeyFlow() {
248
248
  const keyEntries = [
249
249
  { type: "header", text: "Select provider to configure key" },
@@ -266,7 +266,7 @@ export function createPickers(ctx) {
266
266
  })
267
267
  }
268
268
 
269
- /** 给指定 provider key (内存 + Config文件);若它是current激活的,同步运行时 */
269
+ /** Write key for a given provider (memory + config file); if it's the currently active one, sync runtime too */
270
270
  async function setProviderKey(name, key) {
271
271
  const target = agent.providers.find((p) => p.name === name)
272
272
  if (!target) return
@@ -1,9 +1,9 @@
1
1
  /**
2
- * render-frame.mjs — 终端帧渲染(纯计算,无副作用)
3
- * state + agent + layout 产生 ANSI 帧字符串,返回光标位置。
2
+ * render-frame.mjs — terminal frame renderer (pure computation, no side effects)
3
+ * Produces an ANSI frame string from state + agent + layout, returns cursor position.
4
4
  *
5
- * 布局计算见 layout.mjscomputeLayout 纯函数)。
6
- * 副作用(scroll 归位、ctxCache 更新)由调用方在渲染前执行。
5
+ * Layout calculation lives in layout.mjs (computeLayout pure function).
6
+ * Side effects (scroll clamping, ctxCache update) are performed by the caller before rendering.
7
7
  */
8
8
  import { ansi, C, ESC } from "./ansi.mjs"
9
9
  import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
@@ -13,7 +13,7 @@ import {
13
13
  import { specForModel } from "../config.mjs"
14
14
  import { basename } from "node:path"
15
15
 
16
- // ---------- status bar slash-command hints (查表替代 if-else ) ----------
16
+ // ---------- status bar slash-command hints (lookup table instead of if-else chain) ----------
17
17
  const SLASH_HINTS = {
18
18
  "/config": "open config menu",
19
19
  "/model": "select model & manage providers",
@@ -25,8 +25,8 @@ const SLASH_HINTS = {
25
25
  }
26
26
 
27
27
  /**
28
- * 渲染一帧,返回 { frame, cursorRow, cursorCol }
29
- * 纯函数:不修改 state/agent
28
+ * Render one frame, returns { frame, cursorRow, cursorCol }.
29
+ * Pure function: does not modify state/agent.
30
30
  */
31
31
  export function renderFrame(state, agent, opts) {
32
32
  const cols = opts.cols || 80
@@ -111,6 +111,29 @@ export function renderFrame(state, agent, opts) {
111
111
  }
112
112
  }
113
113
 
114
+ // ---- tool output panels (streaming output like tail -f, auto-clears when done) ----
115
+ if (panels.output) {
116
+ const active = Object.values(state.outputPanels).filter((p) => !p.done)
117
+ if (active.length > 0) {
118
+ const linesPerPanel = Math.max(1, Math.floor(panels.output.h / active.length))
119
+ for (const p of active) {
120
+ const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
121
+ const tail = textLines.slice(-linesPerPanel)
122
+ for (const line of tail) {
123
+ out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}${ansi.clearLine}`)
124
+ }
125
+ }
126
+ // fill remaining rows
127
+ const used = active.reduce((s, p) => {
128
+ const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
129
+ return s + tl.length
130
+ }, 0)
131
+ for (let i = used; i < panels.output.h; i++) {
132
+ out.push(ansi.clearLine)
133
+ }
134
+ }
135
+ }
136
+
114
137
  // ---- permission preview ----
115
138
  if (panels.permission) {
116
139
  out.push(`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}${ansi.clearLine}`)
@@ -122,7 +145,7 @@ export function renderFrame(state, agent, opts) {
122
145
  // ---- queue preview ----
123
146
  if (panels.queue) {
124
147
  const preview = sliceByWidth(state.queue[0].text, W - 20)
125
- out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D del${ansi.reset}${ansi.clearLine}`)
148
+ out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D delete${ansi.reset}${ansi.clearLine}`)
126
149
  }
127
150
 
128
151
  // ---- input box ----
@@ -131,6 +154,9 @@ export function renderFrame(state, agent, opts) {
131
154
  if (title === " Input " && isMultimodal) {
132
155
  const hint = platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
133
156
  topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
157
+ } else if (title === " Input ") {
158
+ const hint = " Ctrl+U clear "
159
+ topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
134
160
  } else {
135
161
  topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
136
162
  }
@@ -161,7 +187,7 @@ export function renderFrame(state, agent, opts) {
161
187
  return { frame, cursorRow, cursorCol }
162
188
  }
163
189
 
164
- // ---------------------------------------------------------- 内部实现
190
+ // ---------------------------------------------------------- internal helpers
165
191
 
166
192
  /** Count conversation lines after sanitize + wrap (for scroll clamping). Pure. */
167
193
  export function countConvLines(state, cols) {
@@ -282,8 +308,8 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
282
308
  const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
283
309
  const ctxHint = ctxPct > 0
284
310
  ? ctxPct >= 80
285
- ? ` │ ${ansi.reset}${C.warn}ctx ${ctxPct}%${ansi.reset}${ansi.dim}`
286
- : ` │ ctx ${ctxPct}%`
311
+ ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}`
312
+ : ` │ context ${ctxPct}%`
287
313
  : ""
288
314
  const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
289
315
  return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
@@ -1,14 +1,14 @@
1
1
  /**
2
- * tui-render.mjs — 终端显示工具(纯函数,零依赖)
3
- * 字符宽度计算、CJK/emoji 排版、文本折行、markdown 表格重排。
2
+ * tui-render.mjs — terminal display utilities (pure functions, zero dependencies)
3
+ * Character width calculation, CJK/emoji typesetting, text wrapping, markdown table reformatting.
4
4
  */
5
5
 
6
- /** 字符显示宽度:CJK/emoji 2,组合字符计 0,其余计 1 */
6
+ /** Character display width: CJK/emoji count as 2, combining characters as 0, rest as 1 */
7
7
  export function charWidth(cp) {
8
8
  if (
9
- (cp >= 0x300 && cp <= 0x36f) || // 组合变音符
10
- (cp >= 0x200b && cp <= 0x200f) || // 零宽
11
- cp === 0xfe0f // emoji 变体选择符
9
+ (cp >= 0x300 && cp <= 0x36f) || // combining diacritics
10
+ (cp >= 0x200b && cp <= 0x200f) || // zero-width
11
+ cp === 0xfe0f // emoji variation selector
12
12
  ) {
13
13
  return 0
14
14
  }
@@ -29,13 +29,14 @@ export function charWidth(cp) {
29
29
  return 1
30
30
  }
31
31
 
32
+ /** Compute the display width of a string (CJK characters count as 2) */
32
33
  export function stringWidth(text) {
33
34
  let w = 0
34
35
  for (const ch of text) w += charWidth(ch.codePointAt(0))
35
36
  return w
36
37
  }
37
38
 
38
- /** 按显示宽度裁剪 */
39
+ /** Slice by display width */
39
40
  export function sliceByWidth(text, maxWidth) {
40
41
  let w = 0
41
42
  let out = ""
@@ -48,19 +49,19 @@ export function sliceByWidth(text, maxWidth) {
48
49
  return out
49
50
  }
50
51
 
51
- /** 按显示宽度右补空格 */
52
+ /** Right-pad by display width */
52
53
  function padByWidth(text, width) {
53
54
  return text + " ".repeat(Math.max(0, width - stringWidth(text)))
54
55
  }
55
56
 
56
- // ---------------------------------------------------------------- markdown 表格重排
57
+ // ---------------------------------------------------------------- markdown table reformatting
57
58
 
58
59
  const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
59
60
  const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
60
61
 
61
62
  /**
62
- * 识别文本中的 markdown 表格块,按显示宽度重排 ( CJK 错位)。
63
- * width 为可用显示宽度;过宽的表格按列收缩。非表格行原样保留。
63
+ * Identify markdown table blocks in text, reformat by display width (fix CJK misalignment).
64
+ * width is the available display width; over-wide tables shrink columns. Non-table lines are kept as-is.
64
65
  */
65
66
  export function formatTables(text, width) {
66
67
  const lines = text.split("\n")
@@ -94,31 +95,31 @@ function renderTable(block, width) {
94
95
  const colCount = Math.max(...rows.map((r) => r.length))
95
96
  for (const r of rows) while (r.length < colCount) r.push("")
96
97
 
97
- // 列宽:先按内容,超宽则从最宽列开始收缩 (收缩到至少 3
98
+ // Column widths: content first; if too wide, shrink from widest column (down to at least 3)
98
99
  const widths = Array.from({ length: colCount }, (_, c) =>
99
100
  Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
100
101
  )
101
- const borders = colCount * 3 + 1 // " │ " 分隔 + 首尾 |
102
+ const borders = colCount * 3 + 1 // " │ " separators + leading/trailing |
102
103
  while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
103
104
  const widest = widths.indexOf(Math.max(...widths))
104
105
  widths[widest]--
105
106
  }
106
107
 
107
- // 单元格渲染:sliceByWidth 截断 (表头单行),padByWidth 补齐
108
+ // Cell rendering: sliceByWidth truncates (single-line for header), padByWidth pads
108
109
  const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
109
110
  const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
110
111
 
111
- // 分隔线
112
+ // separator line
112
113
  const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
113
114
 
114
115
  const out = []
115
- // 表头:单行截断 (表头通常是短标签,折行不如截断直观)
116
+ // Header: single-line truncation (header labels are usually short, truncation beats wrapping)
116
117
  out.push(fmtRow(rows[0]))
117
118
  out.push(separator)
118
119
 
119
- // 数据行:过长单元格按列宽折行,一个逻辑行可能对应多条显示行
120
+ // Data rows: over-long cells wrap by column width; one logical row may produce multiple display lines
120
121
  for (let r = 2; r < rows.length; r++) {
121
- // wrapText 返回按 width 折行后的行数组,保留内部 \n
122
+ // wrapText returns array of lines wrapped by width, preserving internal \n
122
123
  const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
123
124
  const height = Math.max(...wrapped.map((lines) => lines.length))
124
125
  for (let lineIdx = 0; lineIdx < height; lineIdx++) {
@@ -129,7 +130,7 @@ function renderTable(block, width) {
129
130
  return out
130
131
  }
131
132
 
132
- /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, ) 位置 (显示宽度) */
133
+ /** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width) */
133
134
  export function layoutInput(chars, cursor, width) {
134
135
  const PROMPT = "\u25b8 "
135
136
  const lines = []
@@ -147,8 +148,6 @@ export function layoutInput(chars, cursor, width) {
147
148
  }
148
149
  for (let i = 0; i <= chars.length; i++) {
149
150
  const ch = chars[i]
150
- // 先处理 flush:宽字符可能触发换行,cursorCol 必须在 flush 之后取,
151
- // 否则光标会停在上一行的末尾而非下一行的起始
152
151
  if (ch !== undefined && ch !== "\n") {
153
152
  const w = charWidth(ch.codePointAt(0))
154
153
  if (col + w > avail()) flush()
@@ -171,8 +170,10 @@ export function layoutInput(chars, cursor, width) {
171
170
  }
172
171
 
173
172
  /**
174
- * 显示净化:控制字符会破坏终端网格数学 (\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
175
- * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
173
+ * Display sanitization: control characters can break terminal grid math (\r carriage return overwrite,
174
+ * \t width misjudgment causing entire frame misalignment, ANSI/bell screen floods).
175
+ * Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
176
+ * are also cleaned during replay.
176
177
  */
177
178
  const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
178
179
  export function sanitizeDisplay(s) {
@@ -185,7 +186,7 @@ export function sanitizeDisplay(s) {
185
186
  .replace(/\n+$/, "")
186
187
  }
187
188
 
188
- /** 文本按宽度折行 (保留 \n),返回行数组 */
189
+ /** Wrap text by display width (preserving \n), returns array of lines */
189
190
  export function wrapText(text, width) {
190
191
  const lines = []
191
192
  for (const rawLine of text.split("\n")) {