thincoder 0.7.7 → 0.8.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/README.md +36 -13
- package/bin/thincoder.mjs +27 -346
- package/package.json +1 -1
- 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.md +1 -0
- 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 -1188
- 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 -2546
- 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,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.mjs — 裸 ANSI 终端 UI
|
|
3
|
+
* 零依赖:raw mode 键盘输入、ANSI 转义渲染、自研宽字符换行。
|
|
4
|
+
* 布局:header / 对话区 (可滚动)/ todo 面板 (有任务时)/ 输入框 / 状态栏。
|
|
5
|
+
*
|
|
6
|
+
* 大型逻辑块已拆到独立模块:
|
|
7
|
+
* agent-turn.mjs — agent 循环 + 回调构建
|
|
8
|
+
* key-handler.mjs — 键盘事件分发
|
|
9
|
+
* startup.mjs — 启动画面 + 会话恢复 + 后台索引
|
|
10
|
+
* interaction.mjs — 权限审批 + 问答
|
|
11
|
+
* pickers.mjs — 通用列表选择器 + 模型选择器
|
|
12
|
+
* wizard.mjs — 首次配置向导
|
|
13
|
+
* slash-commands.mjs — 斜杠命令分发
|
|
14
|
+
* config-helpers.mjs — persistRaw / syncProviderField / maskKey
|
|
15
|
+
* clipboard.mjs — 剪贴板图片粘贴
|
|
16
|
+
* distill-cmd.mjs — /distill 命令
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { emitKeypressEvents } from "node:readline"
|
|
20
|
+
import { PassThrough } from "node:stream"
|
|
21
|
+
import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
|
|
22
|
+
import { closeAllMcp } from "../mcp.mjs"
|
|
23
|
+
import { estimateTokens } from "../context.mjs"
|
|
24
|
+
import { ansi, C } from "./ansi.mjs"
|
|
25
|
+
import { renderFrame, countConvLines } from "./render-frame.mjs"
|
|
26
|
+
import { computeLayout } from "./layout.mjs"
|
|
27
|
+
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
28
|
+
import { createWizard } from "./wizard.mjs"
|
|
29
|
+
import { createPickers } from "./pickers.mjs"
|
|
30
|
+
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
31
|
+
import { createInteraction } from "./interaction.mjs"
|
|
32
|
+
import { pasteClipboardImage as pasteClipboardImageImpl } from "./clipboard.mjs"
|
|
33
|
+
import { runAgentTurn } from "./agent-turn.mjs"
|
|
34
|
+
import { createKeyHandler } from "./key-handler.mjs"
|
|
35
|
+
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
36
|
+
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 启动 TUI,接管终端直到退出。
|
|
40
|
+
* agent: createAgent 的返回值
|
|
41
|
+
* opts: { projectDir?, team?, author? } —— /distill 写入 project/team 层时用
|
|
42
|
+
*/
|
|
43
|
+
export async function startTUI(agent, opts = {}) {
|
|
44
|
+
if (!process.stdin.isTTY) {
|
|
45
|
+
throw new Error("TUI requires a TTY; use 'thincoder chat' for non-interactive use")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const distillOpts = opts
|
|
49
|
+
|
|
50
|
+
const state = {
|
|
51
|
+
lines: [], // 对话区行:{ text, color }
|
|
52
|
+
streaming: "", // current流式缓冲
|
|
53
|
+
input: [], // 输入缓冲区 (码点数组)
|
|
54
|
+
cursor: 0,
|
|
55
|
+
history: [],
|
|
56
|
+
historyIndex: -1,
|
|
57
|
+
scroll: 0, // 从底部向上的滚动行数
|
|
58
|
+
processing: false,
|
|
59
|
+
controller: null, // AbortController for current agent run
|
|
60
|
+
permission: null, // { name, args, resolve }
|
|
61
|
+
permissionPreview: [], // 权限审批的内容预览行 (渲染在输入框上方,不分隔)
|
|
62
|
+
question: null, // { text, options, resolve } — agent 的 question 工具回调
|
|
63
|
+
picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
|
|
64
|
+
wizard: null, // 首次Config向导 { step, index, scroll, selectedLine, fields, error, lines }
|
|
65
|
+
tasks: agent.tasks ?? [], // task 工具的任务列表 (状态栏显示进度);会话恢复时直接带上,全完成自动收起
|
|
66
|
+
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量 (状态栏显示)
|
|
67
|
+
ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存 (estimateTokens 是 O(n),history 变长才重算)
|
|
68
|
+
reasoning: "", // 思考流缓冲 (暗色展示)
|
|
69
|
+
completion: null, // Tab 补全状态 { candidates, index }
|
|
70
|
+
toolStreams: {}, // 各工具的实时输出 (按工具名隔离,并行工具互不串扰)
|
|
71
|
+
subTasks: {}, // 子 agent 面板:{ roleName: { role, text, done } },每 role 一行,完成后标记 done 停留片刻
|
|
72
|
+
currentTool: null, // 正在执行的工具名 (状态栏显示)
|
|
73
|
+
processingStarted: 0, // 本轮处理开始时间 (状态栏计时)
|
|
74
|
+
status: "Ready",
|
|
75
|
+
queue: [], // 处理中排队的待执行消息:[{ text }],处理完自动取下一条
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 恢复的会话如果所有任务completed,自动收起 todo 面板 (对齐运行时行为)
|
|
79
|
+
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
80
|
+
state.tasks = []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 输入流先过一道滤网:鼠标序列 (滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
|
|
84
|
+
// 防止序列残片 (如 "64;72;42M")漏进输入框
|
|
85
|
+
const keyStream = new PassThrough()
|
|
86
|
+
let mousePending = "" // 跨 chunk 的不完整鼠标序列尾部
|
|
87
|
+
let lastRenderedScroll = 0
|
|
88
|
+
emitKeypressEvents(keyStream)
|
|
89
|
+
process.stdin.setRawMode(true)
|
|
90
|
+
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn)
|
|
91
|
+
|
|
92
|
+
process.stdin.on("data", (chunk) => {
|
|
93
|
+
let text = mousePending + chunk.toString("utf8")
|
|
94
|
+
mousePending = ""
|
|
95
|
+
|
|
96
|
+
// 滚轮:\x1b[<64;…M 上滚,\x1b[<65;…M 下滚 (每次 3 行)
|
|
97
|
+
for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
|
|
98
|
+
if (Number(m[1]) === 64) {
|
|
99
|
+
state.scroll += 3
|
|
100
|
+
} else if (Number(m[1]) === 65) {
|
|
101
|
+
state.scroll = Math.max(0, state.scroll - 3)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 剥掉完整鼠标序列;不完整的尾部留到下一块数据再拼
|
|
106
|
+
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
107
|
+
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
108
|
+
if (tail) {
|
|
109
|
+
mousePending = tail[0]
|
|
110
|
+
text = text.slice(0, -tail[0].length)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (state.scroll !== lastRenderedScroll) {
|
|
114
|
+
lastRenderedScroll = state.scroll
|
|
115
|
+
render()
|
|
116
|
+
}
|
|
117
|
+
if (text) keyStream.write(text)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
let cleanedUp = false
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
if (cleanedUp) return
|
|
123
|
+
cleanedUp = true
|
|
124
|
+
// 退出前保存会话 (同步写);先归档current到槽位,再落新——不丢
|
|
125
|
+
try {
|
|
126
|
+
archiveCurrent(agent.cwd)
|
|
127
|
+
saveSession(agent, state.lines)
|
|
128
|
+
} catch {
|
|
129
|
+
// 存失败不耽误退出
|
|
130
|
+
}
|
|
131
|
+
// Off MCP stdio 子进程,不留孤儿
|
|
132
|
+
try {
|
|
133
|
+
closeAllMcp(agent)
|
|
134
|
+
} catch {
|
|
135
|
+
// 关不掉就算了,进程马上退出
|
|
136
|
+
}
|
|
137
|
+
process.stdin.setRawMode(false)
|
|
138
|
+
process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
139
|
+
}
|
|
140
|
+
process.on("exit", cleanup)
|
|
141
|
+
|
|
142
|
+
const pushLine = (text, color) => {
|
|
143
|
+
state.lines.push({ text, color })
|
|
144
|
+
if (state.lines.length > 5000) state.lines.splice(0, 1000) // 防none限增长
|
|
145
|
+
render()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 消息块标签:空行 + 标签行。用户/助手消息之间留出呼吸空间 */
|
|
149
|
+
const pushLabel = (text, color) => {
|
|
150
|
+
if (state.lines.length > 0) state.lines.push({ text: "", color: C.dim })
|
|
151
|
+
state.lines.push({ text, color })
|
|
152
|
+
render()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 每轮对话只打一次助手标签 (首个 token 或首个工具调用时)
|
|
156
|
+
let assistantLabeled = false
|
|
157
|
+
const ensureAssistantLabel = () => {
|
|
158
|
+
if (!assistantLabeled) {
|
|
159
|
+
assistantLabeled = true
|
|
160
|
+
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------- 渲染
|
|
165
|
+
|
|
166
|
+
// 帧去重 + 流式限流:内容没变的帧不重写 (防闪屏);token 洪流合并到 ~25fps
|
|
167
|
+
let lastFrame = ""
|
|
168
|
+
let renderTimer = null
|
|
169
|
+
|
|
170
|
+
function scheduleRender() {
|
|
171
|
+
if (renderTimer) return
|
|
172
|
+
renderTimer = setTimeout(() => {
|
|
173
|
+
renderTimer = null
|
|
174
|
+
render()
|
|
175
|
+
}, 40)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function render() {
|
|
179
|
+
// 副作用:scroll 归位 + ctxCache 更新 + overlay scroll 归位
|
|
180
|
+
// (renderFrame 是纯函数,副作用集中在此)
|
|
181
|
+
const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
182
|
+
const layout = computeLayout(state, dims)
|
|
183
|
+
// clamp conversation scroll
|
|
184
|
+
const convLines = countConvLines(state, dims.cols)
|
|
185
|
+
const maxScroll = Math.max(0, convLines - layout.panels.conversation.h)
|
|
186
|
+
state.scroll = Math.min(state.scroll, maxScroll)
|
|
187
|
+
// clamp overlay scroll
|
|
188
|
+
const overlay = state.picker ?? state.wizard
|
|
189
|
+
if (overlay && layout.panels.picker) {
|
|
190
|
+
const winH = layout.panels.picker.h - 1
|
|
191
|
+
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
192
|
+
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
193
|
+
}
|
|
194
|
+
// update ctxCache
|
|
195
|
+
if (state.ctxCache.len !== agent.history.length) {
|
|
196
|
+
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { frame, cursorRow, cursorCol } = renderFrame(state, agent, {
|
|
200
|
+
...dims,
|
|
201
|
+
slashCommands: SLASH_COMMANDS,
|
|
202
|
+
})
|
|
203
|
+
if (frame !== lastFrame) {
|
|
204
|
+
lastFrame = frame
|
|
205
|
+
process.stdout.write(frame)
|
|
206
|
+
}
|
|
207
|
+
// 光标:输入态定位到输入框内;权限确认/菜单态时隐藏
|
|
208
|
+
if (state.permission || state.question || state.picker || state.wizard?.step === "provider") {
|
|
209
|
+
process.stdout.write(ansi.hideCursor)
|
|
210
|
+
} else {
|
|
211
|
+
process.stdout.write(`${"\x1b"}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
process.stdout.on("resize", render)
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------- 提交
|
|
218
|
+
|
|
219
|
+
async function submit() {
|
|
220
|
+
const text = state.input.join("").trim()
|
|
221
|
+
if (!text) return
|
|
222
|
+
state.input = []
|
|
223
|
+
state.cursor = 0
|
|
224
|
+
state.history.push(text)
|
|
225
|
+
state.historyIndex = -1
|
|
226
|
+
state.scroll = 0
|
|
227
|
+
|
|
228
|
+
// 斜杠Commands:本地处理,不进入 agent
|
|
229
|
+
if (text.startsWith("/")) {
|
|
230
|
+
if (state.processing) {
|
|
231
|
+
// 处理中:只读命令(切换/查看/帮助)直接执行,有副作用的(清屏/新建/重建索引/提取)排队
|
|
232
|
+
const cmd0 = text.split(/\s+/)[0]
|
|
233
|
+
const ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
|
|
234
|
+
const resolved0 = ALIASES[cmd0] ?? cmd0
|
|
235
|
+
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
236
|
+
if (safeDuringProcessing.has(resolved0)) {
|
|
237
|
+
await handleSlash(text)
|
|
238
|
+
} else {
|
|
239
|
+
state.queue.push({ text })
|
|
240
|
+
render()
|
|
241
|
+
}
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
await handleSlash(text)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 处理中:入队等待,不立即执行
|
|
249
|
+
if (state.processing) {
|
|
250
|
+
state.queue.push({ text })
|
|
251
|
+
pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
|
|
252
|
+
pushLine(text, C.dim)
|
|
253
|
+
render()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await turn(text)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 交互原语:权限审批 + 问答输入,实现在 interaction.mjs
|
|
261
|
+
const { askPermission, askQuestion } = createInteraction({
|
|
262
|
+
agent, state, pushLine, pushLabel, render, summarize,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// 剪贴板图片粘贴:实现在 clipboard.mjs
|
|
266
|
+
const pasteClipboardImage = () => pasteClipboardImageImpl({ agent, state, pushLine, render })
|
|
267
|
+
|
|
268
|
+
// agent 循环:实现在 agent-turn.mjs
|
|
269
|
+
const turnCtx = {
|
|
270
|
+
agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
|
|
271
|
+
askPermission, askQuestion, handleSlash: null, summarize,
|
|
272
|
+
get assistantLabeled() { return assistantLabeled },
|
|
273
|
+
set assistantLabeled(v) { assistantLabeled = v },
|
|
274
|
+
}
|
|
275
|
+
const turn = (text) => runAgentTurn(turnCtx, text)
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------- 斜杠Commands
|
|
278
|
+
|
|
279
|
+
// 配置辅助:实现在 config-helpers.mjs
|
|
280
|
+
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
281
|
+
|
|
282
|
+
// 模型选择器 + 通用 picker:实现在 pickers.mjs
|
|
283
|
+
const { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey } = createPickers({
|
|
284
|
+
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// 初始 Config 向导:实现在 wizard.mjs,通过 ctx 传入闭包依赖
|
|
288
|
+
const { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems } = createWizard({
|
|
289
|
+
agent, state, pushLine, pushLabel, render, persistRaw,
|
|
290
|
+
openModelPicker: () => openModelPicker(),
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// /distill: impl in distill-cmd.mjs, ctx-passed
|
|
294
|
+
const runDistill = () => runDistillImpl({ agent, state, pushLine, render, askPermission, distillOpts })
|
|
295
|
+
|
|
296
|
+
// 斜杠命令分发、Tab 补全:实现在 slash-commands.mjs,通过 ctx 传入闭包依赖
|
|
297
|
+
const { handleSlash, completions, handleTab } = createSlashCommands({
|
|
298
|
+
agent, state, distillOpts,
|
|
299
|
+
pushLine, pushLabel, render,
|
|
300
|
+
openPicker, askQuestion, askPermission,
|
|
301
|
+
persistRaw, syncProviderField, maskKey,
|
|
302
|
+
openModelPicker: () => openModelPicker(),
|
|
303
|
+
setProviderKey,
|
|
304
|
+
runDistill,
|
|
305
|
+
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
306
|
+
})
|
|
307
|
+
// handleSlash 被 turnCtx 引用(循环依赖:submit → turn → handleSlash),在此回填
|
|
308
|
+
turnCtx.handleSlash = handleSlash
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------- 键盘 / 鼠标
|
|
311
|
+
|
|
312
|
+
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
313
|
+
const onKeypress = createKeyHandler({
|
|
314
|
+
agent, state, render, closePicker, renderPickerLines,
|
|
315
|
+
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
316
|
+
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
317
|
+
renderWizard, pushLine, cleanup,
|
|
318
|
+
})
|
|
319
|
+
keyStream.on("keypress", onKeypress)
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------- 启动画面 + 后台索引
|
|
322
|
+
|
|
323
|
+
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
324
|
+
backgroundIndex({ agent, state, render })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function summarize(obj) {
|
|
328
|
+
const s = JSON.stringify(obj)
|
|
329
|
+
return s.length > 80 ? s.slice(0, 80) + "…" : s
|
|
330
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
|
|
3
|
+
/** 交互原语:权限审批 + 问答输入。
|
|
4
|
+
* 从 index.mjs 抽出,通过 createInteraction(ctx) 接收闭包依赖。
|
|
5
|
+
* ctx: { agent, state, pushLine, pushLabel, render, summarize } */
|
|
6
|
+
export function createInteraction(ctx) {
|
|
7
|
+
const { agent, state, pushLine, pushLabel, render, summarize } = ctx
|
|
8
|
+
|
|
9
|
+
/** 权限请求的关键信息 (按工具定制),返回行数组。name 可能带子 agent 前缀 ("coder/bash"),取基名匹配 */
|
|
10
|
+
function formatPermission(name, args) {
|
|
11
|
+
const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
|
|
12
|
+
const base = name.includes("/") ? name.split("/").pop() : name
|
|
13
|
+
if (base === "bash") return cap(args.command ?? "").split("\n")
|
|
14
|
+
if (base === "write") {
|
|
15
|
+
// 批准写文件必须看得到要写什么:路径 + 内容预览
|
|
16
|
+
return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
|
|
17
|
+
}
|
|
18
|
+
if (base === "edit") {
|
|
19
|
+
// 简易 diff:- 旧内容 / + 新内容
|
|
20
|
+
return [
|
|
21
|
+
`${args.path}`,
|
|
22
|
+
...cap(args.old_string ?? "", 500).split("\n").map((l) => `- ${l}`),
|
|
23
|
+
" ↓",
|
|
24
|
+
...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
if (base === "apply_patch") {
|
|
28
|
+
// 补丁本身就是可读的 diff,直接预览
|
|
29
|
+
return cap(args.patch ?? "", 1500).split("\n")
|
|
30
|
+
}
|
|
31
|
+
if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
|
|
32
|
+
if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
33
|
+
if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
|
|
34
|
+
return [cap(summarize(args), 300)]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function askPermission(name, args) {
|
|
38
|
+
// auto 模式:完全授权,不再询问
|
|
39
|
+
if (agent.autoApprove) {
|
|
40
|
+
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
41
|
+
return Promise.resolve(true)
|
|
42
|
+
}
|
|
43
|
+
// 预览内容存到 permissionPreview,渲染在输入框上方紧挨"Allow?"提示
|
|
44
|
+
state.permissionPreview = formatPermission(name, args)
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
state.permission = { name, args, resolve }
|
|
47
|
+
state.status = `Waiting: ${name}`
|
|
48
|
+
render()
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function askQuestion(text, options = []) {
|
|
53
|
+
// 一次只能问一个:question 是只读工具走并行通道,同批第二个直接驳回,
|
|
54
|
+
// 否则后到的会覆盖 state.question,先到的 Promise 永远悬挂 (agent 死等)
|
|
55
|
+
if (state.question) {
|
|
56
|
+
return Promise.resolve("(error: another question is pending; ask one at a time and wait for the answer)")
|
|
57
|
+
}
|
|
58
|
+
if (!options.length) {
|
|
59
|
+
// 自由文本:打开输入态让用户打字,Enter 提交
|
|
60
|
+
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
61
|
+
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
state.question = { text, options: [], resolve }
|
|
64
|
+
state.status = "Waiting for answer..."
|
|
65
|
+
render()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
// 选项模式:输入框内显示列表,方向键选,Enter 确认
|
|
69
|
+
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
70
|
+
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
state.question = { text, options, selected: 0, resolve }
|
|
73
|
+
state.status = "Waiting for choice..."
|
|
74
|
+
render()
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { askPermission, askQuestion, formatPermission }
|
|
79
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
|
|
3
|
+
/** 键盘事件分发:权限确认 / 问答 / picker / wizard / 编辑 / 翻页 / 历史 / 粘贴。
|
|
4
|
+
* 从 index.mjs 抽出。
|
|
5
|
+
* ctx: { agent, state, render, renderPickerLines, closePicker,
|
|
6
|
+
* handleSlash, handleTab, submit, pasteClipboardImage,
|
|
7
|
+
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
8
|
+
* renderWizard, pushLine, cleanup } */
|
|
9
|
+
export function createKeyHandler(ctx) {
|
|
10
|
+
const { agent, state, render, closePicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
|
|
11
|
+
|
|
12
|
+
return function onKeypress(str, key = {}) {
|
|
13
|
+
// 权限确认态:y 批准 / n 拒绝 / a 批准并On AUTO (后续不再询问)
|
|
14
|
+
if (state.permission) {
|
|
15
|
+
const answer = (str || "").toLowerCase()
|
|
16
|
+
const isContinue = state.permission.name === "continue"
|
|
17
|
+
const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
|
|
18
|
+
if (validKeys.includes(answer) || key.name === "escape") {
|
|
19
|
+
const { resolve, name } = state.permission
|
|
20
|
+
state.permission = null
|
|
21
|
+
state.permissionPreview = []
|
|
22
|
+
state.status = "Processing..."
|
|
23
|
+
if (answer === "a" && !isContinue) {
|
|
24
|
+
agent.autoApprove = true
|
|
25
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
26
|
+
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
27
|
+
pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
|
|
28
|
+
}
|
|
29
|
+
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
30
|
+
// 决定落痕:对话区留下批准/拒绝记录 (continue 询问有自己的输出,不重复记)
|
|
31
|
+
if (!isContinue) {
|
|
32
|
+
pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
|
|
33
|
+
}
|
|
34
|
+
resolve(approved)
|
|
35
|
+
render()
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// question 工具回调:自由文本 / 选项选择
|
|
41
|
+
if (state.question) {
|
|
42
|
+
const q = state.question
|
|
43
|
+
if (q.options.length > 0) {
|
|
44
|
+
// 选项模式:↑↓ 选择,Enter 确认,Esc 取消
|
|
45
|
+
if (key.name === "escape") {
|
|
46
|
+
q.resolve("(cancelled)")
|
|
47
|
+
state.question = null
|
|
48
|
+
state.status = "Processing..."
|
|
49
|
+
render()
|
|
50
|
+
} else if (key.name === "up") {
|
|
51
|
+
q.selected = Math.max(0, (q.selected ?? 0) - 1)
|
|
52
|
+
render()
|
|
53
|
+
} else if (key.name === "down") {
|
|
54
|
+
q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
|
|
55
|
+
render()
|
|
56
|
+
} else if (key.name === "return") {
|
|
57
|
+
const answer = q.options[q.selected ?? 0]
|
|
58
|
+
q.resolve(answer)
|
|
59
|
+
state.question = null
|
|
60
|
+
state.status = "Processing..."
|
|
61
|
+
pushLine(` → ${answer}`, C.tool)
|
|
62
|
+
render()
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// 自由文本:键入答案,Enter 提交,Esc 取消
|
|
66
|
+
if (key.name === "escape") {
|
|
67
|
+
q.resolve("(cancelled)")
|
|
68
|
+
state.question = null
|
|
69
|
+
state.status = "Processing..."
|
|
70
|
+
render()
|
|
71
|
+
} else if (key.name === "return") {
|
|
72
|
+
const answer = (q.answer ?? "").trim()
|
|
73
|
+
q.resolve(answer || "(empty answer)")
|
|
74
|
+
state.question = null
|
|
75
|
+
state.status = "Processing..."
|
|
76
|
+
pushLine(` → ${answer || "(empty)"}`, C.tool)
|
|
77
|
+
render()
|
|
78
|
+
} else if (key.name === "backspace") {
|
|
79
|
+
q.answer = (q.answer ?? "").slice(0, -1)
|
|
80
|
+
render()
|
|
81
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
82
|
+
q.answer = (q.answer ?? "") + str
|
|
83
|
+
render()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (key.ctrl && key.name === "c") {
|
|
90
|
+
if (state.processing && state.controller) {
|
|
91
|
+
state.controller.abort()
|
|
92
|
+
pushLine("[Aborting…]", C.warn)
|
|
93
|
+
render()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
cleanup()
|
|
97
|
+
setTimeout(() => process.exit(0), 100)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 通用列表选择器:↑↓ 移动,Enter 确认,Esc 取消
|
|
101
|
+
if (state.picker) {
|
|
102
|
+
const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
103
|
+
if (key.name === "escape") {
|
|
104
|
+
closePicker()
|
|
105
|
+
} else if (key.name === "up" && items.length) {
|
|
106
|
+
state.picker.index = (state.picker.index - 1 + items.length) % items.length
|
|
107
|
+
renderPickerLines()
|
|
108
|
+
} else if (key.name === "down" && items.length) {
|
|
109
|
+
state.picker.index = (state.picker.index + 1) % items.length
|
|
110
|
+
renderPickerLines()
|
|
111
|
+
} else if (key.name === "return" && items.length) {
|
|
112
|
+
const selected = items[state.picker.index]
|
|
113
|
+
const handler = state.picker.onSelect
|
|
114
|
+
state.picker = null // 先关 picker,避免 onSelect 内部 render 时 picker 还在
|
|
115
|
+
// onSelect 是 async(如删 provider 要写文件),用 catch 兜住错误不被吞
|
|
116
|
+
Promise.resolve(handler?.(selected)).catch((err) => {
|
|
117
|
+
pushLine(`[error] ${err.message}`, C.error)
|
|
118
|
+
}).finally(() => render())
|
|
119
|
+
}
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 初始Config向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
|
|
124
|
+
if (state.wizard) {
|
|
125
|
+
const w = state.wizard
|
|
126
|
+
if (key.name === "escape") {
|
|
127
|
+
cancelWizard()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (w.step === "provider") {
|
|
131
|
+
const items = wizardProviderItems()
|
|
132
|
+
if (key.name === "up" && items.length) {
|
|
133
|
+
w.index = (w.index - 1 + items.length) % items.length
|
|
134
|
+
renderWizard()
|
|
135
|
+
} else if (key.name === "down" && items.length) {
|
|
136
|
+
w.index = (w.index + 1) % items.length
|
|
137
|
+
renderWizard()
|
|
138
|
+
} else if (key.name === "return" && items.length) {
|
|
139
|
+
wizardChooseProvider(items[w.index])
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (key.name === "return") {
|
|
144
|
+
wizardSubmitText()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
// 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
|
|
148
|
+
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 翻页
|
|
152
|
+
if (key.name === "pageup") {
|
|
153
|
+
state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
|
|
154
|
+
render()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (key.name === "pagedown") {
|
|
158
|
+
state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
|
|
159
|
+
render()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (state.processing) {
|
|
164
|
+
// 处理中允许输入(排队),但屏蔽方向键历史和 Tab 补全
|
|
165
|
+
if (key.name === "tab" || key.name === "up" || key.name === "down") return
|
|
166
|
+
// Ctrl+D:删除队列中最后一条
|
|
167
|
+
if (key.ctrl && key.name === "d") {
|
|
168
|
+
if (state.queue.length > 0) {
|
|
169
|
+
state.queue.pop()
|
|
170
|
+
render()
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
// 其余可打印字符正常进入输入框
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Tab:斜杠Commands补全 (循环候选);其余输入忽略 (\t 会顶破输入框,永不直接插入)
|
|
178
|
+
if (key.name === "tab") {
|
|
179
|
+
handleTab()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 输入历史
|
|
184
|
+
if (key.name === "up") {
|
|
185
|
+
if (state.history.length) {
|
|
186
|
+
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
187
|
+
state.input = [...state.history[state.historyIndex]]
|
|
188
|
+
state.cursor = state.input.length
|
|
189
|
+
render()
|
|
190
|
+
}
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (key.name === "down") {
|
|
194
|
+
if (state.historyIndex !== -1) {
|
|
195
|
+
state.historyIndex++
|
|
196
|
+
if (state.historyIndex >= state.history.length) {
|
|
197
|
+
state.historyIndex = -1
|
|
198
|
+
state.input = []
|
|
199
|
+
} else {
|
|
200
|
+
state.input = [...state.history[state.historyIndex]]
|
|
201
|
+
}
|
|
202
|
+
state.cursor = state.input.length
|
|
203
|
+
render()
|
|
204
|
+
}
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 光标移动
|
|
209
|
+
if (key.name === "left") {
|
|
210
|
+
state.cursor = Math.max(0, state.cursor - 1)
|
|
211
|
+
render()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (key.name === "right") {
|
|
215
|
+
state.cursor = Math.min(state.input.length, state.cursor + 1)
|
|
216
|
+
render()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (key.name === "home") {
|
|
220
|
+
state.cursor = 0
|
|
221
|
+
render()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (key.name === "end") {
|
|
225
|
+
state.cursor = state.input.length
|
|
226
|
+
render()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 编辑
|
|
231
|
+
if (key.name === "backspace") {
|
|
232
|
+
if (state.cursor > 0) {
|
|
233
|
+
state.input.splice(state.cursor - 1, 1)
|
|
234
|
+
state.cursor--
|
|
235
|
+
render()
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (key.name === "delete") {
|
|
240
|
+
if (state.cursor < state.input.length) {
|
|
241
|
+
state.input.splice(state.cursor, 1)
|
|
242
|
+
render()
|
|
243
|
+
}
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
if (key.name === "return") {
|
|
247
|
+
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Ctrl+V (Unix) / Alt+V (Windows):粘贴剪贴板图片 → 存临时文件 → 输入框插入 read_image
|
|
252
|
+
const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
|
|
253
|
+
if (isPasteImage) {
|
|
254
|
+
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 可打印字符 / 粘贴 (str 可能一次多个字符);Tab 一律转成两个空格 (\t 显示宽度不定,会顶破输入框)
|
|
259
|
+
// \r\n 在 Windows raw mode 下可能漏进来冲乱页面
|
|
260
|
+
if (str && !key.ctrl && !key.meta) {
|
|
261
|
+
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
262
|
+
state.input.splice(state.cursor, 0, ...chars)
|
|
263
|
+
state.cursor += chars.length
|
|
264
|
+
render()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|