thincoder 0.4.0 → 0.6.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/src/tui.mjs CHANGED
@@ -10,7 +10,7 @@ import { basename } from "node:path"
10
10
  import { existsSync, readFileSync } from "node:fs"
11
11
  import { runAgent, ContinueError } from "./agent.mjs"
12
12
  import { estimateTokens } from "./context.mjs"
13
- import { saveSession, clearSession } from "./session.mjs"
13
+ import { saveSession, clearSession, archiveCurrent, listSlots, switchToSlot, sessionPath } from "./session.mjs"
14
14
  import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
15
15
  import { closeAllMcp } from "./mcp.mjs"
16
16
 
@@ -207,6 +207,21 @@ export function layoutInput(chars, cursor, width) {
207
207
  }
208
208
 
209
209
  /** 文本按宽度折行(保留 \n),返回行数组 */
210
+ /**
211
+ * 显示净化:控制字符会破坏终端网格数学(\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
212
+ * 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
213
+ */
214
+ const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
215
+ export function sanitizeDisplay(s) {
216
+ return s
217
+ .replace(ANSI_SEQUENCE_RE, "")
218
+ .replace(/\r\n/g, "\n")
219
+ .replace(/\r/g, "\n")
220
+ .replace(/\t/g, " ")
221
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
222
+ .replace(/\n+$/, "")
223
+ }
224
+
210
225
  export function wrapText(text, width) {
211
226
  const lines = []
212
227
  for (const rawLine of text.split("\n")) {
@@ -250,20 +265,28 @@ export async function startTUI(agent, opts = {}) {
250
265
  processing: false,
251
266
  controller: null, // AbortController for current agent run
252
267
  permission: null, // { name, args, resolve }
268
+ permissionPreview: [], // 权限审批的内容预览行(渲染在输入框上方,不分隔)
253
269
  question: null, // { text, options, resolve } — agent 的 question 工具回调
254
270
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
255
271
  wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
256
- tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上
272
+ tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上,全完成自动收起
257
273
  tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
258
274
  ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
259
275
  reasoning: "", // 思考流缓冲(暗色展示)
260
276
  completion: null, // Tab 补全状态 { candidates, index }
261
277
  toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
278
+ subOutput: "", // 子 agent 流式输出(滚动显示,最长保留末尾 300 字符)
279
+ currentSub: null, // 当前活跃的子 agent 角色名
262
280
  currentTool: null, // 正在执行的工具名(状态栏显示)
263
281
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
264
282
  status: "Ready",
265
283
  }
266
284
 
285
+ // 恢复的会话如果所有任务已完成,自动收起 todo 面板(对齐运行时行为)
286
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
287
+ state.tasks = []
288
+ }
289
+
267
290
  // 输入流先过一道滤网:鼠标序列(滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
268
291
  // 防止序列残片(如 "64;72;42M")漏进输入框
269
292
  const keyStream = new PassThrough()
@@ -301,10 +324,14 @@ export async function startTUI(agent, opts = {}) {
301
324
  if (text) keyStream.write(text)
302
325
  })
303
326
 
327
+ let cleanedUp = false
304
328
  const cleanup = () => {
305
- // 退出前保存会话(同步写,保证 exit 路径也能落盘)
329
+ if (cleanedUp) return
330
+ cleanedUp = true
331
+ // 退出前保存会话(同步写);先归档当前到槽位,再落新——不丢
306
332
  try {
307
- saveSession(agent)
333
+ archiveCurrent(agent.cwd)
334
+ saveSession(agent, state.lines)
308
335
  } catch {
309
336
  // 存失败不耽误退出
310
337
  }
@@ -405,12 +432,16 @@ export async function startTUI(agent, opts = {}) {
405
432
  visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
406
433
  }
407
434
  const taskPanelH = visibleTasks.length
408
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH)
435
+ // agent 流式输出占位(显示时占最多 2 行)
436
+ 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
439
+ const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
409
440
 
410
441
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
411
442
  const convLines = []
