thincoder 0.1.0
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/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/thincoder.mjs +383 -0
- package/package.json +29 -0
- package/src/agent.mjs +351 -0
- package/src/checkpoint.mjs +135 -0
- package/src/config.mjs +106 -0
- package/src/context.mjs +76 -0
- package/src/distill.mjs +117 -0
- package/src/embedding.mjs +107 -0
- package/src/gitmem.mjs +87 -0
- package/src/markdown.mjs +99 -0
- package/src/memory.mjs +495 -0
- package/src/provider.mjs +153 -0
- package/src/session.mjs +53 -0
- package/src/tools.mjs +513 -0
- package/src/tui.mjs +912 -0
package/src/tui.mjs
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.mjs — 裸 ANSI 终端 UI
|
|
3
|
+
* 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
|
|
4
|
+
* 布局:header / 对话区(可滚动)/ 输入框 / 状态栏。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { emitKeypressEvents } from "node:readline"
|
|
8
|
+
import { PassThrough } from "node:stream"
|
|
9
|
+
import { basename } from "node:path"
|
|
10
|
+
import { runAgent } from "./agent.mjs"
|
|
11
|
+
import { saveSession, clearSession } from "./session.mjs"
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------- ANSI 工具
|
|
14
|
+
|
|
15
|
+
const ESC = "\x1b"
|
|
16
|
+
const ansi = {
|
|
17
|
+
hideCursor: `${ESC}[?25l`,
|
|
18
|
+
showCursor: `${ESC}[?25h`,
|
|
19
|
+
altBuffer: `${ESC}[?1049h`,
|
|
20
|
+
mainBuffer: `${ESC}[?1049l`,
|
|
21
|
+
mouseOn: `${ESC}[?1000h${ESC}[?1006h`, // 基本鼠标 + SGR 扩展坐标(滚轮上报)
|
|
22
|
+
mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
|
|
23
|
+
home: `${ESC}[H`,
|
|
24
|
+
clearLine: `${ESC}[K`,
|
|
25
|
+
reset: `${ESC}[0m`,
|
|
26
|
+
dim: `${ESC}[2m`,
|
|
27
|
+
bold: `${ESC}[1m`,
|
|
28
|
+
fg: (n) => `${ESC}[${30 + n}m`,
|
|
29
|
+
gray: `${ESC}[90m`,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const C = {
|
|
33
|
+
user: ansi.fg(4), // blue(标签)
|
|
34
|
+
assistant: ansi.fg(2), // green(标签)
|
|
35
|
+
text: ansi.fg(7), // white(对话正文)
|
|
36
|
+
reason: `${ESC}[2m${ESC}[3m`, // dim + italic(思考流)
|
|
37
|
+
tool: ansi.fg(6), // cyan
|
|
38
|
+
error: ansi.fg(1), // red
|
|
39
|
+
dim: ansi.gray,
|
|
40
|
+
warn: ansi.fg(3), // yellow
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 字符显示宽度:CJK/emoji 计 2,组合字符计 0,其余计 1 */
|
|
44
|
+
export function charWidth(cp) {
|
|
45
|
+
if (
|
|
46
|
+
(cp >= 0x300 && cp <= 0x36f) || // 组合变音符
|
|
47
|
+
(cp >= 0x200b && cp <= 0x200f) || // 零宽
|
|
48
|
+
cp === 0xfe0f // emoji 变体选择符
|
|
49
|
+
) {
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
54
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
55
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
56
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
57
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
58
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
59
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
60
|
+
(cp >= 0x1f000 && cp <= 0x1faff) ||
|
|
61
|
+
(cp >= 0x20000 && cp <= 0x3fffd) ||
|
|
62
|
+
(cp >= 0x2600 && cp <= 0x27bf)
|
|
63
|
+
) {
|
|
64
|
+
return 2
|
|
65
|
+
}
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function stringWidth(text) {
|
|
70
|
+
let w = 0
|
|
71
|
+
for (const ch of text) w += charWidth(ch.codePointAt(0))
|
|
72
|
+
return w
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 按显示宽度裁剪 */
|
|
76
|
+
function sliceByWidth(text, maxWidth) {
|
|
77
|
+
let w = 0
|
|
78
|
+
let out = ""
|
|
79
|
+
for (const ch of text) {
|
|
80
|
+
const cw = charWidth(ch.codePointAt(0))
|
|
81
|
+
if (w + cw > maxWidth) break
|
|
82
|
+
w += cw
|
|
83
|
+
out += ch
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 按显示宽度右补空格 */
|
|
89
|
+
function padByWidth(text, width) {
|
|
90
|
+
return text + " ".repeat(Math.max(0, width - stringWidth(text)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------- markdown 表格重排
|
|
94
|
+
|
|
95
|
+
const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
|
|
96
|
+
const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 识别文本中的 markdown 表格块,按显示宽度重排(修 CJK 错位)。
|
|
100
|
+
* width 为可用显示宽度;过宽的表格按列收缩。非表格行原样保留。
|
|
101
|
+
*/
|
|
102
|
+
export function formatTables(text, width) {
|
|
103
|
+
const lines = text.split("\n")
|
|
104
|
+
const out = []
|
|
105
|
+
let i = 0
|
|
106
|
+
while (i < lines.length) {
|
|
107
|
+
if (isTableRow(lines[i]) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
108
|
+
const block = [lines[i], lines[i + 1]]
|
|
109
|
+
i += 2
|
|
110
|
+
while (i < lines.length && isTableRow(lines[i])) {
|
|
111
|
+
block.push(lines[i])
|
|
112
|
+
i++
|
|
113
|
+
}
|
|
114
|
+
out.push(...renderTable(block, width))
|
|
115
|
+
} else {
|
|
116
|
+
out.push(lines[i])
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderTable(block, width) {
|
|
124
|
+
const rows = block.map((line) =>
|
|
125
|
+
line
|
|
126
|
+
.replace(/^\s*\|/, "")
|
|
127
|
+
.replace(/\|\s*$/, "")
|
|
128
|
+
.split("|")
|
|
129
|
+
.map((c) => c.trim()),
|
|
130
|
+
)
|
|
131
|
+
const colCount = Math.max(...rows.map((r) => r.length))
|
|
132
|
+
for (const r of rows) while (r.length < colCount) r.push("")
|
|
133
|
+
|
|
134
|
+
// 列宽:先按内容,超宽则从最宽列开始收缩(收缩到至少 3)
|
|
135
|
+
const widths = Array.from({ length: colCount }, (_, c) =>
|
|
136
|
+
Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
|
|
137
|
+
)
|
|
138
|
+
const borders = colCount * 3 + 1 // " │ " 分隔 + 首尾 |
|
|
139
|
+
while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
|
|
140
|
+
const widest = widths.indexOf(Math.max(...widths))
|
|
141
|
+
widths[widest]--
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fmtRow = (cells) =>
|
|
145
|
+
"│ " + cells.map((c, i) => padByWidth(sliceByWidth(c, widths[i]), widths[i])).join(" │ ") + " │"
|
|
146
|
+
const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
|
|
147
|
+
|
|
148
|
+
return [fmtRow(rows[0]), separator, ...rows.slice(2).map(fmtRow)]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置(显示宽度) */
|
|
152
|
+
export function layoutInput(chars, cursor, width) {
|
|
153
|
+
const PROMPT = "▸ "
|
|
154
|
+
const lines = []
|
|
155
|
+
let cursorLine = 0
|
|
156
|
+
let cursorCol = 0
|
|
157
|
+
let cur = ""
|
|
158
|
+
let col = 0
|
|
159
|
+
let firstLine = true
|
|
160
|
+
const avail = () => (firstLine ? width - 2 : width)
|
|
161
|
+
const flush = () => {
|
|
162
|
+
lines.push((firstLine ? PROMPT : "") + cur)
|
|
163
|
+
firstLine = false
|
|
164
|
+
cur = ""
|
|
165
|
+
col = 0
|
|
166
|
+
}
|
|
167
|
+
for (let i = 0; i <= chars.length; i++) {
|
|
168
|
+
if (i === cursor) {
|
|
169
|
+
cursorLine = lines.length
|
|
170
|
+
cursorCol = (firstLine ? 2 : 0) + col
|
|
171
|
+
}
|
|
172
|
+
const ch = chars[i]
|
|
173
|
+
if (ch === undefined) break
|
|
174
|
+
if (ch === "\n") {
|
|
175
|
+
flush()
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const w = charWidth(ch.codePointAt(0))
|
|
179
|
+
if (col + w > avail()) flush()
|
|
180
|
+
cur += ch
|
|
181
|
+
col += w
|
|
182
|
+
}
|
|
183
|
+
if (cur || lines.length === 0) flush()
|
|
184
|
+
return { lines, cursorLine, cursorCol }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 文本按宽度折行(保留 \n),返回行数组 */
|
|
188
|
+
export function wrapText(text, width) {
|
|
189
|
+
const lines = []
|
|
190
|
+
for (const rawLine of text.split("\n")) {
|
|
191
|
+
if (rawLine === "") {
|
|
192
|
+
lines.push("")
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
let line = rawLine
|
|
196
|
+
while (stringWidth(line) > width) {
|
|
197
|
+
const head = sliceByWidth(line, width)
|
|
198
|
+
lines.push(head)
|
|
199
|
+
line = line.slice([...head].length)
|
|
200
|
+
}
|
|
201
|
+
lines.push(line)
|
|
202
|
+
}
|
|
203
|
+
return lines
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------- TUI 主入口
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 启动 TUI,接管终端直到退出。
|
|
210
|
+
* agent: createAgent 的返回值
|
|
211
|
+
* opts: { projectDir?, team?, author? } —— /distill 写入 project/team 层时用
|
|
212
|
+
*/
|
|
213
|
+
export async function startTUI(agent, opts = {}) {
|
|
214
|
+
if (!process.stdin.isTTY) {
|
|
215
|
+
throw new Error("TUI requires a TTY; use 'thincoder chat' for non-interactive use")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const distillOpts = opts
|
|
219
|
+
|
|
220
|
+
const state = {
|
|
221
|
+
lines: [], // 对话区行:{ text, color }
|
|
222
|
+
streaming: "", // 当前流式缓冲
|
|
223
|
+
input: [], // 输入缓冲区(码点数组)
|
|
224
|
+
cursor: 0,
|
|
225
|
+
history: [],
|
|
226
|
+
historyIndex: -1,
|
|
227
|
+
scroll: 0, // 从底部向上的滚动行数
|
|
228
|
+
processing: false,
|
|
229
|
+
permission: null, // { name, args, resolve }
|
|
230
|
+
tasks: [], // task 工具的任务列表(状态栏显示进度)
|
|
231
|
+
reasoning: "", // 思考流缓冲(暗色展示)
|
|
232
|
+
toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
|
|
233
|
+
currentTool: null, // 正在执行的工具名(状态栏显示)
|
|
234
|
+
processingStarted: 0, // 本轮处理开始时间(状态栏计时)
|
|
235
|
+
status: "Ready",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 输入流先过一道滤网:鼠标序列(滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
|
|
239
|
+
// 防止序列残片(如 "64;72;42M")漏进输入框
|
|
240
|
+
const keyStream = new PassThrough()
|
|
241
|
+
let mousePending = "" // 跨 chunk 的不完整鼠标序列尾部
|
|
242
|
+
let lastRenderedScroll = 0
|
|
243
|
+
emitKeypressEvents(keyStream)
|
|
244
|
+
process.stdin.setRawMode(true)
|
|
245
|
+
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn)
|
|
246
|
+
|
|
247
|
+
process.stdin.on("data", (chunk) => {
|
|
248
|
+
let text = mousePending + chunk.toString("utf8")
|
|
249
|
+
mousePending = ""
|
|
250
|
+
|
|
251
|
+
// 滚轮:\x1b[<64;…M 上滚,\x1b[<65;…M 下滚(每次 3 行)
|
|
252
|
+
for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
|
|
253
|
+
if (Number(m[1]) === 64) {
|
|
254
|
+
state.scroll += 3
|
|
255
|
+
} else if (Number(m[1]) === 65) {
|
|
256
|
+
state.scroll = Math.max(0, state.scroll - 3)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 剥掉完整鼠标序列;不完整的尾部留到下一块数据再拼
|
|
261
|
+
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
262
|
+
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
263
|
+
if (tail) {
|
|
264
|
+
mousePending = tail[0]
|
|
265
|
+
text = text.slice(0, -tail[0].length)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (state.scroll !== lastRenderedScroll) {
|
|
269
|
+
lastRenderedScroll = state.scroll
|
|
270
|
+
render()
|
|
271
|
+
}
|
|
272
|
+
if (text) keyStream.write(text)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const cleanup = () => {
|
|
276
|
+
// 退出前保存会话(同步写,保证 exit 路径也能落盘)
|
|
277
|
+
try {
|
|
278
|
+
saveSession(agent)
|
|
279
|
+
} catch {
|
|
280
|
+
// 存失败不耽误退出
|
|
281
|
+
}
|
|
282
|
+
process.stdin.setRawMode(false)
|
|
283
|
+
process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
284
|
+
}
|
|
285
|
+
process.on("exit", cleanup)
|
|
286
|
+
|
|
287
|
+
const pushLine = (text, color) => {
|
|
288
|
+
state.lines.push({ text, color })
|
|
289
|
+
if (state.lines.length > 5000) state.lines.splice(0, 1000) // 防无限增长
|
|
290
|
+
render()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 消息块标签:空行 + 标签行。用户/助手消息之间留出呼吸空间 */
|
|
294
|
+
const pushLabel = (text, color) => {
|
|
295
|
+
if (state.lines.length > 0) state.lines.push({ text: "", color: C.dim })
|
|
296
|
+
state.lines.push({ text, color })
|
|
297
|
+
render()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 每轮对话只打一次助手标签(首个 token 或首个工具调用时)
|
|
301
|
+
let assistantLabeled = false
|
|
302
|
+
const ensureAssistantLabel = () => {
|
|
303
|
+
if (!assistantLabeled) {
|
|
304
|
+
assistantLabeled = true
|
|
305
|
+
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------- 渲染
|
|
310
|
+
|
|
311
|
+
// 帧去重 + 流式限流:内容没变的帧不重写(防闪屏);token 洪流合并到 ~25fps
|
|
312
|
+
let lastFrame = ""
|
|
313
|
+
let renderTimer = null
|
|
314
|
+
|
|
315
|
+
/** 流式期间的限流渲染(trailing edge:最后一次变化一定渲染到) */
|
|
316
|
+
function scheduleRender() {
|
|
317
|
+
if (renderTimer) return
|
|
318
|
+
renderTimer = setTimeout(() => {
|
|
319
|
+
renderTimer = null
|
|
320
|
+
render()
|
|
321
|
+
}, 40)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function render() {
|
|
325
|
+
const cols = process.stdout.columns || 80
|
|
326
|
+
const rows = process.stdout.rows || 24
|
|
327
|
+
const model = agent.provider.model
|
|
328
|
+
|
|
329
|
+
// 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
|
|
330
|
+
const W = Math.max(20, cols - 1)
|
|
331
|
+
const layout = layoutInput(state.input, state.cursor, W - 4)
|
|
332
|
+
// 最多显示 5 行;超出时以光标所在行为中心滚动
|
|
333
|
+
const MAX_INPUT_LINES = 5
|
|
334
|
+
let inputOffset = 0
|
|
335
|
+
if (layout.lines.length > MAX_INPUT_LINES) {
|
|
336
|
+
inputOffset = Math.min(layout.cursorLine, layout.lines.length - MAX_INPUT_LINES)
|
|
337
|
+
}
|
|
338
|
+
const inputLines = layout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
|
|
339
|
+
const inputBoxH = inputLines.length + 2
|
|
340
|
+
|
|
341
|
+
const headerH = 1
|
|
342
|
+
const statusH = 1
|
|
343
|
+
const convH = Math.max(1, rows - headerH - inputBoxH - statusH)
|
|
344
|
+
|
|
345
|
+
// 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
|
|
346
|
+
const convLines = []
|
|
347
|
+
for (const l of state.lines) {
|
|
348
|
+
for (const line of formatTables(l.text, cols - 1)) {
|
|
349
|
+
for (const wrapped of wrapText(line, cols - 1)) {
|
|
350
|
+
convLines.push({ text: wrapped, color: l.color })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// 思考流(暗色)在正文流之前
|
|
355
|
+
if (state.reasoning) {
|
|
356
|
+
for (const wrapped of wrapText(state.reasoning, cols - 1)) {
|
|
357
|
+
convLines.push({ text: wrapped, color: C.reason })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (state.streaming) {
|
|
361
|
+
for (const line of formatTables(state.streaming, cols - 1)) {
|
|
362
|
+
for (const wrapped of wrapText(line, cols - 1)) {
|
|
363
|
+
convLines.push({ text: wrapped, color: C.text })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// 工具实时输出(暗色,只保留末尾防刷屏)
|
|
368
|
+
if (state.toolStream) {
|
|
369
|
+
const tail = state.toolStream.slice(-4000)
|
|
370
|
+
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
371
|
+
convLines.push({ text: wrapped, color: C.dim })
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const maxScroll = Math.max(0, convLines.length - convH)
|
|
376
|
+
state.scroll = Math.min(state.scroll, maxScroll)
|
|
377
|
+
const end = convLines.length - state.scroll
|
|
378
|
+
const visible = convLines.slice(Math.max(0, end - convH), end)
|
|
379
|
+
|
|
380
|
+
const out = [ansi.home]
|
|
381
|
+
|
|
382
|
+
// header
|
|
383
|
+
out.push(
|
|
384
|
+
`${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
// 对话区(不足部分补空行,把输入框钉在底部)
|
|
388
|
+
const pad = convH - visible.length
|
|
389
|
+
for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
|
|
390
|
+
for (const l of visible) {
|
|
391
|
+
out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 输入框(全边框,宽 W)
|
|
395
|
+
const borderColor = state.permission ? C.warn : C.tool
|
|
396
|
+
const title = state.permission
|
|
397
|
+
? ` Allow ${state.permission.name}? (y/n) `
|
|
398
|
+
: state.processing
|
|
399
|
+
? " Processing... "
|
|
400
|
+
: " Input "
|
|
401
|
+
const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
402
|
+
out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
|
|
403
|
+
for (const l of inputLines) {
|
|
404
|
+
const content = sliceByWidth(l, W - 4)
|
|
405
|
+
const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
|
|
406
|
+
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
|
|
407
|
+
}
|
|
408
|
+
out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}${ansi.clearLine}`)
|
|
409
|
+
|
|
410
|
+
// 状态栏(输入 / 开头时变为命令提示)
|
|
411
|
+
const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
|
|
412
|
+
const rawInput = state.input.join("")
|
|
413
|
+
let statusLine
|
|
414
|
+
if (rawInput.startsWith("/") && !state.processing && !state.permission) {
|
|
415
|
+
const prefix = rawInput.split(/\s/)[0]
|
|
416
|
+
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix))
|
|
417
|
+
statusLine = matches.length > 0
|
|
418
|
+
? ` ${matches.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
419
|
+
: ` 未知命令(/help 查看可用命令)`
|
|
420
|
+
} else {
|
|
421
|
+
const taskHint = state.tasks.length > 0
|
|
422
|
+
? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
423
|
+
: ""
|
|
424
|
+
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
425
|
+
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
426
|
+
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
427
|
+
statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
|
428
|
+
}
|
|
429
|
+
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
430
|
+
out.push(`${ansi.dim}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
|
|
431
|
+
|
|
432
|
+
const frame = out.join("\r\n")
|
|
433
|
+
if (frame !== lastFrame) {
|
|
434
|
+
lastFrame = frame
|
|
435
|
+
process.stdout.write(frame)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认时隐藏
|
|
439
|
+
if (state.processing || state.permission) {
|
|
440
|
+
process.stdout.write(ansi.hideCursor)
|
|
441
|
+
} else {
|
|
442
|
+
const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
|
|
443
|
+
const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
|
|
444
|
+
process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
process.stdout.on("resize", render)
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------- 提交
|
|
451
|
+
|
|
452
|
+
async function submit() {
|
|
453
|
+
const text = state.input.join("").trim()
|
|
454
|
+
if (!text || state.processing) return
|
|
455
|
+
state.input = []
|
|
456
|
+
state.cursor = 0
|
|
457
|
+
state.history.push(text)
|
|
458
|
+
state.historyIndex = -1
|
|
459
|
+
state.scroll = 0
|
|
460
|
+
|
|
461
|
+
// 斜杠命令:本地处理,不进入 agent
|
|
462
|
+
if (text.startsWith("/")) {
|
|
463
|
+
await handleSlash(text)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
468
|
+
pushLine(text, C.text)
|
|
469
|
+
|
|
470
|
+
// 任务开始前自动打存档点(git 仓库内;失败静默,不挡任务)
|
|
471
|
+
try {
|
|
472
|
+
const { createCheckpoint } = await import("./checkpoint.mjs")
|
|
473
|
+
await createCheckpoint(agent.cwd)
|
|
474
|
+
} catch {
|
|
475
|
+
// 存档失败不影响任务
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
assistantLabeled = false
|
|
479
|
+
state.processing = true
|
|
480
|
+
state.status = "Processing..."
|
|
481
|
+
state.streaming = ""
|
|
482
|
+
state.reasoning = ""
|
|
483
|
+
state.currentTool = null
|
|
484
|
+
state.processingStarted = Date.now()
|
|
485
|
+
// 处理中每秒刷新一次状态栏(运行计时)
|
|
486
|
+
const ticker = setInterval(() => {
|
|
487
|
+
if (state.processing) render()
|
|
488
|
+
}, 1000)
|
|
489
|
+
render()
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
await runAgent(agent, text, {
|
|
493
|
+
onToken: (t) => {
|
|
494
|
+
ensureAssistantLabel()
|
|
495
|
+
state.streaming += t
|
|
496
|
+
scheduleRender() // token 洪流限流,防闪屏
|
|
497
|
+
},
|
|
498
|
+
onReasoning: (t) => {
|
|
499
|
+
ensureAssistantLabel()
|
|
500
|
+
state.reasoning += t
|
|
501
|
+
scheduleRender()
|
|
502
|
+
},
|
|
503
|
+
onToolCall: (name, args) => {
|
|
504
|
+
flushStream()
|
|
505
|
+
ensureAssistantLabel()
|
|
506
|
+
state.currentTool = name
|
|
507
|
+
pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
|
|
508
|
+
},
|
|
509
|
+
onToolResult: (name, result) => {
|
|
510
|
+
state.currentTool = null
|
|
511
|
+
if (state.toolStream) {
|
|
512
|
+
// 实时输出落盘为历史行(保留末尾 4000 字符),并清掉临时缓冲
|
|
513
|
+
const tail = state.toolStream.trimEnd().slice(-4000)
|
|
514
|
+
if (tail) pushLine(tail, C.dim)
|
|
515
|
+
state.toolStream = ""
|
|
516
|
+
}
|
|
517
|
+
const first = result.split("\n")[0]
|
|
518
|
+
pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
|
|
519
|
+
},
|
|
520
|
+
onToolOutput: (name, chunk) => {
|
|
521
|
+
state.toolStream += chunk
|
|
522
|
+
scheduleRender()
|
|
523
|
+
},
|
|
524
|
+
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
525
|
+
onTaskUpdate: (items) => {
|
|
526
|
+
state.tasks = items
|
|
527
|
+
const done = items.filter((i) => i.status === "done").length
|
|
528
|
+
pushLine(` [task] ${done}/${items.length}`, C.dim)
|
|
529
|
+
render()
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
flushStream()
|
|
533
|
+
} catch (error) {
|
|
534
|
+
flushStream()
|
|
535
|
+
pushLine(`[error] ${error.message}`, C.error)
|
|
536
|
+
} finally {
|
|
537
|
+
clearInterval(ticker)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
state.processing = false
|
|
541
|
+
state.status = "Ready"
|
|
542
|
+
// 每轮结束后保存会话(崩溃也不丢)
|
|
543
|
+
try {
|
|
544
|
+
saveSession(agent)
|
|
545
|
+
} catch {
|
|
546
|
+
// 存失败不打断使用
|
|
547
|
+
}
|
|
548
|
+
render()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function flushStream() {
|
|
552
|
+
if (state.reasoning) {
|
|
553
|
+
pushLine(state.reasoning, C.reason)
|
|
554
|
+
state.reasoning = ""
|
|
555
|
+
}
|
|
556
|
+
if (state.streaming) {
|
|
557
|
+
pushLine(state.streaming, C.text)
|
|
558
|
+
state.streaming = ""
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function askPermission(name, args) {
|
|
563
|
+
// auto 模式:完全授权,不再询问
|
|
564
|
+
if (agent.autoApprove) {
|
|
565
|
+
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
566
|
+
return Promise.resolve(true)
|
|
567
|
+
}
|
|
568
|
+
return new Promise((resolve) => {
|
|
569
|
+
state.permission = { name, args, resolve }
|
|
570
|
+
state.status = `Waiting: ${name}`
|
|
571
|
+
render()
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------- 斜杠命令
|
|
576
|
+
|
|
577
|
+
const SLASH_COMMANDS = [
|
|
578
|
+
{ name: "/help", desc: "命令列表" },
|
|
579
|
+
{ name: "/model", desc: "查看/切换模型" },
|
|
580
|
+
{ name: "/config", desc: "查看当前配置" },
|
|
581
|
+
{ name: "/auto", desc: "自动授权开关" },
|
|
582
|
+
{ name: "/rewind", desc: "回滚到存档点" },
|
|
583
|
+
{ name: "/reindex", desc: "重建记忆索引" },
|
|
584
|
+
{ name: "/distill", desc: "从会话提取知识" },
|
|
585
|
+
{ name: "/new", desc: "开始新会话" },
|
|
586
|
+
{ name: "/clear", desc: "清屏" },
|
|
587
|
+
{ name: "/exit", desc: "退出" },
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
async function handleSlash(text) {
|
|
591
|
+
const [cmd, ...rest] = text.split(/\s+/)
|
|
592
|
+
switch (cmd) {
|
|
593
|
+
case "/clear":
|
|
594
|
+
state.lines = []
|
|
595
|
+
state.streaming = ""
|
|
596
|
+
render()
|
|
597
|
+
return
|
|
598
|
+
case "/new":
|
|
599
|
+
agent.history = []
|
|
600
|
+
agent.tasks = []
|
|
601
|
+
state.tasks = []
|
|
602
|
+
state.lines = []
|
|
603
|
+
state.streaming = ""
|
|
604
|
+
clearSession(agent.cwd)
|
|
605
|
+
pushLine("已开始新会话(上一会话已归档)", C.dim)
|
|
606
|
+
return
|
|
607
|
+
case "/exit":
|
|
608
|
+
cleanup()
|
|
609
|
+
process.exit(0)
|
|
610
|
+
return
|
|
611
|
+
case "/reindex": {
|
|
612
|
+
const { syncDir } = await import("./memory.mjs")
|
|
613
|
+
pushLine("[reindex] 重建索引...", C.tool)
|
|
614
|
+
agent.memory.db.prepare("DELETE FROM files").run()
|
|
615
|
+
let total = 0
|
|
616
|
+
if (distillOpts.projectDir) {
|
|
617
|
+
const s = await syncDir(agent.memory, { layer: "project", dir: distillOpts.projectDir })
|
|
618
|
+
total += s.added
|
|
619
|
+
pushLine(` project: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
|
|
620
|
+
}
|
|
621
|
+
if (distillOpts.team?.dir) {
|
|
622
|
+
const s = await syncDir(agent.memory, { layer: "team", dir: distillOpts.team.dir })
|
|
623
|
+
total += s.added
|
|
624
|
+
pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
|
|
625
|
+
}
|
|
626
|
+
pushLine(`[reindex] 完成,共 ${total} 条。向量将在下次搜索时惰性生成。`, C.tool)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
case "/distill":
|
|
630
|
+
await runDistill()
|
|
631
|
+
return
|
|
632
|
+
case "/rewind": {
|
|
633
|
+
const { listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
|
|
634
|
+
if (!isGitRepo(agent.cwd)) {
|
|
635
|
+
pushLine("[rewind] 当前目录不是 git 仓库,无法使用存档点", C.error)
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
const id = rest[0]
|
|
639
|
+
if (!id) {
|
|
640
|
+
const cps = await listCheckpoints(agent.cwd)
|
|
641
|
+
pushLabel(`❯ Checkpoints`, ansi.bold + C.tool)
|
|
642
|
+
if (cps.length === 0) {
|
|
643
|
+
pushLine("(暂无存档点——每次提交任务前自动创建)", C.dim)
|
|
644
|
+
}
|
|
645
|
+
for (const cp of cps.slice(0, 10)) {
|
|
646
|
+
pushLine(` ${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} 个未跟踪文件)`, C.dim)
|
|
647
|
+
}
|
|
648
|
+
pushLine("回滚: /rewind <id>(恢复前会先存当前状态,回滚可逆)", C.dim)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const summary = await rewind(agent.cwd, id)
|
|
653
|
+
pushLabel(`❯ Rewind`, ansi.bold + C.warn)
|
|
654
|
+
pushLine(`已回滚到 ${id}:补丁${summary.patchApplied ? "已应用" : "无"},删除新建文件 ${summary.deleted} 个,还原文件 ${summary.restored} 个`, C.tool)
|
|
655
|
+
pushLine("(当前状态已先存为新存档点,可再次 /rewind 回到刚才)", C.dim)
|
|
656
|
+
} catch (error) {
|
|
657
|
+
pushLine(`[rewind] ${error.message}`, C.error)
|
|
658
|
+
}
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
case "/auto":
|
|
662
|
+
agent.autoApprove = !agent.autoApprove
|
|
663
|
+
pushLabel(`❯ Auto`, ansi.bold + (agent.autoApprove ? C.warn : C.tool))
|
|
664
|
+
pushLine(
|
|
665
|
+
agent.autoApprove
|
|
666
|
+
? `AUTO 已开启:所有工具调用(含写文件/bash/子 agent)不再询问,自动执行。长任务专用,/auto 关闭。`
|
|
667
|
+
: `AUTO 已关闭:有副作用的工具调用恢复逐个确认。`,
|
|
668
|
+
agent.autoApprove ? C.warn : C.dim,
|
|
669
|
+
)
|
|
670
|
+
return
|
|
671
|
+
case "/model": {
|
|
672
|
+
const arg = rest[0]
|
|
673
|
+
if (!arg) {
|
|
674
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
675
|
+
pushLine(`model: ${agent.provider.model}`, C.dim)
|
|
676
|
+
pushLine(`baseURL: ${agent.provider.baseURL}`, C.dim)
|
|
677
|
+
pushLine(`切换: /model <名称>(仅本次会话;永久修改请编辑 ~/.thincoder/config.json)`, C.dim)
|
|
678
|
+
// 拉取端点可用模型列表
|
|
679
|
+
pushLine(`正在拉取可用模型...`, C.dim)
|
|
680
|
+
try {
|
|
681
|
+
const { listModels } = await import("./provider.mjs")
|
|
682
|
+
const models = await listModels(agent.provider)
|
|
683
|
+
pushLabel(`❯ Available (${models.length})`, ansi.bold + C.tool)
|
|
684
|
+
for (const m of models) {
|
|
685
|
+
pushLine(` ${m === agent.provider.model ? "▸ " : " "}${m}`, m === agent.provider.model ? C.tool : C.dim)
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
pushLine(` (拉取失败: ${error.message})`, C.error)
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
agent.provider.model = arg
|
|
692
|
+
// 阈值是自动推导的则跟着新模型走;用户显式配置过的不动
|
|
693
|
+
let thresholdNote = ""
|
|
694
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
695
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
696
|
+
const { value } = resolveCompactThreshold(null, arg)
|
|
697
|
+
agent.config.agent.compactThreshold = value
|
|
698
|
+
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
699
|
+
}
|
|
700
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
701
|
+
pushLine(`已切换到 ${arg}(仅本次会话)${thresholdNote}`, C.tool)
|
|
702
|
+
}
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
case "/config": {
|
|
706
|
+
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
707
|
+
pushLine(`provider: ${agent.provider.baseURL} | model: ${agent.provider.model}`, C.dim)
|
|
708
|
+
pushLine(`apiKey: ${maskKey(agent.provider.apiKey)}`, C.dim)
|
|
709
|
+
const ac = agent.config?.agent ?? {}
|
|
710
|
+
const thresholdNote = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto,随模型)" : ""}`
|
|
711
|
+
pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${thresholdNote}`, C.dim)
|
|
712
|
+
pushLine(`memory: ${agent.memory ? "enabled" : "disabled"}${agent.memory?.embedder ? " + vector" : " (FTS only)"}`, C.dim)
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
case "/help": {
|
|
716
|
+
pushLabel(`❯ Commands`, ansi.bold + C.tool)
|
|
717
|
+
for (const c of SLASH_COMMANDS) pushLine(` ${c.name.padEnd(10)} ${c.desc}`, C.dim)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
default:
|
|
721
|
+
pushLine(`Unknown command: ${cmd}(/help 查看可用命令)`, C.error)
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function maskKey(key) {
|
|
727
|
+
if (!key) return "(none)"
|
|
728
|
+
if (key.length <= 8) return "***"
|
|
729
|
+
return `${key.slice(0, 5)}…${key.slice(-4)}`
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
|
|
733
|
+
async function runDistill() {
|
|
734
|
+
if (agent.history.length === 0) {
|
|
735
|
+
pushLine("[distill] 当前会话为空,没有可提取的内容", C.dim)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
state.processing = true
|
|
739
|
+
state.status = "Distilling..."
|
|
740
|
+
render()
|
|
741
|
+
try {
|
|
742
|
+
const { extractCandidates, historyToTranscript, saveCandidate } = await import("./distill.mjs")
|
|
743
|
+
pushLine("[distill] 正在分析会话...", C.tool)
|
|
744
|
+
const candidates = await extractCandidates(agent.provider, historyToTranscript(agent.history))
|
|
745
|
+
if (candidates.length === 0) {
|
|
746
|
+
pushLine("[distill] 本次会话没有值得沉淀的知识", C.dim)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
let saved = 0
|
|
750
|
+
for (const c of candidates) {
|
|
751
|
+
pushLine(`── 候选 [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
|
|
752
|
+
for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
|
|
753
|
+
if (c.type === "rule") pushLine(" (rule 类建议手动撰写;确认提取请按 y)", C.warn)
|
|
754
|
+
const accept = await askPermission("distill-save", { title: c.title })
|
|
755
|
+
if (!accept) {
|
|
756
|
+
pushLine(" skipped", C.dim)
|
|
757
|
+
continue
|
|
758
|
+
}
|
|
759
|
+
const where = await saveCandidate(agent.memory, c, distillOpts)
|
|
760
|
+
pushLine(` saved -> ${where}`, C.tool)
|
|
761
|
+
saved++
|
|
762
|
+
}
|
|
763
|
+
pushLine(`[distill] 完成:入库 ${saved}/${candidates.length} 条`, C.tool)
|
|
764
|
+
} catch (error) {
|
|
765
|
+
pushLine(`[distill] error: ${error.message}`, C.error)
|
|
766
|
+
} finally {
|
|
767
|
+
state.processing = false
|
|
768
|
+
state.status = "Ready"
|
|
769
|
+
render()
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------- 键盘 / 鼠标
|
|
774
|
+
|
|
775
|
+
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
776
|
+
keyStream.on("keypress", (str, key = {}) => {
|
|
777
|
+
// 权限确认态:只认 y/n
|
|
778
|
+
if (state.permission) {
|
|
779
|
+
const answer = (str || "").toLowerCase()
|
|
780
|
+
if (answer === "y" || answer === "n" || key.name === "escape") {
|
|
781
|
+
const { resolve } = state.permission
|
|
782
|
+
state.permission = null
|
|
783
|
+
state.status = "Processing..."
|
|
784
|
+
resolve(answer === "y")
|
|
785
|
+
render()
|
|
786
|
+
}
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (key.ctrl && key.name === "c") {
|
|
791
|
+
cleanup()
|
|
792
|
+
process.exit(0)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// 翻页
|
|
796
|
+
if (key.name === "pageup") {
|
|
797
|
+
state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
|
|
798
|
+
render()
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
if (key.name === "pagedown") {
|
|
802
|
+
state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
|
|
803
|
+
render()
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (state.processing) return // 处理中锁定输入
|
|
808
|
+
|
|
809
|
+
// 输入历史
|
|
810
|
+
if (key.name === "up") {
|
|
811
|
+
if (state.history.length) {
|
|
812
|
+
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
813
|
+
state.input = [...state.history[state.historyIndex]]
|
|
814
|
+
state.cursor = state.input.length
|
|
815
|
+
render()
|
|
816
|
+
}
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
if (key.name === "down") {
|
|
820
|
+
if (state.historyIndex !== -1) {
|
|
821
|
+
state.historyIndex++
|
|
822
|
+
if (state.historyIndex >= state.history.length) {
|
|
823
|
+
state.historyIndex = -1
|
|
824
|
+
state.input = []
|
|
825
|
+
} else {
|
|
826
|
+
state.input = [...state.history[state.historyIndex]]
|
|
827
|
+
}
|
|
828
|
+
state.cursor = state.input.length
|
|
829
|
+
render()
|
|
830
|
+
}
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// 光标移动
|
|
835
|
+
if (key.name === "left") {
|
|
836
|
+
state.cursor = Math.max(0, state.cursor - 1)
|
|
837
|
+
render()
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
if (key.name === "right") {
|
|
841
|
+
state.cursor = Math.min(state.input.length, state.cursor + 1)
|
|
842
|
+
render()
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
if (key.name === "home") {
|
|
846
|
+
state.cursor = 0
|
|
847
|
+
render()
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
if (key.name === "end") {
|
|
851
|
+
state.cursor = state.input.length
|
|
852
|
+
render()
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// 编辑
|
|
857
|
+
if (key.name === "backspace") {
|
|
858
|
+
if (state.cursor > 0) {
|
|
859
|
+
state.input.splice(state.cursor - 1, 1)
|
|
860
|
+
state.cursor--
|
|
861
|
+
render()
|
|
862
|
+
}
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
if (key.name === "delete") {
|
|
866
|
+
if (state.cursor < state.input.length) {
|
|
867
|
+
state.input.splice(state.cursor, 1)
|
|
868
|
+
render()
|
|
869
|
+
}
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
if (key.name === "return") {
|
|
873
|
+
submit()
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 可打印字符 / 粘贴(str 可能一次多个字符)
|
|
878
|
+
if (str && !key.ctrl && !key.meta) {
|
|
879
|
+
const chars = [...str.replace(/\r/g, "")]
|
|
880
|
+
state.input.splice(state.cursor, 0, ...chars)
|
|
881
|
+
state.cursor += chars.length
|
|
882
|
+
render()
|
|
883
|
+
}
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
// 启动画面
|
|
887
|
+
pushLine(`Welcome to ThinCoder. Model: ${agent.provider.model}`, C.dim)
|
|
888
|
+
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
889
|
+
// 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
|
|
890
|
+
if (opts.restored?.history?.length) {
|
|
891
|
+
for (const m of opts.restored.history) {
|
|
892
|
+
if (m.role === "user") {
|
|
893
|
+
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
894
|
+
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
895
|
+
} else if (m.role === "assistant") {
|
|
896
|
+
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
897
|
+
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
898
|
+
for (const tc of m.tool_calls ?? []) {
|
|
899
|
+
pushLine(` [tool] ${tc.function?.name ?? "?"}`, C.tool)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// tool 角色的结果行省略:调用行已足够还原现场
|
|
903
|
+
}
|
|
904
|
+
pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
|
|
905
|
+
}
|
|
906
|
+
render()
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function summarize(obj) {
|
|
910
|
+
const s = JSON.stringify(obj)
|
|
911
|
+
return s.length > 80 ? s.slice(0, 80) + "…" : s
|
|
912
|
+
}
|