thincoder 0.8.1 → 0.8.3

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.cjs +4 -0
  3. package/bin/thincoder.mjs +25 -25
  4. package/package.json +2 -2
  5. package/src/agent/dispatch.mjs +17 -12
  6. package/src/agent/helpers.mjs +22 -11
  7. package/src/agent/setup.mjs +32 -10
  8. package/src/agent-tools/goal.mjs +7 -6
  9. package/src/agent-tools/plan.mjs +3 -3
  10. package/src/agent-tools/recent-changes.mjs +3 -3
  11. package/src/agent-tools/skill.mjs +6 -6
  12. package/src/agent-tools/subagent.mjs +19 -18
  13. package/src/agent-tools/task.mjs +4 -4
  14. package/src/agent-tools/verify.mjs +21 -19
  15. package/src/agent-tools.mjs +3 -3
  16. package/src/agent.mjs +58 -37
  17. package/src/cli/distill-command.mjs +2 -2
  18. package/src/cli/make-agent.mjs +9 -9
  19. package/src/cli/memory-command.mjs +1 -1
  20. package/src/cli/permission.mjs +3 -3
  21. package/src/cli/setup-wizard.mjs +15 -15
  22. package/src/config.mjs +55 -53
  23. package/src/context.mjs +59 -56
  24. package/src/distill.mjs +35 -35
  25. package/src/embedding.mjs +17 -17
  26. package/src/git/checkpoint.mjs +211 -38
  27. package/src/git/gitmem.mjs +21 -20
  28. package/src/markdown.mjs +13 -13
  29. package/src/mcp/helpers.mjs +6 -1
  30. package/src/mcp/transport-http.mjs +2 -1
  31. package/src/mcp/transport-stdio.mjs +3 -2
  32. package/src/mcp/transport-ws.mjs +3 -2
  33. package/src/mcp.mjs +5 -2
  34. package/src/memory/code-index.mjs +16 -14
  35. package/src/memory/code-sync.mjs +36 -26
  36. package/src/memory/core.mjs +42 -35
  37. package/src/memory/docs.mjs +24 -15
  38. package/src/memory/schema.mjs +28 -27
  39. package/src/memory.mjs +2 -2
  40. package/src/prompts/coder.md +1 -1
  41. package/src/prompts/discipline.md +3 -0
  42. package/src/prompts/main.md +1 -2
  43. package/src/prompts/system.md +21 -16
  44. package/src/provider/core.mjs +11 -6
  45. package/src/provider/index.mjs +2 -2
  46. package/src/provider/rate.mjs +11 -11
  47. package/src/session.mjs +73 -40
  48. package/src/skills.mjs +17 -17
  49. package/src/tools/checkpoint.md +6 -2
  50. package/src/tools/file.mjs +16 -12
  51. package/src/tools/git.mjs +83 -12
  52. package/src/tools/index.mjs +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 +49 -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 +4 -2
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +1 -1
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +2 -2
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +1 -1
  67. package/src/tui/cmd-goal.mjs +4 -4
  68. package/src/tui/cmd-help.mjs +1 -1
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +5 -5
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +1 -1
  73. package/src/tui/cmd-plan.mjs +1 -1
  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 +2 -2
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +3 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +31 -23
  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 +14 -14
  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,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")) {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * slash-commands.mjs — 斜杠命令定义、分发、Tab 补全。
3
- * 每个子命令的具体实现都在独立的 cmd-*.mjs 中,本文件只做 dispatch
2
+ * slash-commands.mjs — slash command definitions, dispatch, Tab completion.
3
+ * Each subcommand's implementation lives in its own cmd-*.mjs file; this file only does dispatch.
4
4
  *
