thincoder 0.6.0 → 0.7.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/src/tui.mjs CHANGED
@@ -206,7 +206,6 @@ export function layoutInput(chars, cursor, width) {
206
206
  return { lines, cursorLine, cursorCol }
207
207
  }
208
208
 
209
- /** 文本按宽度折行(保留 \n),返回行数组 */
210
209
  /**
211
210
  * 显示净化:控制字符会破坏终端网格数学(\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
212
211
  * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
@@ -222,6 +221,7 @@ export function sanitizeDisplay(s) {
222
221
  .replace(/\n+$/, "")
223
222
  }
224
223
 
224
+ /** 文本按宽度折行(保留 \n),返回行数组 */
225
225
  export function wrapText(text, width) {
226
226
  const lines = []
227
227
  for (const rawLine of text.split("\n")) {
@@ -406,9 +406,17 @@ export async function startTUI(agent, opts = {}) {
406
406
  let boxLines = inputLines
407
407
  if (state.question) {
408
408
  const q = state.question
409
- boxLines = q.options.length > 0
410
- ? q.options.map((opt, i) => (i === (q.selected ?? 0) ? "▸ " : " ") + opt)
411
- : ["▸ " + (q.answer ?? "")]
409
+ if (q.options.length > 0) {
410
+ // 选项窗口:只显示选中项 ±2,选项过多时防输入框无限增高撑破锚定布局
411
+ const sel = q.selected ?? 0
412
+ const QWIN = 5
413
+ const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
414
+ boxLines = q.options
415
+ .slice(start, start + QWIN)
416
+ .map((opt, i) => (start + i === sel ? "▸ " : " ") + opt)
417
+ } else {
418
+ boxLines = ["▸ " + (q.answer ?? "")]
419
+ }
412
420
  }
413
421
  const inputBoxH = boxLines.length + 2
414
422
 
@@ -434,8 +442,18 @@ export async function startTUI(agent, opts = {}) {
434
442
  const taskPanelH = visibleTasks.length
435
443
  // 子 agent 流式输出占位(显示时占最多 2 行)
436
444
  const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
437
- // 权限预览占位
438
- const permPreviewLen = state.permission ? 1 + state.permissionPreview.reduce((s, l) => s + wrapText(` ${l}`, W - 1).length, 0) : 0
445
+ // 权限预览占位:字符数之外再封顶显示行数(rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
446
+ let permPreviewLines = []
447
+ if (state.permission) {
448
+ const maxLines = Math.max(1, rows - 8)
449
+ outer: for (const l of state.permissionPreview) {
450
+ for (const wrapped of wrapText(` ${l}`, W - 1)) {
451
+ if (permPreviewLines.length >= maxLines) break outer
452
+ permPreviewLines.push(wrapped)
453
+ }
454
+ }
455
+ }
456
+ const permPreviewLen = state.permission ? 1 + permPreviewLines.length : 0
439
457
  const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
440
458
 
441
459
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
@@ -519,13 +537,11 @@ export async function startTUI(agent, opts = {}) {
519
537
  }
520
538
  }
521
539
 
522
- // 权限审批内容预览(黄色,紧挨输入框上方)
540
+ // 权限审批内容预览(黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
523
541
  if (state.permission) {
524
542
  out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
525
- for (const line of state.permissionPreview) {
526
- for (const wrapped of wrapText(` ${line}`, W - 1)) {
527
- out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
528
- }
543
+ for (const wrapped of permPreviewLines) {
544
+ out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
529
545
  }
530
546
  }
531
547
 
@@ -534,7 +550,7 @@ export async function startTUI(agent, opts = {}) {
534
550
  let title
535
551
  if (state.question) {
536
552
  borderColor = C.tool
537
- title = ` ${sliceByWidth(state.question.text, W - 6)} `
553
+ title = " Question "
538
554
  } else if (state.permission) {
539
555
  borderColor = C.warn
540
556
  if (state.permission.name === "continue") {
@@ -697,8 +713,8 @@ export async function startTUI(agent, opts = {}) {
697
713
 
698
714
  const callbacks = {
699
715
  onToken: (t) => {
700
- // 子 agent 流式输出:前缀匹配 explore/coder/plan token 进 subOutput
701
- const subMatch = t.match(/^(explore|coder|plan)\//)
716
+ // 子 agent 流式输出:前缀匹配 explore/coder/plan/sub(无角色子 agent 用 sub/)的 token 进 subOutput
717
+ const subMatch = t.match(/^(explore|coder|plan|sub)\//)
702
718
  if (subMatch) {
703
719
  state.currentSub = subMatch[1]
704
720
  state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
@@ -710,6 +726,14 @@ export async function startTUI(agent, opts = {}) {
710
726
  scheduleRender()
711
727
  },
712
728
  onReasoning: (t) => {
729
+ // 子 agent 的思考 token 同样带 role/ 前缀,进 subOutput 滚动区,不污染主思考流
730
+ const subMatch = t.match(/^(explore|coder|plan|sub)\//)
731
+ if (subMatch) {
732
+ state.currentSub = subMatch[1]
733
+ state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
734
+ scheduleRender()
735
+ return
736
+ }
713
737
  ensureAssistantLabel()
714
738
  state.reasoning += t
715
739
  scheduleRender()
@@ -722,8 +746,9 @@ export async function startTUI(agent, opts = {}) {
722
746
  },
723
747
  onToolResult: (name, result) => {
724
748
  state.currentTool = null
725
- // 子 agent 结束:清空流式缓冲,报告进对话区
726
- const isSubagent = name.startsWith("explore/") || name.startsWith("coder/") || name.startsWith("plan/")
749
+ // 子 agent 结束(父 agent 侧的 subagent 工具结果带着最终报告):清空流式缓冲,报告进对话区。
750
+ // 注意只能用精确匹配——子 agent 内部工具调用不 relay TUI(刷了满屏的教训)
751
+ const isSubagent = name === "subagent"
727
752
  if (isSubagent) {
728
753
  state.subOutput = ""
729
754
  state.currentSub = null
@@ -877,6 +902,10 @@ export async function startTUI(agent, opts = {}) {
877
902
  ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
878
903
  ]
879
904
  }
905
+ if (base === "apply_patch") {
906
+ // 补丁本身就是可读的 diff,直接预览
907
+ return cap(args.patch ?? "", 1500).split("\n")
908
+ }
880
909
  if (base === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
881
910
  if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
882
911
  if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
@@ -917,6 +946,7 @@ export async function startTUI(agent, opts = {}) {
917
946
  { name: "/model", group: "Agent", desc: "选择模型" },
918
947
  { name: "/goal", group: "Agent", desc: "设置/查看/取消长期目标" },
919
948
  { name: "/think", group: "Agent", desc: "思维模式与推理强度" },
949
+ { name: "/init", group: "Tools", desc: "生成项目 AGENTS.md 骨架" },
920
950
  { name: "/skills", group: "Tools", desc: "列出项目技能" },
921
951
  { name: "/mcp", group: "Tools", desc: "管理 MCP server" },
922
952
  { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
@@ -1032,6 +1062,74 @@ export async function startTUI(agent, opts = {}) {
1032
1062
  case "/distill":
1033
1063
  await runDistill()
1034
1064
  return
1065
+ case "/init": {
1066
+ const { existsSync } = await import("node:fs")
1067
+ const { writeFile, readFile } = await import("node:fs/promises")
1068
+ const { join, basename } = await import("node:path")
1069
+ const agPath = join(agent.cwd, "AGENTS.md")
1070
+ if (existsSync(agPath)) {
1071
+ pushLine(`AGENTS.md 已存在: ${agPath}`, C.warn)
1072
+ return
1073
+ }
1074
+
1075
+ // 探测项目类型与关键信息
1076
+ let name = basename(agent.cwd)
1077
+ let lang = "", cmds = ""
1078
+
1079
+ // Node.js
1080
+ try {
1081
+ const pkg = JSON.parse(await readFile(join(agent.cwd, "package.json"), "utf8"))
1082
+ if (pkg.name) name = pkg.name
1083
+ lang = "Node.js"
1084
+ const ks = Object.keys(pkg.scripts ?? {})
1085
+ if (ks.length) cmds = ks.slice(0, 5).map(k => `- \`npm run ${k}\``).join("\n")
1086
+ } catch {}
1087
+
1088
+ // Python
1089
+ if (!lang) {
1090
+ for (const f of ["requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"]) {
1091
+ if (existsSync(join(agent.cwd, f))) { lang = "Python"; break }
1092
+ }
1093
+ if (lang) cmds = "- `pip install -r requirements.txt`\n- `python -m pytest`"
1094
+ }
1095
+
1096
+ // Go
1097
+ if (!lang) {
1098
+ if (existsSync(join(agent.cwd, "go.mod"))) {
1099
+ lang = "Go"
1100
+ cmds = "- `go build ./...`\n- `go test ./...`"
1101
+ }
1102
+ }
1103
+
1104
+ // Rust
1105
+ if (!lang) {
1106
+ if (existsSync(join(agent.cwd, "Cargo.toml"))) {
1107
+ lang = "Rust"
1108
+ cmds = "- `cargo build`\n- `cargo test`"
1109
+ }
1110
+ }
1111
+
1112
+ // Java / Kotlin
1113
+ if (!lang) {
1114
+ if (existsSync(join(agent.cwd, "pom.xml"))) { lang = "Java (Maven)"; cmds = "- `mvn test`" }
1115
+ else if (existsSync(join(agent.cwd, "build.gradle")) || existsSync(join(agent.cwd, "build.gradle.kts"))) {
1116
+ lang = "Java/Kotlin (Gradle)"; cmds = "- `gradle test`"
1117
+ }
1118
+ }
1119
+
1120
+ const lines = [`# ${name}`, ""]
1121
+ if (lang) {
1122
+ lines.push(`## 技术栈`, "", lang, "")
1123
+ if (cmds) lines.push(`## 命令`, "", cmds, "")
1124
+ }
1125
+
1126
+ const template = lines.join("\n")
1127
+ await writeFile(agPath, template, "utf8")
1128
+ pushLabel(`❯ Init`, ansi.bold + C.tool)
1129
+ pushLine(`已生成 AGENTS.md → ${agPath}${lang ? ` (${lang})` : ""}`, C.tool)
1130
+ if (lang) pushLine("可继续告诉我项目信息,我来补充约定和结构", C.dim)
1131
+ return
1132
+ }
1035
1133
  case "/rewind": {
1036
1134
  const { listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
1037
1135
  if (!isGitRepo(agent.cwd)) {
@@ -1132,60 +1230,52 @@ export async function startTUI(agent, opts = {}) {
1132
1230
  const servers = agent.config?.mcp?.servers ?? []
1133
1231
  pushLabel(`❯ MCP Servers`, ansi.bold + C.tool)
1134
1232
  if (servers.length === 0) {
1135
- pushLine("(无 MCP server——使用 /mcp add <name> <command> [args] 添加)", C.dim)
1233
+ pushLine("(无 MCP server——使用 /mcp add <name> <url|command> 添加)", C.dim)
1136
1234
  }
1137
1235
  for (const srv of servers) {
1138
1236
  const connected = agent.tools.some((t) => t._mcpName === srv.name)
1139
1237
  const mark = connected ? "●" : "○"
1140
1238
  const color = connected ? C.tool : C.dim
1141
1239
  const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
1142
- const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1240
+ const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1143
1241
  pushLine(` ${mark} ${srv.name}: ${desc} (${toolCount} tools)`, color)
1144
1242
  }
1145
1243
  pushLabel(`❯ 操作`, ansi.bold + C.tool)
1146
- pushLine(`/mcp add <name> <command> [args...] 添加 stdio server`, C.dim)
1147
- pushLine(`/mcp url <name> <url> [headers...] 添加 HTTP server`, C.dim)
1148
- pushLine(`/mcp remove <name> 断开并移除 server`, C.dim)
1149
- pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1150
- pushLine("配置持久化到 config.json mcp.servers[]", C.dim)
1244
+ pushLine("/mcp add <name> <url|command> [args|headers...]", C.dim)
1245
+ pushLine(" URL 自动识别: https://… HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1246
+ pushLine(" 例: /mcp add myapi https://api.example.com/mcp Authorization=\"Bearer x\"", C.dim)
1247
+ pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1248
+ pushLine(`/mcp remove <name> 断开并移除`, C.dim)
1249
+ pushLine(`/mcp connect <name> 重连已配置的 server`, C.dim)
1151
1250
  return
1152
1251
  }
1153
- // ---- /mcp add <name> <command> [args...] (stdio) ----
1154
- if (sub === "add") {
1252
+ // ---- /mcp add <name> <url|command> [args|headers...] (统一入口,自动识别传输类型) ----
1253
+ // url / ws 子命令作为别名保留(兼容旧配置)
1254
+ if (sub === "add" || sub === "url" || sub === "ws") {
1155
1255
  const args = rest.slice(1)
1156
1256
  if (args.length < 2) {
1157
- pushLine("用法: /mcp add <name> <command> [args...]", C.error)
1158
- pushLine(" 例: /mcp add github npx -y @modelcontextprotocol/server-github", C.dim)
1257
+ pushLine("用法: /mcp add <name> <url|command> [args|headers...]", C.error)
1258
+ pushLine(" URL 自动识别: https://… HTTP, ws://… → WebSocket, 其他 → stdio 命令", C.dim)
1159
1259
  return
1160
1260
  }
1161
1261
  const name = args[0]
1162
- const command = args[1]
1163
- const cmdArgs = args.slice(2)
1262
+ const second = args[1]
1263
+ const extras = args.slice(2)
1164
1264
  const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1165
1265
  if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1166
- const srv = { name, command, args: cmdArgs.length > 0 ? cmdArgs : undefined }
1167
- await addAndConnect(srv)
1168
- return
1169
- }
1170
- // ---- /mcp url <name> <url> [key=value...] (HTTP) ----
1171
- if (sub === "url") {
1172
- const args = rest.slice(1)
1173
- if (args.length < 2) {
1174
- pushLine("用法: /mcp url <name> <url> [header=value...]", C.error)
1175
- pushLine(" 例: /mcp url myapi https://api.example.com/mcp Authorization=\"Bearer token123\"", C.dim)
1176
- return
1177
- }
1178
- const name = args[0]
1179
- const url = args[1]
1180
- const headerPairs = args.slice(2)
1181
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1182
- if (existing) { pushLine(`[mcp] "${name}" 已存在,用 /mcp remove ${name} 先移除`, C.error); return }
1183
- const headers = {}
1184
- for (const pair of headerPairs) {
1185
- const eq = pair.indexOf("=")
1186
- if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
1266
+
1267
+ const isWS = /^wss?:\/\//.test(second)
1268
+ const isHTTP = /^https?:\/\//.test(second)
1269
+ let srv
1270
+ if (isWS || sub === "ws") {
1271
+ const headers = parseHeaders(extras)
1272
+ srv = { name, wsUrl: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1273
+ } else if (isHTTP || sub === "url") {
1274
+ const headers = parseHeaders(extras)
1275
+ srv = { name, url: second, headers: Object.keys(headers).length > 0 ? headers : undefined }
1276
+ } else {
1277
+ srv = { name, command: second, args: extras.length > 0 ? extras : undefined }
1187
1278
  }
1188
- const srv = { name, url, headers: Object.keys(headers).length > 0 ? headers : undefined }
1189
1279
  await addAndConnect(srv)
1190
1280
  return
1191
1281
  }
@@ -1206,7 +1296,7 @@ export async function startTUI(agent, opts = {}) {
1206
1296
  const name = rest[1]
1207
1297
  if (!name) { pushLine("用法: /mcp connect <name>", C.error); return }
1208
1298
  const srv = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
1209
- if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add /mcp url)`, C.error); return }
1299
+ if (!srv) { pushLine(`[mcp] "${name}" 未在配置中找到(先用 /mcp add 添加)`, C.error); return }
1210
1300
  const { removeMcpTools, connectMcpServer } = await import("./mcp.mjs")
1211
1301
  removeMcpTools(agent, name)
1212
1302
  try {
@@ -1220,16 +1310,27 @@ export async function startTUI(agent, opts = {}) {
1220
1310
  }
1221
1311
  return
1222
1312
  }
1223
- pushLine(`未知子命令: ${sub}(/mcp list | add | url | remove | connect)`, C.error)
1313
+ pushLine(`未知子命令: ${sub}(/mcp list | add | remove | connect)`, C.error)
1224
1314
  return
1225
1315
  }
1226
1316
 
1317
+ // ---- header 解析(/mcp add 共享)----
1318
+ function parseHeaders(pairs) {
1319
+ const headers = {}
1320
+ for (const pair of pairs) {
1321
+ const eq = pair.indexOf("=")
1322
+ if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
1323
+ }
1324
+ return headers
1325
+ }
1326
+
1227
1327
  // ---- /mcp 共享 helper: 保存配置 + 连接 ----
1228
1328
  async function addAndConnect(srv) {
1229
1329
  await persistRaw((raw) => {
1230
1330
  raw.mcp ??= { servers: [] }
1231
1331
  const entry = { name: srv.name }
1232
1332
  if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
1333
+ else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.headers) entry.headers = srv.headers }
1233
1334
  else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
1234
1335
  raw.mcp.servers.push(entry)
1235
1336
  })
@@ -1242,7 +1343,7 @@ export async function startTUI(agent, opts = {}) {
1242
1343
  const tools = await connectMcpServer(srv)
1243
1344
  agent.tools.push(...tools)
1244
1345
  pushLabel(`❯ MCP`, ansi.bold + C.tool)
1245
- const desc = srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1346
+ const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
1246
1347
  pushLine(`${srv.name} (${desc}) 已连接,${tools.length} 个工具:`, C.tool)
1247
1348
  for (const t of tools) pushLine(` ${t.name}: ${t.description.slice(0, 100)}`, C.dim)
1248
1349
  } catch (error) {
@@ -1557,7 +1658,7 @@ export async function startTUI(agent, opts = {}) {
1557
1658
  if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
1558
1659
  if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
1559
1660
  if (cmd === "/mcp") {
1560
- if (argIndex === 0) return match(["add", "url", "remove", "connect", "list"])
1661
+ if (argIndex === 0) return match(["add", "url", "ws", "remove", "connect", "list"])
1561
1662
  if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
1562
1663
  }
1563
1664
  return []