412
443
  for (const l of state.lines) {
413
- for (const line of formatTables(l.text, cols - 1)) {
444
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
414
445
  for (const wrapped of wrapText(line, cols - 1)) {
415
446
  convLines.push({ text: wrapped, color: l.color })
416
447
  }
@@ -418,12 +449,12 @@ export async function startTUI(agent, opts = {}) {
418
449
  }
419
450
  // 思考流(暗色)在正文流之前
420
451
  if (state.reasoning) {
421
- for (const wrapped of wrapText(state.reasoning, cols - 1)) {
452
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
422
453
  convLines.push({ text: wrapped, color: C.reason })
423
454
  }
424
455
  }
425
456
  if (state.streaming) {
426
- for (const line of formatTables(state.streaming, cols - 1)) {
457
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
427
458
  for (const wrapped of wrapText(line, cols - 1)) {
428
459
  convLines.push({ text: wrapped, color: C.text })
429
460
  }
@@ -432,7 +463,7 @@ export async function startTUI(agent, opts = {}) {
432
463
  // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
433
464
  const allStreams = Object.values(state.toolStreams).join("")
434
465
  if (allStreams) {
435
- const tail = allStreams.slice(-4000)
466
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
436
467
  for (const wrapped of wrapText(tail, cols - 1)) {
437
468
  convLines.push({ text: wrapped, color: C.dim })
438
469
  }
@@ -479,6 +510,25 @@ export async function startTUI(agent, opts = {}) {
479
510
  out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
480
511
  }
481
512
 
513
+ // 子 agent 流式输出(最多 2 行,滚动显示最新内容)
514
+ if (state.subOutput && state.processing) {
515
+ const lines = wrapText(state.subOutput, W - 8)
516
+ const tail = lines.slice(-2)
517
+ for (const l of tail) {
518
+ out.push(`${C.dim}[${state.currentSub}] ${l}${ansi.reset}${ansi.clearLine}`)
519
+ }
520
+ }
521
+
522
+ // 权限审批内容预览(黄色,紧挨输入框上方)
523
+ if (state.permission) {
524
+ 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
+ }
529
+ }
530
+ }
531
+
482
532
  // 输入框(全边框,宽 W)
483
533
  let borderColor = C.tool
484
534
  let title
@@ -552,7 +602,7 @@ export async function startTUI(agent, opts = {}) {
552
602
  }
553
603
  } else {
554
604
  const taskHint = state.tasks.length > 0
555
- ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
605
+ ? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
556
606
  : ""
557
607
  // token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
558
608
  const tk = state.tokens
@@ -595,7 +645,7 @@ export async function startTUI(agent, opts = {}) {
595
645
  if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
596
646
  process.stdout.write(ansi.hideCursor)
597
647
  } else {
598
- const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
648
+ const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
599
649
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
600
650
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
601
651
  }
@@ -647,6 +697,14 @@ export async function startTUI(agent, opts = {}) {
647
697
 
648
698
  const callbacks = {
649
699
  onToken: (t) => {
700
+ // 子 agent 流式输出:前缀匹配 explore/coder/plan 的 token 进 subOutput
701
+ const subMatch = t.match(/^(explore|coder|plan)\//)
702
+ if (subMatch) {
703
+ state.currentSub = subMatch[1]
704
+ state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
705
+ scheduleRender()
706
+ return
707
+ }
650
708
  ensureAssistantLabel()
651
709
  state.streaming += t
652
710
  scheduleRender()
@@ -664,14 +722,27 @@ export async function startTUI(agent, opts = {}) {
664
722
  },
665
723
  onToolResult: (name, result) => {
666
724
  state.currentTool = null
725
+ // 子 agent 结束:清空流式缓冲,报告进对话区
726
+ const isSubagent = name.startsWith("explore/") || name.startsWith("coder/") || name.startsWith("plan/")
727
+ if (isSubagent) {
728
+ state.subOutput = ""
729
+ state.currentSub = null
730
+ // 子 agent 报告摘要(最多 8 行)直接展示在对话区
731
+ const lines = result.split("\n")
732
+ const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
733
+ if (preview) pushLine(preview, C.dim)
734
+ if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
735
+ }
667
736
  const stream = state.toolStreams[name]
668
737
  if (stream) {
669
738
  const tail = stream.trimEnd().slice(-4000)
670
739
  if (tail) pushLine(tail, C.dim)
671
740
  delete state.toolStreams[name]
672
741
  }
673
- const first = result.split("\n")[0]
674
- pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
742
+ if (!isSubagent) {
743
+ const first = result.split("\n")[0]
744
+ pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
745
+ }
675
746
  },
676
747
  onToolOutput: (name, chunk) => {
677
748
  state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
@@ -696,6 +767,14 @@ export async function startTUI(agent, opts = {}) {
696
767
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
697
768
  render()
698
769
  },
770
+ // 增量保存:每 5 个工具 turn 落一次盘,中途崩溃丢失窗口从一整轮缩到几轮
771
+ onTurnEnd: (() => {
772
+ let n = 0
773
+ return () => {
774
+ if (++n % 5 !== 0) return
775
+ try { saveSession(agent, state.lines) } catch {}
776
+ }
777
+ })(),
699
778
  }
700
779
 
701
780
  for (let resume = false; ; resume = true) {
@@ -747,7 +826,7 @@ export async function startTUI(agent, opts = {}) {
747
826
  }
748
827
  // 每轮结束后保存会话(崩溃也不丢)
749
828
  try {
750
- saveSession(agent)
829
+ saveSession(agent, state.lines)
751
830
  } catch {
752
831
  // 存失败不打断使用
753
832
  }
@@ -771,10 +850,8 @@ export async function startTUI(agent, opts = {}) {
771
850
  pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
772
851
  return Promise.resolve(true)
773
852
  }
774
- // 把关键参数摆出来:批什么要让人看明白
775
- // 内容行用警告色——与正常输出(白)区分,滚动回看也能认出这是待审批内容
776
- pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
777
- for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.warn)
853
+ // 预览内容存到 permissionPreview,渲染在输入框上方紧挨"Allow?"提示
854
+ state.permissionPreview = formatPermission(name, args)
778
855
  return new Promise((resolve) => {
779
856
  state.permission = { name, args, resolve }
780
857
  state.status = `Waiting: ${name}`
@@ -782,15 +859,16 @@ export async function startTUI(agent, opts = {}) {
782
859
  })
783
860
  }
784
861
 
785
- /** 权限请求的关键信息(按工具定制),返回行数组 */
862
+ /** 权限请求的关键信息(按工具定制),返回行数组。name 可能带子 agent 前缀("coder/bash"),取基名匹配 */
786
863
  function formatPermission(name, args) {
787
864
  const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
788
- if (name === "bash") return cap(args.command ?? "").split("\n")
789
- if (name === "write") {
865
+ const base = name.includes("/") ? name.split("/").pop() : name
866
+ if (base === "bash") return cap(args.command ?? "").split("\n")
867
+ if (base === "write") {
790
868
  // 批准写文件必须看得到要写什么:路径 + 内容预览
791
869
  return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`, ...cap(args.content ?? "", 1000).split("\n")]
792
870
  }
793
- if (name === "edit") {
871
+ if (base === "edit") {
794
872
  // 简易 diff:- 旧内容 / + 新内容
795
873
  return [
796
874
  `${args.path}`,
@@ -799,9 +877,9 @@ export async function startTUI(agent, opts = {}) {
799
877
  ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
800
878
  ]
801
879
  }
802
- if (name === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
803
- if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
804
- if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
880
+ if (base === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
881
+ if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
882
+ if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
805
883
  return [cap(summarize(args), 300)]
806
884
  }
807
885
 
@@ -844,7 +922,8 @@ export async function startTUI(agent, opts = {}) {
844
922
  { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
845
923
  { name: "/config", group: "Config", desc: "配置管理(embedding / agent)" },
846
924
  { name: "/reindex", group: "Config", desc: "重建记忆索引" },
847
- { name: "/new", group: "Session", desc: "开始新会话" },
925
+ { name: "/new", group: "Session", desc: "新会话(旧会话归档到槽位)" },
926
+ { name: "/session", group: "Session", desc: "列出/切换归档会话" },
848
927
  { name: "/clear", group: "Session", desc: "清屏" },
849
928
  { name: "/distill", group: "Session", desc: "从会话提取知识" },
850
929
  { name: "/rewind", group: "Session", desc: "回滚到存档点" },
@@ -870,16 +949,52 @@ export async function startTUI(agent, opts = {}) {
870
949
  state.lines = []
871
950
  state.streaming = ""
872
951
  clearSession(agent.cwd)
873
- pushLine("已开始新会话(上一会话已归档)", C.dim)
952
+ pushLine("已开始新会话(旧会话已归档到槽位;/session 可查看)", C.dim)
874
953
  return
875
954
  case "/exit":
876
955
  cleanup()
877
956
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
878
957
  return
958
+ case "/session": {
959
+ const slotNum = Number(rest[0])
960
+ if (rest.length > 0 && !isNaN(slotNum)) {
961
+ // 切换到指定槽位
962
+ const data = switchToSlot(agent.cwd, slotNum)
963
+ if (!data) {
964
+ pushLine(`槽位 ${slotNum} 不存在`, C.dim)
965
+ } else {
966
+ applySession(agent, data)
967
+ state.lines = data.display.length
968
+ ? data.display.map((l) => ({ text: l.text, color: l.color }))
969
+ : []
970
+ state.tasks = agent.tasks ?? []
971
+ // 切换过来的会话如果任务全完成,自动收起面板
972
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
973
+ state.tasks = []
974
+ }
975
+ pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
976
+ render()
977
+ }
978
+ } else {
979
+ // 列出所有槽位
980
+ const slots = listSlots(agent.cwd)
981
+ if (slots.length === 0) {
982
+ pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
983
+ } else {
984
+ pushLabel(`归档会话(/session <n> 切换):`, ansi.bold + C.tool)
985
+ for (const s of slots) {
986
+ pushLine(` 槽位 ${s.slot} — ${s.date}`, C.text)
987
+ }
988
+ }
989
+ }
990
+ return
991
+ }
879
992
  case "/reindex": {
880
- const { syncDir } = await import("./memory.mjs")
993
+ const { syncDir, codeSync, docSync } = await import("./memory.mjs")
881
994
  pushLine("[reindex] 重建索引...", C.tool)
882
995
  agent.memory.db.prepare("DELETE FROM files").run()
996
+ agent.memory.db.prepare("DELETE FROM code_chunks").run()
997
+ agent.memory.db.prepare("DELETE FROM doc_chunks").run()
883
998
  let total = 0
884
999
  if (distillOpts.projectDir) {
885
1000
  const s = await syncDir(agent.memory, { layer: "project", dir: distillOpts.projectDir })
@@ -891,7 +1006,27 @@ export async function startTUI(agent, opts = {}) {
891
1006
  total += s.added
892
1007
  pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
893
1008
  }
894
- pushLine(`[reindex] 完成,共 ${total} 条。向量将在下次搜索时惰性生成。`, C.tool)
1009
+ // 重建代码索引
1010
+ pushLine(` [code] 重建代码索引...`, C.tool)
1011
+ const cr = await codeSync(agent.memory, agent.cwd, {
1012
+ onProgress: (p) => {
1013
+ if (p.phase === "index" && p.current % 20 === 0) {
1014
+ pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
1015
+ }
1016
+ }
1017
+ })
1018
+ pushLine(` code: ${cr.total} 文件,+${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
1019
+ // 重建文档索引
1020
+ pushLine(` [doc] 重建文档索引...`, C.tool)
1021
+ const dr = await docSync(agent.memory, agent.cwd, {
1022
+ onProgress: (p) => {
1023
+ if (p.phase === "index" && p.current % 5 === 0) {
1024
+ pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
1025
+ }
1026
+ }
1027
+ })
1028
+ pushLine(` doc: ${dr.total} 文件,+${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
1029
+ pushLine(`[reindex] 完成,共 ${total} 条目。向量将在下次搜索时惰性生成。`, C.tool)
895
1030
  return
896
1031
  }
897
1032
  case "/distill":
@@ -947,14 +1082,15 @@ export async function startTUI(agent, opts = {}) {
947
1082
  const sub = rest[0]
948
1083
  if (sub === "set") {
949
1084
  const text = rest.slice(1).join(" ")
950
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
1085
+ if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
951
1086
  const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
952
1087
  const objective = semi ? text.slice(0, semi).trim() : text.trim()
953
1088
  const criteria = semi ? text.slice(semi + 1).trim() : ""
954
- agent.goal = { objective, criteria, setAt: Date.now() }
1089
+ agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
955
1090
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
956
1091
  pushLine(`目标已设置: ${objective}`, C.tool)
957
1092
  if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1093
+ else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
958
1094
  return
959
1095
  }
960
1096
  if (sub === "cancel") {
@@ -964,10 +1100,11 @@ export async function startTUI(agent, opts = {}) {
964
1100
  return
965
1101
  }
966
1102
  if (agent.goal) {
1103
+ const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
967
1104
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
968
1105
  pushLine(`目标: ${agent.goal.objective}`, C.tool)
969
1106
  if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
970
- pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1107
+ pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
971
1108
  pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
972
1109
  } else {
973
1110
  pushLabel(`❯ Goal`, ansi.bold + C.dim)
@@ -1143,12 +1280,23 @@ export async function startTUI(agent, opts = {}) {
1143
1280
  // ---- /think on / off ----
1144
1281
  if (sub === "on" || sub === "off") {
1145
1282
  const enable = sub === "on"
1146
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1147
- if (!enable) delete cur.reasoningEffort
1148
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1149
- await syncProviderField("thinking", cur.thinking)
1150
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1151
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1283
+ // 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
1284
+ const { specForModel } = await import("./config.mjs")
1285
+ const spec = specForModel(cur.model)
1286
+ if (spec.thinkApi === "effort") {
1287
+ // 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
1288
+ if (!enable) delete cur.reasoningEffort
1289
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1290
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1291
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1292
+ } else {
1293
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1294
+ if (!enable) delete cur.reasoningEffort
1295
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1296
+ await syncProviderField("thinking", cur.thinking)
1297
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1298
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1299
+ }
1152
1300
  pushLabel(`❯ Think`, ansi.bold + C.tool)
1153
1301
  pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1154
1302
  if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
@@ -1781,6 +1929,7 @@ export async function startTUI(agent, opts = {}) {
1781
1929
  if (validKeys.includes(answer) || key.name === "escape") {
1782
1930
  const { resolve, name } = state.permission
1783
1931
  state.permission = null
1932
+ state.permissionPreview = []
1784
1933
  state.status = "Processing..."
1785
1934
  if (answer === "a" && !isContinue) {
1786
1935
  agent.autoApprove = true
@@ -1993,8 +2142,9 @@ export async function startTUI(agent, opts = {}) {
1993
2142
  }
1994
2143
 
1995
2144
  // 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
2145
+ // \r\n 在 Windows raw mode 下可能漏进来冲乱页面
1996
2146
  if (str && !key.ctrl && !key.meta) {
1997
- const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
2147
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
1998
2148
  state.input.splice(state.cursor, 0, ...chars)
1999
2149
  state.cursor += chars.length
2000
2150
  render()
@@ -2011,23 +2161,75 @@ export async function startTUI(agent, opts = {}) {
2011
2161
  }
2012
2162
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
2013
2163
  // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
2014
- if (opts.restored?.history?.length) {
2015
- for (const m of opts.restored.history) {
2164
+ if (opts.restored?.display?.length) {
2165
+ // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
2166
+ state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
2167
+ pushLabel(`── 已恢复上次会话(退出前原样回放);/new 开始新会话 ──`, C.warn)
2168
+ } else if (opts.restored?.history?.length) {
2169
+ // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
2170
+ for (let i = 0; i < opts.restored.history.length; i++) {
2171
+ const m = opts.restored.history[i]
2016
2172
  if (m.role === "user") {
2173
+ if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
2017
2174
  pushLabel(`❯ You:`, ansi.bold + C.user)
2018
2175
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2019
2176
  } else if (m.role === "assistant") {
2020
2177
  pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
2021
2178
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2022
2179
  for (const tc of m.tool_calls ?? []) {
2023
- pushLine(` [tool] ${tc.function?.name ?? "?"}`, C.tool)
2180
+ // 找到下一条对应的 tool 结果,显示首行摘要
2181
+ const toolResult = opts.restored.history[i + 1]
2182
+ const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
2183
+ const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
2184
+ pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
2024
2185
  }
2025
2186
  }
2026
- // tool 角色的结果行省略:调用行已足够还原现场
2187
+ // tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
2027
2188
  }
2028
2189
  pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
2029
2190
  }
2191
+ // 有归档槽位时给个提示
2192
+ if (listSlots(agent.cwd).length > 0) {
2193
+ pushLine("提示:存在归档会话,/session 可查看/切换", C.dim)
2194
+ }
2030
2195
  render()
2196
+
2197
+ // 后台索引(进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
2198
+ ;(async () => {
2199
+ const { codeSync, docSync } = await import("./memory.mjs")
2200
+ const cwd = agent.cwd
2201
+ let codeFiles = 0, docFiles = 0
2202
+ try {
2203
+ state.status = "Indexing code..."
2204
+ render()
2205
+ await codeSync(agent.memory, cwd, {
2206
+ onProgress: (p) => {
2207
+ if (p.phase === "index" && p.current % 30 === 0) {
2208
+ state.status = `Indexing code... ${p.current}/${p.total}`
2209
+ render()
2210
+ }
2211
+ }
2212
+ })
2213
+ codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
2214
+ } catch { /* 不阻塞 */ }
2215
+ try {
2216
+ state.status = "Indexing docs..."
2217
+ render()
2218
+ await docSync(agent.memory, cwd, {
2219
+ onProgress: (p) => {
2220
+ if (p.phase === "index" && p.current % 10 === 0) {
2221
+ state.status = `Indexing docs... ${p.current}/${p.total}`
2222
+ render()
2223
+ }
2224
+ }
2225
+ })
2226
+ docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
2227
+ } catch { /* 不阻塞 */ }
2228
+ state.status = codeFiles || docFiles
2229
+ ? `Ready — idx code ${codeFiles} doc ${docFiles}`
2230
+ : "Ready"
2231
+ render()
2232
+ })()
2031
2233
  }
2032
2234
 
2033
2235
  function summarize(obj) {