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.
- package/README.md +32 -13
- package/bin/thincoder.js +4 -0
- package/bin/thincoder.mjs +27 -346
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /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
|
+
}
|