thincoder 0.4.0 → 0.5.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")) {
@@ -301,10 +316,14 @@ export async function startTUI(agent, opts = {}) {
301
316
  if (text) keyStream.write(text)
302
317
  })
303
318
 
319
+ let cleanedUp = false
304
320
  const cleanup = () => {
305
- // 退出前保存会话(同步写,保证 exit 路径也能落盘)
321
+ if (cleanedUp) return
322
+ cleanedUp = true
323
+ // 退出前保存会话(同步写);先归档当前到槽位,再落新——不丢
306
324
  try {
307
- saveSession(agent)
325
+ archiveCurrent(agent.cwd)
326
+ saveSession(agent, state.lines)
308
327
  } catch {
309
328
  // 存失败不耽误退出
310
329
  }
@@ -410,7 +429,7 @@ export async function startTUI(agent, opts = {}) {
410
429
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
411
430
  const convLines = []
412
431
  for (const l of state.lines) {
413
- for (const line of formatTables(l.text, cols - 1)) {
432
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
414
433
  for (const wrapped of wrapText(line, cols - 1)) {
415
434
  convLines.push({ text: wrapped, color: l.color })
416
435
  }
@@ -418,12 +437,12 @@ export async function startTUI(agent, opts = {}) {
418
437
  }
419
438
  // 思考流(暗色)在正文流之前
420
439
  if (state.reasoning) {
421
- for (const wrapped of wrapText(state.reasoning, cols - 1)) {
440
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
422
441
  convLines.push({ text: wrapped, color: C.reason })
423
442
  }
424
443
  }
425
444
  if (state.streaming) {
426
- for (const line of formatTables(state.streaming, cols - 1)) {
445
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
427
446
  for (const wrapped of wrapText(line, cols - 1)) {
428
447
  convLines.push({ text: wrapped, color: C.text })
429
448
  }
@@ -432,7 +451,7 @@ export async function startTUI(agent, opts = {}) {
432
451
  // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
433
452
  const allStreams = Object.values(state.toolStreams).join("")
434
453
  if (allStreams) {
435
- const tail = allStreams.slice(-4000)
454
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
436
455
  for (const wrapped of wrapText(tail, cols - 1)) {
437
456
  convLines.push({ text: wrapped, color: C.dim })
438
457
  }
@@ -595,7 +614,7 @@ export async function startTUI(agent, opts = {}) {
595
614
  if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
596
615
  process.stdout.write(ansi.hideCursor)
597
616
  } else {
598
- const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
617
+ const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
599
618
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
600
619
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
601
620
  }
@@ -696,6 +715,14 @@ export async function startTUI(agent, opts = {}) {
696
715
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
697
716
  render()
698
717
  },
718
+ // 增量保存:每 5 个工具 turn 落一次盘,中途崩溃丢失窗口从一整轮缩到几轮
719
+ onTurnEnd: (() => {
720
+ let n = 0
721
+ return () => {
722
+ if (++n % 5 !== 0) return
723
+ try { saveSession(agent, state.lines) } catch {}
724
+ }
725
+ })(),
699
726
  }
700
727
 
701
728
  for (let resume = false; ; resume = true) {
@@ -747,7 +774,7 @@ export async function startTUI(agent, opts = {}) {
747
774
  }
748
775
  // 每轮结束后保存会话(崩溃也不丢)
749
776
  try {
750
- saveSession(agent)
777
+ saveSession(agent, state.lines)
751
778
  } catch {
752
779
  // 存失败不打断使用
753
780
  }
@@ -782,15 +809,16 @@ export async function startTUI(agent, opts = {}) {
782
809
  })
783
810
  }
784
811
 
785
- /** 权限请求的关键信息(按工具定制),返回行数组 */
812
+ /** 权限请求的关键信息(按工具定制),返回行数组。name 可能带子 agent 前缀("coder/bash"),取基名匹配 */
786
813
  function formatPermission(name, args) {
787
814
  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") {
815
+ const base = name.includes("/") ? name.split("/").pop() : name
816
+ if (base === "bash") return cap(args.command ?? "").split("\n")
817
+ if (base === "write") {
790
818
  // 批准写文件必须看得到要写什么:路径 + 内容预览
791
819
  return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`, ...cap(args.content ?? "", 1000).split("\n")]
792
820
  }
793
- if (name === "edit") {
821
+ if (base === "edit") {
794
822
  // 简易 diff:- 旧内容 / + 新内容
795
823
  return [
796
824
  `${args.path}`,
@@ -799,9 +827,9 @@ export async function startTUI(agent, opts = {}) {
799
827
  ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
800
828
  ]
801
829
  }
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")]
830
+ if (base === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
831
+ if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
832
+ if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
805
833
  return [cap(summarize(args), 300)]
806
834
  }
807
835
 
@@ -844,7 +872,8 @@ export async function startTUI(agent, opts = {}) {
844
872
  { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
845
873
  { name: "/config", group: "Config", desc: "配置管理(embedding / agent)" },
846
874
  { name: "/reindex", group: "Config", desc: "重建记忆索引" },
847
- { name: "/new", group: "Session", desc: "开始新会话" },
875
+ { name: "/new", group: "Session", desc: "新会话(旧会话归档到槽位)" },
876
+ { name: "/session", group: "Session", desc: "列出/切换归档会话" },
848
877
  { name: "/clear", group: "Session", desc: "清屏" },
849
878
  { name: "/distill", group: "Session", desc: "从会话提取知识" },
850
879
  { name: "/rewind", group: "Session", desc: "回滚到存档点" },
@@ -870,16 +899,48 @@ export async function startTUI(agent, opts = {}) {
870
899
  state.lines = []
871
900
  state.streaming = ""
872
901
  clearSession(agent.cwd)
873
- pushLine("已开始新会话(上一会话已归档)", C.dim)
902
+ pushLine("已开始新会话(旧会话已归档到槽位;/session 可查看)", C.dim)
874
903
  return
875
904
  case "/exit":
876
905
  cleanup()
877
906
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
878
907
  return
908
+ case "/session": {
909
+ const slotNum = Number(rest[0])
910
+ if (rest.length > 0 && !isNaN(slotNum)) {
911
+ // 切换到指定槽位
912
+ const data = switchToSlot(agent.cwd, slotNum)
913
+ if (!data) {
914
+ pushLine(`槽位 ${slotNum} 不存在`, C.dim)
915
+ } else {
916
+ applySession(agent, data)
917
+ state.lines = data.display.length
918
+ ? data.display.map((l) => ({ text: l.text, color: l.color }))
919
+ : []
920
+ state.tasks = agent.tasks ?? []
921
+ pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
922
+ render()
923
+ }
924
+ } else {
925
+ // 列出所有槽位
926
+ const slots = listSlots(agent.cwd)
927
+ if (slots.length === 0) {
928
+ pushLine("没有归档会话(用 /new 后旧会话会自动归档)", C.dim)
929
+ } else {
930
+ pushLabel(`归档会话(/session <n> 切换):`, ansi.bold + C.tool)
931
+ for (const s of slots) {
932
+ pushLine(` 槽位 ${s.slot} — ${s.date}`, C.text)
933
+ }
934
+ }
935
+ }
936
+ return
937
+ }
879
938
  case "/reindex": {
880
- const { syncDir } = await import("./memory.mjs")
939
+ const { syncDir, codeSync, docSync } = await import("./memory.mjs")
881
940
  pushLine("[reindex] 重建索引...", C.tool)
882
941
  agent.memory.db.prepare("DELETE FROM files").run()
942
+ agent.memory.db.prepare("DELETE FROM code_chunks").run()
943
+ agent.memory.db.prepare("DELETE FROM doc_chunks").run()
883
944
  let total = 0
884
945
  if (distillOpts.projectDir) {
885
946
  const s = await syncDir(agent.memory, { layer: "project", dir: distillOpts.projectDir })
@@ -891,7 +952,27 @@ export async function startTUI(agent, opts = {}) {
891
952
  total += s.added
892
953
  pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
893
954
  }
894
- pushLine(`[reindex] 完成,共 ${total} 条。向量将在下次搜索时惰性生成。`, C.tool)
955
+ // 重建代码索引
956
+ pushLine(` [code] 重建代码索引...`, C.tool)
957
+ const cr = await codeSync(agent.memory, agent.cwd, {
958
+ onProgress: (p) => {
959
+ if (p.phase === "index" && p.current % 20 === 0) {
960
+ pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
961
+ }
962
+ }
963
+ })
964
+ pushLine(` code: ${cr.total} 文件,+${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
965
+ // 重建文档索引
966
+ pushLine(` [doc] 重建文档索引...`, C.tool)
967
+ const dr = await docSync(agent.memory, agent.cwd, {
968
+ onProgress: (p) => {
969
+ if (p.phase === "index" && p.current % 5 === 0) {
970
+ pushLine(` 索引中... ${p.current}/${p.total}`, C.dim)
971
+ }
972
+ }
973
+ })
974
+ pushLine(` doc: ${dr.total} 文件,+${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
975
+ pushLine(`[reindex] 完成,共 ${total} 条目。向量将在下次搜索时惰性生成。`, C.tool)
895
976
  return
896
977
  }
897
978
  case "/distill":
@@ -947,14 +1028,15 @@ export async function startTUI(agent, opts = {}) {
947
1028
  const sub = rest[0]
948
1029
  if (sub === "set") {
949
1030
  const text = rest.slice(1).join(" ")
950
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
1031
+ if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
951
1032
  const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
952
1033
  const objective = semi ? text.slice(0, semi).trim() : text.trim()
953
1034
  const criteria = semi ? text.slice(semi + 1).trim() : ""
954
- agent.goal = { objective, criteria, setAt: Date.now() }
1035
+ agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
955
1036
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
956
1037
  pushLine(`目标已设置: ${objective}`, C.tool)
957
1038
  if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1039
+ else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
958
1040
  return
959
1041
  }
960
1042
  if (sub === "cancel") {
@@ -964,10 +1046,11 @@ export async function startTUI(agent, opts = {}) {
964
1046
  return
965
1047
  }
966
1048
  if (agent.goal) {
1049
+ const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
967
1050
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
968
1051
  pushLine(`目标: ${agent.goal.objective}`, C.tool)
969
1052
  if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
970
- pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1053
+ pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
971
1054
  pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
972
1055
  } else {
973
1056
  pushLabel(`❯ Goal`, ansi.bold + C.dim)
@@ -2011,9 +2094,14 @@ export async function startTUI(agent, opts = {}) {
2011
2094
  }
2012
2095
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
2013
2096
  // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
2014
- if (opts.restored?.history?.length) {
2097
+ if (opts.restored?.display?.length) {
2098
+ // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
2099
+ state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
2100
+ pushLabel(`── 已恢复上次会话(退出前原样回放);/new 开始新会话 ──`, C.warn)
2101
+ } else if (opts.restored?.history?.length) {
2015
2102
  for (const m of opts.restored.history) {
2016
2103
  if (m.role === "user") {
2104
+ if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
2017
2105
  pushLabel(`❯ You:`, ansi.bold + C.user)
2018
2106
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2019
2107
  } else if (m.role === "assistant") {
@@ -2027,7 +2115,48 @@ export async function startTUI(agent, opts = {}) {
2027
2115
  }
2028
2116
  pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
2029
2117
  }
2118
+ // 有归档槽位时给个提示
2119
+ if (listSlots(agent.cwd).length > 0) {
2120
+ pushLine("提示:存在归档会话,/session 可查看/切换", C.dim)
2121
+ }
2030
2122
  render()
2123
+
2124
+ // 后台索引(进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行
2125
+ ;(async () => {
2126
+ const { codeSync, docSync } = await import("./memory.mjs")
2127
+ const cwd = agent.cwd
2128
+ let codeFiles = 0, docFiles = 0
2129
+ try {
2130
+ state.status = "Indexing code..."
2131
+ render()
2132
+ await codeSync(agent.memory, cwd, {
2133
+ onProgress: (p) => {
2134
+ if (p.phase === "index" && p.current % 30 === 0) {
2135
+ state.status = `Indexing code... ${p.current}/${p.total}`
2136
+ render()
2137
+ }
2138
+ }
2139
+ })
2140
+ codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
2141
+ } catch { /* 不阻塞 */ }
2142
+ try {
2143
+ state.status = "Indexing docs..."
2144
+ render()
2145
+ await docSync(agent.memory, cwd, {
2146
+ onProgress: (p) => {
2147
+ if (p.phase === "index" && p.current % 10 === 0) {
2148
+ state.status = `Indexing docs... ${p.current}/${p.total}`
2149
+ render()
2150
+ }
2151
+ }
2152
+ })
2153
+ docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
2154
+ } catch { /* 不阻塞 */ }
2155
+ state.status = codeFiles || docFiles
2156
+ ? `Ready — idx code ${codeFiles} doc ${docFiles}`
2157
+ : "Ready"
2158
+ render()
2159
+ })()
2031
2160
  }
2032
2161
 
2033
2162
  function summarize(obj) {