5
- * ctx 对象由 index.mjs 注入,透传给各 handler
5
+ * ctx object is injected by index.mjs and forwarded to each handler:
6
6
  * { agent, state, distillOpts, pushLine, pushLabel, render,
7
7
  * openPicker, openModelPicker, setProviderKey, runDistill,
8
8
  * persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
@@ -47,7 +47,7 @@ export const SLASH_COMMANDS = [
47
47
  { name: "/help", group: "", desc: "this list" },
48
48
  ]
49
49
 
50
- /** 命令 → handler 映射表 */
50
+ /** Command → handler mapping table */
51
51
  const HANDLERS = {
52
52
  "/clear": handleClearCommand,
53
53
  "/new": handleNewCommand,
@@ -69,17 +69,17 @@ const HANDLERS = {
69
69
  }
70
70
 
71
71
  /**
72
- * 创建斜杠命令处理器。
73
- * 返回 { handleSlash, completions, handleTab }
72
+ * Creates the slash command processor.
73
+ * Returns { handleSlash, completions, handleTab }.
74
74
  */
75
75
  export function createSlashCommands(ctx) {
76
76
  const { agent, state, render } = ctx
77
- // /help 传 SLASH_COMMANDS
77
+ // forward SLASH_COMMANDS to /help
78
78
  const handlerCtx = { ...ctx, SLASH_COMMANDS }
79
79
 
80
80
  async function handleSlash(text) {
81
81
  const [cmd] = text.split(/\s+/)
82
- // 高频命令缩写
82
+ // high-frequency command aliases
83
83
  const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
84
84
  const resolved = aliases[cmd] ?? cmd
85
85
  const handler = HANDLERS[resolved]
@@ -90,18 +90,18 @@ export function createSlashCommands(ctx) {
90
90
  ctx.pushLine(`Unknown command: ${cmd} (/help for available commands)`, C.error)
91
91
  }
92
92
 
93
- /** Tab 补全候选:Commands / Commands / provider / 预设名 / think 参数 */
93
+ /** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
94
94
  function completions(input) {
95
95
  if (!input.startsWith("/")) return []
96
96
  const parts = input.split(/\s+/)
97
- // 还在敲第一个 token:补 Commands
97
+ // still typing the first token: complete command names
98
98
  if (parts.length === 1) {
99
99
  return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
100
100
  }
101
101
  const cmd = parts[0]
102
- const last = parts.at(-1) // 结尾是空格时,列出全部候选
102
+ const last = parts.at(-1) // when trailing space, list all candidates
103
103
  const head = parts.slice(0, -1).join(" ")
104
- const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
104
+ const argIndex = parts.length - 2 // which parameter is being typed (0-based)
105
105
  const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
106
106
  if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
107
107
  if (cmd === "/think") {
@@ -117,11 +117,11 @@ export function createSlashCommands(ctx) {
117
117
  return []
118
118
  }
119
119
 
120
- /** Tab:计算候选并循环替换输入 */
120
+ /** Tab: compute candidates and cycle through replacement */
121
121
  function handleTab() {
122
122
  const input = state.input.join("")
123
123
  if (state.completion && input === state.completion.candidates[state.completion.index]) {
124
- // 上一次的候选还在输入框:循环到下一个
124
+ // previous candidate still in input box: cycle to next
125
125
  state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
126
126
  } else {
127
127
  const candidates = completions(input)
@@ -2,13 +2,13 @@ import { listSlots } from "../session.mjs"
2
2
  import { sliceByWidth } from "./render.mjs"
3
3
  import { ansi, C } from "./ansi.mjs"
4
4
 
5
- /** 启动画面 + 会话恢复 + 后台索引。
6
- * index.mjs 抽出。
5
+ /** Startup screen + session recovery + background indexing.
6
+ * Extracted from index.mjs.
7
7
  * ctx: { agent, state, opts, pushLine, pushLabel, render, startWizard } */
8
8
  export function showStartup(ctx) {
9
9
  const { agent, state, opts, pushLine, pushLabel, render, startWizard } = ctx
10
10
 
11
- // 启动画面
11
+ // Startup screen
12
12
  if (!agent.provider.apiKey) {
13
13
  pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
14
14
  pushLine("No API key configured yet — entering initial setup (Esc to skip anytime)", C.text)
@@ -18,13 +18,14 @@ export function showStartup(ctx) {
18
18
  }
19
19
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
20
20
 
21
- // 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
21
+ // Recover previous session: rebuild conversation display (tool result lines omitted, keep it clean)
22
22
  if (opts.restored?.display?.length) {
23
- // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
23
+ // User-facing recovery: display is a WYSIWYG snapshot of the conversation before exit
24
24
  state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
25
- pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
25
+ const recoveryNote = opts.restored._recovered ? " (recovered from backup)" : ""
26
+ pushLabel(`── Restored previous session${recoveryNote}; /new for a fresh session ──`, C.warn)
26
27
  } else if (opts.restored?.history?.length) {
27
- // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
28
+ // Rebuild conversation: user/assistant messages shown one by one, tool result lines show only first-line summary
28
29
  for (let i = 0; i < opts.restored.history.length; i++) {
29
30
  const m = opts.restored.history[i]
30
31
  if (m.role === "user") {
@@ -35,27 +36,27 @@ export function showStartup(ctx) {
35
36
  pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
36
37
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
37
38
  for (const tc of m.tool_calls ?? []) {
38
- // 找到下一条对应的 tool 结果,显示首行摘要
39
+ // Find the next corresponding tool result, show first-line summary
39
40
  const toolResult = opts.restored.history[i + 1]
40
41
  const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
41
42
  const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
42
43
  pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
43
44
  }
44
45
  }
45
- // tool 消息本身不单独渲染——已在 assistant tool_calls 后以摘要形式展示
46
+ // Tool messages aren't rendered separately — already shown as summary after assistant's tool_calls
46
47
  }
47
48
  pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
48
49
  }
49
50
 
50
- // 有归档槽位时给个提示
51
+ // Hint when archived slots exist
51
52
  if (listSlots(agent.cwd).length > 0) {
52
53
  pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
53
54
  }
54
55
  render()
55
56
  }
56
57
 
57
- /** 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行。
58
- * 优先用 git diff 增量(快),git 不可用或首次运行时退到全量扫描。 */
58
+ /** Background indexing (runs after startup screen, non-blocking); progress shown in status bar, not conversation.
59
+ * Prefers git diff incremental (fast); falls back to full scan when git is unavailable or on first run. */
59
60
  export async function backgroundIndex(ctx) {
60
61
  const { agent, state, render } = ctx
61
62
  const { codeSync, docSync, gitSync } = await import("../memory.mjs")
@@ -75,11 +76,11 @@ export async function backgroundIndex(ctx) {
75
76
  })
76
77
 
77
78
  if (gitRes !== null) {
78
- // git 增量成功,直接统计
79
+ // Git incremental succeeded, count directly
79
80
  codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
80
81
  docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
81
82
  } else {
82
- // 退到全量扫描(codeSync docSync 并行——读写不同表,SQLite WAL 天然支持)
83
+ // Fall back to full scan (codeSync and docSync in parallel — read/write different tables, SQLite WAL supports this natively)
83
84
  const [codeRes, docRes] = await Promise.allSettled([
84
85
  codeSync(agent.memory, cwd, {
85
86
  onProgress: (p) => {