thincoder 0.7.8 → 0.8.1

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 (97) hide show
  1. package/README.md +32 -13
  2. package/bin/thincoder.js +4 -0
  3. package/bin/thincoder.mjs +27 -346
  4. package/package.json +2 -2
  5. package/src/agent/dispatch.mjs +98 -0
  6. package/src/agent/helpers.mjs +185 -0
  7. package/src/agent/setup.mjs +117 -0
  8. package/src/agent-tools/goal.mjs +71 -0
  9. package/src/agent-tools/plan.mjs +31 -0
  10. package/src/agent-tools/recent-changes.mjs +23 -0
  11. package/src/agent-tools/skill.mjs +46 -0
  12. package/src/agent-tools/subagent.mjs +113 -0
  13. package/src/agent-tools/task.mjs +67 -0
  14. package/src/agent-tools/verify.mjs +198 -0
  15. package/src/agent-tools.mjs +12 -0
  16. package/src/agent.mjs +90 -1040
  17. package/src/cli/distill-command.mjs +85 -0
  18. package/src/cli/make-agent.mjs +85 -0
  19. package/src/cli/memory-command.mjs +63 -0
  20. package/src/cli/permission.mjs +41 -0
  21. package/src/cli/setup-wizard.mjs +70 -0
  22. package/src/config.mjs +5 -8
  23. package/src/context.mjs +10 -13
  24. package/src/distill.mjs +4 -3
  25. package/src/embedding.mjs +4 -2
  26. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  27. package/src/mcp/helpers.mjs +37 -0
  28. package/src/mcp/transport-http.mjs +176 -0
  29. package/src/mcp/transport-stdio.mjs +84 -0
  30. package/src/mcp/transport-ws.mjs +87 -0
  31. package/src/mcp.mjs +4 -428
  32. package/src/memory/code-index.mjs +211 -0
  33. package/src/memory/code-sync.mjs +306 -0
  34. package/src/memory/core.mjs +277 -0
  35. package/src/memory/docs.mjs +262 -0
  36. package/src/memory/schema.mjs +426 -0
  37. package/src/memory.mjs +12 -1403
  38. package/src/provider/core.mjs +239 -0
  39. package/src/provider/index.mjs +6 -0
  40. package/src/provider/rate.mjs +104 -0
  41. package/src/session.mjs +18 -5
  42. package/src/tools/bash.mjs +144 -0
  43. package/src/tools/file.mjs +205 -0
  44. package/src/tools/git.mjs +166 -0
  45. package/src/tools/glob.mjs +51 -0
  46. package/src/tools/grep.mjs +100 -0
  47. package/src/tools/index.mjs +22 -0
  48. package/src/tools/ls.mjs +36 -0
  49. package/src/tools/patch.mjs +226 -0
  50. package/src/tools/repomap-parse.mjs +168 -0
  51. package/src/tools/shared.mjs +257 -0
  52. package/src/tools/system.mjs +336 -0
  53. package/src/tools/web.mjs +121 -0
  54. package/src/tools.mjs +2 -1194
  55. package/src/tui/agent-turn.mjs +254 -0
  56. package/src/tui/ansi.mjs +32 -0
  57. package/src/tui/clipboard.mjs +48 -0
  58. package/src/tui/cmd-auto.mjs +21 -0
  59. package/src/tui/cmd-clear.mjs +26 -0
  60. package/src/tui/cmd-config.mjs +72 -0
  61. package/src/tui/cmd-exit.mjs +5 -0
  62. package/src/tui/cmd-extract.mjs +5 -0
  63. package/src/tui/cmd-goal.mjs +47 -0
  64. package/src/tui/cmd-help.mjs +25 -0
  65. package/src/tui/cmd-init.mjs +91 -0
  66. package/src/tui/cmd-mcp.mjs +146 -0
  67. package/src/tui/cmd-model.mjs +7 -0
  68. package/src/tui/cmd-new.mjs +18 -0
  69. package/src/tui/cmd-plan.mjs +21 -0
  70. package/src/tui/cmd-reindex.mjs +44 -0
  71. package/src/tui/cmd-restore.mjs +39 -0
  72. package/src/tui/cmd-session.mjs +42 -0
  73. package/src/tui/cmd-skills.mjs +17 -0
  74. package/src/tui/cmd-think.mjs +56 -0
  75. package/src/tui/config-helpers.mjs +34 -0
  76. package/src/tui/distill-cmd.mjs +45 -0
  77. package/src/tui/index.mjs +330 -0
  78. package/src/tui/interaction.mjs +79 -0
  79. package/src/tui/key-handler.mjs +267 -0
  80. package/src/tui/layout.mjs +115 -0
  81. package/src/tui/pickers.mjs +279 -0
  82. package/src/tui/render-frame.mjs +304 -0
  83. package/src/tui/render.mjs +205 -0
  84. package/src/tui/slash-commands.mjs +138 -0
  85. package/src/tui/startup.mjs +113 -0
  86. package/src/tui/wizard.mjs +168 -0
  87. package/src/tui-render.mjs +4 -0
  88. package/src/tui.mjs +3 -2566
  89. package/src/provider.mjs +0 -383
  90. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  91. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  92. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  93. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  94. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  95. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  96. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  97. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * render-frame.mjs — 终端帧渲染(纯计算,无副作用)
3
+ * 从 state + agent + layout 产生 ANSI 帧字符串,返回光标位置。
4
+ *
5
+ * 布局计算见 layout.mjs(computeLayout 纯函数)。
6
+ * 副作用(scroll 归位、ctxCache 更新)由调用方在渲染前执行。
7
+ */
8
+ import { ansi, C, ESC } from "./ansi.mjs"
9
+ import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
10
+ import {
11
+ sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay,
12
+ } from "./render.mjs"
13
+ import { specForModel } from "../config.mjs"
14
+ import { basename } from "node:path"
15
+
16
+ // ---------- status bar slash-command hints (查表替代 if-else 链) ----------
17
+ const SLASH_HINTS = {
18
+ "/config": "open config menu",
19
+ "/model": "select model & manage providers",
20
+ "/think": "open thinking mode menu",
21
+ "/mcp": "open MCP management menu",
22
+ "/goal": "open goal management menu",
23
+ "/session": "select archived session",
24
+ "/restore": "select checkpoint to restore",
25
+ }
26
+
27
+ /**
28
+ * 渲染一帧,返回 { frame, cursorRow, cursorCol }。
29
+ * 纯函数:不修改 state/agent。
30
+ */
31
+ export function renderFrame(state, agent, opts) {
32
+ const cols = opts.cols || 80
33
+ const rows = opts.rows || 24
34
+ const slashCommands = opts.slashCommands ?? []
35
+ const platform = opts.platform ?? process.platform
36
+
37
+ const layout = computeLayout(state, { cols, rows })
38
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
39
+ const model = agent.provider.model
40
+ const thinking = agent.provider.thinking
41
+ const effort = agent.provider.reasoningEffort
42
+ const isMultimodal = specForModel(model).multimodal
43
+ const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
44
+ : effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
45
+
46
+ const out = [ansi.home]
47
+ let cursorRow = 0, cursorCol = 0
48
+
49
+ // ---- header ----
50
+ out.push(
51
+ `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}${ansi.clearLine}`,
52
+ )
53
+
54
+ // ---- conversation ----
55
+ const convLines = buildConvLines(state, cols)
56
+ const maxScroll = Math.max(0, convLines.length - panels.conversation.h)
57
+ const scroll = Math.min(state.scroll, maxScroll)
58
+ const end = convLines.length - scroll
59
+ const visible = convLines.slice(Math.max(0, end - panels.conversation.h), end)
60
+ const pad = panels.conversation.h - visible.length
61
+ for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
62
+ for (const l of visible) {
63
+ out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
64
+ }
65
+
66
+ // ---- picker / wizard overlay ----
67
+ if (panels.picker) {
68
+ const winH = panels.picker.h - 1
69
+ const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
70
+ const shown = overlay.lines.slice(start, start + winH)
71
+ const overlayTitle = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ Setup "
72
+ out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ navigate, Enter confirm, Esc cancel)" : ""}${ansi.reset}${ansi.clearLine}`)
73
+ for (const l of shown) {
74
+ out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
75
+ }
76
+ for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
77
+ }
78
+
79
+ // ---- todo panel ----
80
+ for (const t of visibleTasks) {
81
+ const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
82
+ const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
83
+ out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
84
+ }
85
+
86
+ // ---- subagent panel ----
87
+ if (panels.subagent) {
88
+ const subs = allSubs
89
+ for (const s of subs.slice(0, MAX_SUB_LINES)) {
90
+ const icon = s.done ? "✓" : "…"
91
+ const color = s.done ? C.dim : C.tool
92
+ const label = `[${s.role}]`.padEnd(10)
93
+ let content
94
+ if (s.done) {
95
+ const elapsed = Math.floor((Date.now() - s.started) / 1000)
96
+ content = `done ${elapsed}s`
97
+ } else if (s.tool) {
98
+ const argSummary = s.toolArgs ? summarizeToolArg(s.tool, s.toolArgs) : ""
99
+ content = `${s.tool}${argSummary ? ` ${argSummary}` : ""}`
100
+ } else if (s.text) {
101
+ const textLines = s.text.split("\n").filter((l) => l.trim())
102
+ content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
103
+ } else {
104
+ content = "thinking..."
105
+ }
106
+ const availWidth = W - 14
107
+ out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, availWidth))}${ansi.reset}${ansi.clearLine}`)
108
+ }
109
+ if (subs.length > MAX_SUB_LINES) {
110
+ out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}${ansi.clearLine}`)
111
+ }
112
+ }
113
+
114
+ // ---- permission preview ----
115
+ if (panels.permission) {
116
+ out.push(`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}${ansi.clearLine}`)
117
+ for (const wrapped of permPreviewLines) {
118
+ out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
119
+ }
120
+ }
121
+
122
+ // ---- queue preview ----
123
+ if (panels.queue) {
124
+ 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}`)
126
+ }
127
+
128
+ // ---- input box ----
129
+ const { borderColor, title } = inputBoxStyle(state)
130
+ let topBorder
131
+ if (title === " Input " && isMultimodal) {
132
+ const hint = platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
133
+ topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
134
+ } else {
135
+ topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
136
+ }
137
+ out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
138
+ for (const l of boxLines) {
139
+ const content = sliceByWidth(l, W - 4)
140
+ const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
141
+ out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
142
+ }
143
+ out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}${ansi.clearLine}`)
144
+
145
+ // ---- status bar ----
146
+ const statusLine = buildStatusLine(state, agent, { cols, slashCommands })
147
+ const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
148
+ const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
149
+ const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
150
+ const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
151
+ out.push(`${ansi.dim}${planBanner}${autoBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}${ansi.clearLine}`)
152
+
153
+ const frame = out.join("\r\n")
154
+
155
+ // ---- cursor ----
156
+ if (!state.permission && !state.question && !state.picker && state.wizard?.step !== "provider") {
157
+ cursorRow = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
158
+ cursorCol = 3 + inputLayout.cursorCol
159
+ }
160
+
161
+ return { frame, cursorRow, cursorCol }
162
+ }
163
+
164
+ // ---------------------------------------------------------- 内部实现
165
+
166
+ /** Count conversation lines after sanitize + wrap (for scroll clamping). Pure. */
167
+ export function countConvLines(state, cols) {
168
+ return buildConvLines(state, cols).length
169
+ }
170
+
171
+ /** Build conversation lines from state (sanitized + wrapped). Pure. */
172
+ function buildConvLines(state, cols) {
173
+ const convLines = []
174
+ for (const l of state.lines) {
175
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
176
+ for (const wrapped of wrapText(line, cols - 1)) {
177
+ convLines.push({ text: wrapped, color: l.color })
178
+ }
179
+ }
180
+ }
181
+ if (state.reasoning) {
182
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
183
+ convLines.push({ text: wrapped, color: C.reason })
184
+ }
185
+ }
186
+ if (state.streaming) {
187
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
188
+ for (const wrapped of wrapText(line, cols - 1)) {
189
+ convLines.push({ text: wrapped, color: C.text })
190
+ }
191
+ }
192
+ }
193
+ const allStreams = Object.values(state.toolStreams).join("")
194
+ if (allStreams) {
195
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
196
+ for (const wrapped of wrapText(tail, cols - 1)) {
197
+ convLines.push({ text: wrapped, color: C.dim })
198
+ }
199
+ }
200
+ return convLines
201
+ }
202
+
203
+ /** Determine input box border color and title. Pure. */
204
+ function inputBoxStyle(state) {
205
+ let borderColor = C.tool
206
+ let title
207
+ if (state.question) {
208
+ borderColor = C.tool
209
+ title = " Question "
210
+ } else if (state.permission) {
211
+ borderColor = C.warn
212
+ if (state.permission.name === "continue") {
213
+ title = " Continue? (y/n) "
214
+ } else {
215
+ title = ` Allow ${state.permission.name}? (y/n/a) `
216
+ }
217
+ } else if (state.picker) {
218
+ title = " Select "
219
+ } else if (state.wizard) {
220
+ title = " Setup "
221
+ } else if (state.processing) {
222
+ title = " Processing... "
223
+ } else {
224
+ title = " Input "
225
+ }
226
+ return { borderColor, title }
227
+ }
228
+
229
+ /** Build status bar line. Pure. */
230
+ function buildStatusLine(state, agent, { cols, slashCommands }) {
231
+ const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
232
+ const rawInput = state.input.join("")
233
+
234
+ if (state.question) {
235
+ const q = state.question
236
+ return q.options.length > 0
237
+ ? " ↑↓: select │ Enter: confirm │ Esc: cancel"
238
+ : " Type answer then Enter │ Esc: cancel"
239
+ }
240
+ if (state.permission) {
241
+ return state.permission.name === "continue"
242
+ ? " y: continue │ n: stop"
243
+ : " y: approve │ n: deny │ a: approve all (AUTO)"
244
+ }
245
+ if (state.picker) {
246
+ return " ↑↓: select │ Enter: confirm │ Esc: cancel"
247
+ }
248
+ if (state.wizard) {
249
+ return state.wizard.step === "provider"
250
+ ? " ↑↓: select │ Enter: confirm │ Esc: skip"
251
+ : " Type then Enter │ Esc: cancel"
252
+ }
253
+ if (rawInput.startsWith("/") && !state.processing && !state.permission) {
254
+ const [cmd] = rawInput.split(/\s+/)
255
+ const cmds = slashCommands.filter((c) => c.name.startsWith(cmd))
256
+ const match = cmds.length === 1 ? cmds[0] : null
257
+ if (match && SLASH_HINTS[match.name]) {
258
+ return ` ${match.name} ${SLASH_HINTS[match.name]}`
259
+ }
260
+ if (cmds.length > 0) {
261
+ if (cmds.length <= 4) {
262
+ return ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
263
+ }
264
+ return ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
265
+ }
266
+ return ` unknown command (/help for available commands)`
267
+ }
268
+
269
+ const taskHint = state.tasks.length > 0
270
+ ? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
271
+ : ""
272
+ const tk = state.tokens
273
+ const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
274
+ const cacheTotal = tk.cacheHit + tk.cacheMiss
275
+ const tokenHint = tk.prompt > 0
276
+ ? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}`
277
+ : ""
278
+ const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
279
+ const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
280
+ const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
281
+ const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
282
+ const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
283
+ const ctxHint = ctxPct > 0
284
+ ? ctxPct >= 80
285
+ ? ` │ ${ansi.reset}${C.warn}ctx ${ctxPct}%${ansi.reset}${ansi.dim}`
286
+ : ` │ ctx ${ctxPct}%`
287
+ : ""
288
+ const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
289
+ return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
290
+ }
291
+
292
+ /** Summarize tool args for subagent panel display (one line, short). Pure. */
293
+ function summarizeToolArg(toolName, args) {
294
+ if (!args || typeof args !== "object") return ""
295
+ if (toolName === "bash" && args.command) {
296
+ const cmd = args.command.split("\n")[0]
297
+ return `"${sliceByWidth(cmd, 50)}"`
298
+ }
299
+ if (args.path) return sliceByWidth(args.path, 60)
300
+ if (args.pattern) return `"${sliceByWidth(args.pattern, 50)}"`
301
+ if (args.query) return `"${sliceByWidth(args.query, 50)}"`
302
+ if (args.task) return `"${sliceByWidth(args.task, 50)}"`
303
+ return ""
304
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * tui-render.mjs — 终端显示工具(纯函数,零依赖)
3
+ * 字符宽度计算、CJK/emoji 排版、文本折行、markdown 表格重排。
4
+ */
5
+
6
+ /** 字符显示宽度:CJK/emoji 计 2,组合字符计 0,其余计 1 */
7
+ export function charWidth(cp) {
8
+ if (
9
+ (cp >= 0x300 && cp <= 0x36f) || // 组合变音符
10
+ (cp >= 0x200b && cp <= 0x200f) || // 零宽
11
+ cp === 0xfe0f // emoji 变体选择符
12
+ ) {
13
+ return 0
14
+ }
15
+ if (
16
+ (cp >= 0x1100 && cp <= 0x115f) ||
17
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
18
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
19
+ (cp >= 0xf900 && cp <= 0xfaff) ||
20
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
21
+ (cp >= 0xff00 && cp <= 0xff60) ||
22
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
23
+ (cp >= 0x1f000 && cp <= 0x1faff) ||
24
+ (cp >= 0x20000 && cp <= 0x3fffd) ||
25
+ (cp >= 0x2600 && cp <= 0x27bf)
26
+ ) {
27
+ return 2
28
+ }
29
+ return 1
30
+ }
31
+
32
+ export function stringWidth(text) {
33
+ let w = 0
34
+ for (const ch of text) w += charWidth(ch.codePointAt(0))
35
+ return w
36
+ }
37
+
38
+ /** 按显示宽度裁剪 */
39
+ export function sliceByWidth(text, maxWidth) {
40
+ let w = 0
41
+ let out = ""
42
+ for (const ch of text) {
43
+ const cw = charWidth(ch.codePointAt(0))
44
+ if (w + cw > maxWidth) break
45
+ w += cw
46
+ out += ch
47
+ }
48
+ return out
49
+ }
50
+
51
+ /** 按显示宽度右补空格 */
52
+ function padByWidth(text, width) {
53
+ return text + " ".repeat(Math.max(0, width - stringWidth(text)))
54
+ }
55
+
56
+ // ---------------------------------------------------------------- markdown 表格重排
57
+
58
+ const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
59
+ const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
60
+
61
+ /**
62
+ * 识别文本中的 markdown 表格块,按显示宽度重排 (修 CJK 错位)。
63
+ * width 为可用显示宽度;过宽的表格按列收缩。非表格行原样保留。
64
+ */
65
+ export function formatTables(text, width) {
66
+ const lines = text.split("\n")
67
+ const out = []
68
+ let i = 0
69
+ while (i < lines.length) {
70
+ if (isTableRow(lines[i]) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
71
+ const block = [lines[i], lines[i + 1]]
72
+ i += 2
73
+ while (i < lines.length && isTableRow(lines[i])) {
74
+ block.push(lines[i])
75
+ i++
76
+ }
77
+ out.push(...renderTable(block, width))
78
+ } else {
79
+ out.push(lines[i])
80
+ i++
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ function renderTable(block, width) {
87
+ const rows = block.map((line) =>
88
+ line
89
+ .replace(/^\s*\|/, "")
90
+ .replace(/\|\s*$/, "")
91
+ .split("|")
92
+ .map((c) => c.trim()),
93
+ )
94
+ const colCount = Math.max(...rows.map((r) => r.length))
95
+ for (const r of rows) while (r.length < colCount) r.push("")
96
+
97
+ // 列宽:先按内容,超宽则从最宽列开始收缩 (收缩到至少 3)
98
+ const widths = Array.from({ length: colCount }, (_, c) =>
99
+ Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
100
+ )
101
+ const borders = colCount * 3 + 1 // " │ " 分隔 + 首尾 |
102
+ while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
103
+ const widest = widths.indexOf(Math.max(...widths))
104
+ widths[widest]--
105
+ }
106
+
107
+ // 单元格渲染:sliceByWidth 截断 (表头单行),padByWidth 补齐
108
+ const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
109
+ const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
110
+
111
+ // 分隔线
112
+ const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
113
+
114
+ const out = []
115
+ // 表头:单行截断 (表头通常是短标签,折行不如截断直观)
116
+ out.push(fmtRow(rows[0]))
117
+ out.push(separator)
118
+
119
+ // 数据行:过长单元格按列宽折行,一个逻辑行可能对应多条显示行
120
+ for (let r = 2; r < rows.length; r++) {
121
+ // wrapText 返回按 width 折行后的行数组,保留内部 \n
122
+ const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
123
+ const height = Math.max(...wrapped.map((lines) => lines.length))
124
+ for (let lineIdx = 0; lineIdx < height; lineIdx++) {
125
+ out.push(fmtRow(wrapped.map((lines) => lines[lineIdx] ?? "")))
126
+ }
127
+ }
128
+
129
+ return out
130
+ }
131
+
132
+ /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置 (显示宽度) */
133
+ export function layoutInput(chars, cursor, width) {
134
+ const PROMPT = "\u25b8 "
135
+ const lines = []
136
+ let cursorLine = 0
137
+ let cursorCol = 0
138
+ let cur = ""
139
+ let col = 0
140
+ let firstLine = true
141
+ const avail = () => (firstLine ? width - 2 : width)
142
+ const flush = () => {
143
+ lines.push((firstLine ? PROMPT : "") + cur)
144
+ firstLine = false
145
+ cur = ""
146
+ col = 0
147
+ }
148
+ for (let i = 0; i <= chars.length; i++) {
149
+ const ch = chars[i]
150
+ // 先处理 flush:宽字符可能触发换行,cursorCol 必须在 flush 之后取,
151
+ // 否则光标会停在上一行的末尾而非下一行的起始
152
+ if (ch !== undefined && ch !== "\n") {
153
+ const w = charWidth(ch.codePointAt(0))
154
+ if (col + w > avail()) flush()
155
+ if (i === cursor) {
156
+ cursorLine = lines.length
157
+ cursorCol = (firstLine ? 2 : 0) + col
158
+ }
159
+ cur += ch
160
+ col += w
161
+ } else {
162
+ if (i === cursor) {
163
+ cursorLine = lines.length
164
+ cursorCol = (firstLine ? 2 : 0) + col
165
+ }
166
+ if (ch === "\n") flush()
167
+ }
168
+ }
169
+ if (cur || lines.length === 0) flush()
170
+ return { lines, cursorLine, cursorCol }
171
+ }
172
+
173
+ /**
174
+ * 显示净化:控制字符会破坏终端网格数学 (\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
175
+ * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
176
+ */
177
+ const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
178
+ export function sanitizeDisplay(s) {
179
+ return s
180
+ .replace(ANSI_SEQUENCE_RE, "")
181
+ .replace(/\r\n/g, "\n")
182
+ .replace(/\r/g, "\n")
183
+ .replace(/\t/g, " ")
184
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
185
+ .replace(/\n+$/, "")
186
+ }
187
+
188
+ /** 文本按宽度折行 (保留 \n),返回行数组 */
189
+ export function wrapText(text, width) {
190
+ const lines = []
191
+ for (const rawLine of text.split("\n")) {
192
+ if (rawLine === "") {
193
+ lines.push("")
194
+ continue
195
+ }
196
+ let line = rawLine
197
+ while (stringWidth(line) > width) {
198
+ const head = sliceByWidth(line, width)
199
+ lines.push(head)
200
+ line = line.slice([...head].length)
201
+ }
202
+ lines.push(line)
203
+ }
204
+ return lines
205
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * slash-commands.mjs — 斜杠命令定义、分发、Tab 补全。
3
+ * 每个子命令的具体实现都在独立的 cmd-*.mjs 中,本文件只做 dispatch。
4
+ *
5
+ * ctx 对象由 index.mjs 注入,透传给各 handler:
6
+ * { agent, state, distillOpts, pushLine, pushLabel, render,
7
+ * openPicker, openModelPicker, setProviderKey, runDistill,
8
+ * persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
9
+ */
10
+
11
+ import { C } from "./ansi.mjs"
12
+ import { handleClearCommand } from "./cmd-clear.mjs"
13
+ import { handleNewCommand } from "./cmd-new.mjs"
14
+ import { handleExitCommand } from "./cmd-exit.mjs"
15
+ import { handleSessionCommand } from "./cmd-session.mjs"
16
+ import { handleReindexCommand } from "./cmd-reindex.mjs"
17
+ import { handleInitCommand } from "./cmd-init.mjs"
18
+ import { handleRestoreCommand } from "./cmd-restore.mjs"
19
+ import { handlePlanCommand } from "./cmd-plan.mjs"
20
+ import { handleGoalCommand } from "./cmd-goal.mjs"
21
+ import { handleSkillsCommand } from "./cmd-skills.mjs"
22
+ import { handleMcpCommand } from "./cmd-mcp.mjs"
23
+ import { handleAutoCommand } from "./cmd-auto.mjs"
24
+ import { handleThinkCommand } from "./cmd-think.mjs"
25
+ import { handleModelCommand } from "./cmd-model.mjs"
26
+ import { handleConfigCommand } from "./cmd-config.mjs"
27
+ import { handleExtractCommand } from "./cmd-extract.mjs"
28
+ import { handleHelpCommand } from "./cmd-help.mjs"
29
+
30
+ export const SLASH_COMMANDS = [
31
+ { name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
32
+ { name: "/auto", group: "Agent", desc: "toggle auto-approve" },
33
+ { name: "/model", group: "Agent", desc: "select model & manage providers" },
34
+ { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
35
+ { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
36
+ { name: "/init", group: "Tools", desc: "generate project AGENTS.md skeleton" },
37
+ { name: "/skills", group: "Tools", desc: "list project skills" },
38
+ { name: "/mcp", group: "Tools", desc: "manage MCP servers" },
39
+ { name: "/config", group: "Config", desc: "config management (embedding / agent)" },
40
+ { name: "/reindex", group: "Config", desc: "rebuild memory index" },
41
+ { name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
42
+ { name: "/session", group: "Session", desc: "list/switch archived sessions" },
43
+ { name: "/clear", group: "Session", desc: "clear screen" },
44
+ { name: "/extract", group: "Session", desc: "extract knowledge from session" },
45
+ { name: "/restore", group: "Session", desc: "restore checkpoint" },
46
+ { name: "/exit", group: "Session", desc: "exit" },
47
+ { name: "/help", group: "", desc: "this list" },
48
+ ]
49
+
50
+ /** 命令 → handler 映射表 */
51
+ const HANDLERS = {
52
+ "/clear": handleClearCommand,
53
+ "/new": handleNewCommand,
54
+ "/exit": handleExitCommand,
55
+ "/session": handleSessionCommand,
56
+ "/reindex": handleReindexCommand,
57
+ "/init": handleInitCommand,
58
+ "/restore": handleRestoreCommand,
59
+ "/plan": handlePlanCommand,
60
+ "/goal": handleGoalCommand,
61
+ "/skills": handleSkillsCommand,
62
+ "/mcp": handleMcpCommand,
63
+ "/auto": handleAutoCommand,
64
+ "/think": handleThinkCommand,
65
+ "/model": handleModelCommand,
66
+ "/config": handleConfigCommand,
67
+ "/extract": handleExtractCommand,
68
+ "/help": handleHelpCommand,
69
+ }
70
+
71
+ /**
72
+ * 创建斜杠命令处理器。
73
+ * 返回 { handleSlash, completions, handleTab }。
74
+ */
75
+ export function createSlashCommands(ctx) {
76
+ const { agent, state, render } = ctx
77
+ // 给 /help 传 SLASH_COMMANDS
78
+ const handlerCtx = { ...ctx, SLASH_COMMANDS }
79
+
80
+ async function handleSlash(text) {
81
+ const [cmd] = text.split(/\s+/)
82
+ // 高频命令缩写
83
+ const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
84
+ const resolved = aliases[cmd] ?? cmd
85
+ const handler = HANDLERS[resolved]
86
+ if (handler) {
87
+ await handler(handlerCtx)
88
+ return
89
+ }
90
+ ctx.pushLine(`Unknown command: ${cmd} (/help for available commands)`, C.error)
91
+ }
92
+
93
+ /** Tab 补全候选:Commands 名 / 子 Commands / provider 名 / 预设名 / think 参数 */
94
+ function completions(input) {
95
+ if (!input.startsWith("/")) return []
96
+ const parts = input.split(/\s+/)
97
+ // 还在敲第一个 token:补 Commands 名
98
+ if (parts.length === 1) {
99
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
100
+ }
101
+ const cmd = parts[0]
102
+ const last = parts.at(-1) // 结尾是空格时,列出全部候选
103
+ const head = parts.slice(0, -1).join(" ")
104
+ const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
105
+ const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
106
+ if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
107
+ if (cmd === "/think") {
108
+ if (argIndex === 0) return match(["on", "off", "effort"])
109
+ if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
110
+ }
111
+ if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
112
+ if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
113
+ if (cmd === "/mcp") {
114
+ if (argIndex === 0) return match(["add", "url", "ws", "remove", "connect", "list"])
115
+ if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
116
+ }
117
+ return []
118
+ }
119
+
120
+ /** Tab:计算候选并循环替换输入 */
121
+ function handleTab() {
122
+ const input = state.input.join("")
123
+ if (state.completion && input === state.completion.candidates[state.completion.index]) {
124
+ // 上一次的候选还在输入框:循环到下一个
125
+ state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
126
+ } else {
127
+ const candidates = completions(input)
128
+ if (candidates.length === 0) return
129
+ state.completion = { candidates, index: 0 }
130
+ }
131
+ const text = state.completion.candidates[state.completion.index]
132
+ state.input = [...text]
133
+ state.cursor = state.input.length
134
+ render()
135
+ }
136
+
137
+ return { handleSlash, completions, handleTab }
138
+ }