thincoder 0.3.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
@@ -9,7 +9,8 @@ import { PassThrough } from "node:stream"
9
9
  import { basename } from "node:path"
10
10
  import { existsSync, readFileSync } from "node:fs"
11
11
  import { runAgent, ContinueError } from "./agent.mjs"
12
- import { saveSession, clearSession } from "./session.mjs"
12
+ import { estimateTokens } from "./context.mjs"
13
+ import { saveSession, clearSession, archiveCurrent, listSlots, switchToSlot, sessionPath } from "./session.mjs"
13
14
  import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
14
15
  import { closeAllMcp } from "./mcp.mjs"
15
16
 
@@ -144,11 +145,29 @@ function renderTable(block, width) {
144
145
  widths[widest]--
145
146
  }
146
147
 
147
- const fmtRow = (cells) =>
148
- "│ " + cells.map((c, i) => padByWidth(sliceByWidth(c, widths[i]), widths[i])).join(" │ ") + " │"
148
+ // 单元格渲染:sliceByWidth 截断(表头单行),padByWidth 补齐
149
+ const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
150
+ const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
151
+
152
+ // 分隔线
149
153
  const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
150
154
 
151
- return [fmtRow(rows[0]), separator, ...rows.slice(2).map(fmtRow)]
155
+ const out = []
156
+ // 表头:单行截断(表头通常是短标签,折行不如截断直观)
157
+ out.push(fmtRow(rows[0]))
158
+ out.push(separator)
159
+
160
+ // 数据行:过长单元格按列宽折行,一个逻辑行可能对应多条显示行
161
+ for (let r = 2; r < rows.length; r++) {
162
+ // wrapText 返回按 width 折行后的行数组,保留内部 \n
163
+ const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
164
+ const height = Math.max(...wrapped.map((lines) => lines.length))
165
+ for (let lineIdx = 0; lineIdx < height; lineIdx++) {
166
+ out.push(fmtRow(wrapped.map((lines) => lines[lineIdx] ?? "")))
167
+ }
168
+ }
169
+
170
+ return out
152
171
  }
153
172
 
154
173
  /** 输入区布局:把输入缓冲折行,同时算出光标的 (行, 列) 位置(显示宽度) */
@@ -188,6 +207,21 @@ export function layoutInput(chars, cursor, width) {
188
207
  }
189
208
 
190
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
+
191
225
  export function wrapText(text, width) {
192
226
  const lines = []
193
227
  for (const rawLine of text.split("\n")) {
@@ -234,7 +268,9 @@ export async function startTUI(agent, opts = {}) {
234
268
  question: null, // { text, options, resolve } — agent 的 question 工具回调
235
269
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
236
270
  wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
237
- tasks: [], // task 工具的任务列表(状态栏显示进度)
271
+ tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上
272
+ tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
273
+ ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
238
274
  reasoning: "", // 思考流缓冲(暗色展示)
239
275
  completion: null, // Tab 补全状态 { candidates, index }
240
276
  toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
@@ -280,10 +316,14 @@ export async function startTUI(agent, opts = {}) {
280
316
  if (text) keyStream.write(text)
281
317
  })
282
318
 
319
+ let cleanedUp = false
283
320
  const cleanup = () => {
284
- // 退出前保存会话(同步写,保证 exit 路径也能落盘)
321
+ if (cleanedUp) return
322
+ cleanedUp = true
323
+ // 退出前保存会话(同步写);先归档当前到槽位,再落新——不丢
285
324
  try {
286
- saveSession(agent)
325
+ archiveCurrent(agent.cwd)
326
+ saveSession(agent, state.lines)
287
327
  } catch {
288
328
  // 存失败不耽误退出
289
329
  }
@@ -389,7 +429,7 @@ export async function startTUI(agent, opts = {}) {
389
429
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
390
430
  const convLines = []
391
431
  for (const l of state.lines) {
392
- for (const line of formatTables(l.text, cols - 1)) {
432
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
393
433
  for (const wrapped of wrapText(line, cols - 1)) {
394
434
  convLines.push({ text: wrapped, color: l.color })
395
435
  }
@@ -397,12 +437,12 @@ export async function startTUI(agent, opts = {}) {
397
437
  }
398
438
  // 思考流(暗色)在正文流之前
399
439
  if (state.reasoning) {
400
- for (const wrapped of wrapText(state.reasoning, cols - 1)) {
440
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
401
441
  convLines.push({ text: wrapped, color: C.reason })
402
442
  }
403
443
  }
404
444
  if (state.streaming) {
405
- for (const line of formatTables(state.streaming, cols - 1)) {
445
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
406
446
  for (const wrapped of wrapText(line, cols - 1)) {
407
447
  convLines.push({ text: wrapped, color: C.text })
408
448
  }
@@ -411,7 +451,7 @@ export async function startTUI(agent, opts = {}) {
411
451
  // 工具实时输出(暗色,只保留末尾防刷屏;按工具名隔离防止并行工具串扰)
412
452
  const allStreams = Object.values(state.toolStreams).join("")
413
453
  if (allStreams) {
414
- const tail = allStreams.slice(-4000)
454
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
415
455
  for (const wrapped of wrapText(tail, cols - 1)) {
416
456
  convLines.push({ text: wrapped, color: C.dim })
417
457
  }
@@ -451,10 +491,10 @@ export async function startTUI(agent, opts = {}) {
451
491
  for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
452
492
  }
453
493
 
454
- // todo 面板(对话区与输入框之间):▶ in_progress / ✓ done / ○ pending
494
+ // todo 面板(对话区与输入框之间):▶ in_progress / ✓ done(删除线) / ○ pending
455
495
  for (const t of visibleTasks) {
456
496
  const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
457
- const color = t.status === "done" ? C.dim : t.status === "in_progress" ? C.tool : C.text
497
+ const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
458
498
  out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
459
499
  }
460
500
 
@@ -533,10 +573,28 @@ export async function startTUI(agent, opts = {}) {
533
573
  const taskHint = state.tasks.length > 0
534
574
  ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
535
575
  : ""
576
+ // token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
577
+ const tk = state.tokens
578
+ const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
579
+ const cacheTotal = tk.cacheHit + tk.cacheMiss
580
+ const tokenHint = tk.prompt > 0
581
+ ? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}`
582
+ : ""
536
583
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
537
584
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
538
585
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
539
- statusLine = ` ${statusText}${taskHint}${scrollHint} Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
586
+ // 上下文利用率:占压缩阈值百分比(到 100% 触发压缩;≥80% 变黄提醒该收尾或 /new)
587
+ if (state.ctxCache.len !== agent.history.length) {
588
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
589
+ }
590
+ const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
591
+ const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
592
+ const ctxHint = ctxPct > 0
593
+ ? ctxPct >= 80
594
+ ? ` │ ${ansi.reset}${C.warn}ctx ${ctxPct}%${ansi.reset}${ansi.dim}`
595
+ : ` │ ctx ${ctxPct}%`
596
+ : ""
597
+ statusLine = ` ${statusText}${taskHint}${tokenHint}${ctxHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
540
598
  }
541
599
  const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
542
600
  const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
@@ -556,7 +614,7 @@ export async function startTUI(agent, opts = {}) {
556
614
  if (state.processing || state.permission || state.question || state.picker || state.wizard?.step === "provider") {
557
615
  process.stdout.write(ansi.hideCursor)
558
616
  } else {
559
- const cursorRow = 1 + convH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
617
+ const cursorRow = 1 + convH + pickerH + taskPanelH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + todo 面板 + 上边框 + 行偏移
560
618
  const cursorCol = 3 + layout.cursorCol // 左边框 + 空格 + 文本偏移(1 基)
561
619
  process.stdout.write(`${ESC}[${cursorRow};${cursorCol}H${ansi.showCursor}`)
562
620
  }
@@ -643,12 +701,28 @@ export async function startTUI(agent, opts = {}) {
643
701
  onCompress: () => {
644
702
  pushLine(" [context] 上下文过长,已自动压缩(早期对话由 LLM 摘要,任务状态保留)", C.warn)
645
703
  },
704
+ onUsage: (usage) => {
705
+ state.tokens.prompt += usage.prompt_tokens ?? 0
706
+ state.tokens.completion += usage.completion_tokens ?? 0
707
+ state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
708
+ state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
709
+ },
646
710
  onTaskUpdate: (items) => {
647
711
  state.tasks = items
648
712
  const done = items.filter((i) => i.status === "done").length
649
- pushLine(` [task] ${done}/${items.length}`, C.dim)
713
+ // 留痕带上当前任务标题:回看历史时知道进行到哪一项
714
+ const current = items.find((i) => i.status === "in_progress")
715
+ pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
650
716
  render()
651
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
+ })(),
652
726
  }
653
727
 
654
728
  for (let resume = false; ; resume = true) {
@@ -700,7 +774,7 @@ export async function startTUI(agent, opts = {}) {
700
774
  }
701
775
  // 每轮结束后保存会话(崩溃也不丢)
702
776
  try {
703
- saveSession(agent)
777
+ saveSession(agent, state.lines)
704
778
  } catch {
705
779
  // 存失败不打断使用
706
780
  }
@@ -725,8 +799,9 @@ export async function startTUI(agent, opts = {}) {
725
799
  return Promise.resolve(true)
726
800
  }
727
801
  // 把关键参数摆出来:批什么要让人看明白
802
+ // 内容行用警告色——与正常输出(白)区分,滚动回看也能认出这是待审批内容
728
803
  pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
729
- for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
804
+ for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.warn)
730
805
  return new Promise((resolve) => {
731
806
  state.permission = { name, args, resolve }
732
807
  state.status = `Waiting: ${name}`
@@ -734,14 +809,27 @@ export async function startTUI(agent, opts = {}) {
734
809
  })
735
810
  }
736
811
 
737
- /** 权限请求的关键信息(按工具定制),返回行数组 */
812
+ /** 权限请求的关键信息(按工具定制),返回行数组。name 可能带子 agent 前缀("coder/bash"),取基名匹配 */
738
813
  function formatPermission(name, args) {
739
814
  const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
740
- if (name === "bash") return cap(args.command ?? "").split("\n")
741
- if (name === "write") return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`]
742
- if (name === "edit") return [`${args.path}(替换 ${(args.old_string ?? "").length} 字符 → ${(args.new_string ?? "").length} 字符)`]
743
- if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
744
- if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
815
+ const base = name.includes("/") ? name.split("/").pop() : name
816
+ if (base === "bash") return cap(args.command ?? "").split("\n")
817
+ if (base === "write") {
818
+ // 批准写文件必须看得到要写什么:路径 + 内容预览
819
+ return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`, ...cap(args.content ?? "", 1000).split("\n")]
820
+ }
821
+ if (base === "edit") {
822
+ // 简易 diff:- 旧内容 / + 新内容
823
+ return [
824
+ `${args.path}`,
825
+ ...cap(args.old_string ?? "", 500).split("\n").map((l) => `- ${l}`),
826
+ " ↓",
827
+ ...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
828
+ ]
829
+ }
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")]
745
833
  return [cap(summarize(args), 300)]
746
834
  }
747
835
 
@@ -784,7 +872,8 @@ export async function startTUI(agent, opts = {}) {
784
872
  { name: "/provider", group: "Config", desc: "管理 provider(增/删/配 key)" },
785
873
  { name: "/config", group: "Config", desc: "配置管理(embedding / agent)" },
786
874
  { name: "/reindex", group: "Config", desc: "重建记忆索引" },
787
- { name: "/new", group: "Session", desc: "开始新会话" },
875
+ { name: "/new", group: "Session", desc: "新会话(旧会话归档到槽位)" },
876
+ { name: "/session", group: "Session", desc: "列出/切换归档会话" },
788
877
  { name: "/clear", group: "Session", desc: "清屏" },
789
878
  { name: "/distill", group: "Session", desc: "从会话提取知识" },
790
879
  { name: "/rewind", group: "Session", desc: "回滚到存档点" },
@@ -810,16 +899,48 @@ export async function startTUI(agent, opts = {}) {
810
899
  state.lines = []
811
900
  state.streaming = ""
812
901
  clearSession(agent.cwd)
813
- pushLine("已开始新会话(上一会话已归档)", C.dim)
902
+ pushLine("已开始新会话(旧会话已归档到槽位;/session 可查看)", C.dim)
814
903
  return
815
904
  case "/exit":
816
905
  cleanup()
817
906
  setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
818
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
+ }
819
938
  case "/reindex": {
820
- const { syncDir } = await import("./memory.mjs")
939
+ const { syncDir, codeSync, docSync } = await import("./memory.mjs")
821
940
  pushLine("[reindex] 重建索引...", C.tool)
822
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()
823
944
  let total = 0
824
945
  if (distillOpts.projectDir) {
825
946
  const s = await syncDir(agent.memory, { layer: "project", dir: distillOpts.projectDir })
@@ -831,7 +952,27 @@ export async function startTUI(agent, opts = {}) {
831
952
  total += s.added
832
953
  pushLine(` team: +${s.added} ~${s.updated} -${s.removed}`, C.dim)
833
954
  }
834
- 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)
835
976
  return
836
977
  }
837
978
  case "/distill":
@@ -887,14 +1028,15 @@ export async function startTUI(agent, opts = {}) {
887
1028
  const sub = rest[0]
888
1029
  if (sub === "set") {
889
1030
  const text = rest.slice(1).join(" ")
890
- if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件)", C.error); return }
1031
+ if (!text) { pushLine("用法: /goal set <目标描述>(; 分隔完成条件,必须是可机器检查的验证手段)", C.error); return }
891
1032
  const semi = text.indexOf(";") >= 0 ? ";" : text.indexOf(";") >= 0 ? ";" : null
892
1033
  const objective = semi ? text.slice(0, semi).trim() : text.trim()
893
1034
  const criteria = semi ? text.slice(semi + 1).trim() : ""
894
- agent.goal = { objective, criteria, setAt: Date.now() }
1035
+ agent.goal = { objective, criteria, setAt: Date.now(), status: "active", turnsUsed: 0, _blockTally: null }
895
1036
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
896
1037
  pushLine(`目标已设置: ${objective}`, C.tool)
897
1038
  if (criteria) pushLine(` 完成条件: ${criteria}`, C.dim)
1039
+ else pushLine(` ⚠ 未完成条件——agent 用 goal set 设立时会被要求补上可验证的完成条件`, C.warn)
898
1040
  return
899
1041
  }
900
1042
  if (sub === "cancel") {
@@ -904,10 +1046,11 @@ export async function startTUI(agent, opts = {}) {
904
1046
  return
905
1047
  }
906
1048
  if (agent.goal) {
1049
+ const statusText = { active: "进行中", complete: "已完成", blocked: "已阻塞" }[agent.goal.status] ?? agent.goal.status
907
1050
  pushLabel(`❯ Goal`, ansi.bold + C.warn)
908
1051
  pushLine(`目标: ${agent.goal.objective}`, C.tool)
909
1052
  if (agent.goal.criteria) pushLine(` 完成条件: ${agent.goal.criteria}`, C.dim)
910
- pushLine(` 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
1053
+ pushLine(` 状态: ${statusText ?? "进行中"} │ 已用轮数: ${agent.goal.turnsUsed ?? 0} │ 设置于: ${new Date(agent.goal.setAt).toLocaleString()}`, C.dim)
911
1054
  pushLine("操作: /goal set <描述> 覆盖 | /goal cancel 取消", C.dim)
912
1055
  } else {
913
1056
  pushLabel(`❯ Goal`, ansi.bold + C.dim)
@@ -1719,7 +1862,7 @@ export async function startTUI(agent, opts = {}) {
1719
1862
  const isContinue = state.permission.name === "continue"
1720
1863
  const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
1721
1864
  if (validKeys.includes(answer) || key.name === "escape") {
1722
- const { resolve } = state.permission
1865
+ const { resolve, name } = state.permission
1723
1866
  state.permission = null
1724
1867
  state.status = "Processing..."
1725
1868
  if (answer === "a" && !isContinue) {
@@ -1728,7 +1871,12 @@ export async function startTUI(agent, opts = {}) {
1728
1871
  agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
1729
1872
  pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
1730
1873
  }
1731
- resolve(answer === "y" || (answer === "a" && !isContinue))
1874
+ const approved = answer === "y" || (answer === "a" && !isContinue)
1875
+ // 决定落痕:对话区留下批准/拒绝记录(continue 询问有自己的输出,不重复记)
1876
+ if (!isContinue) {
1877
+ pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
1878
+ }
1879
+ resolve(approved)
1732
1880
  render()
1733
1881
  }
1734
1882
  return
@@ -1946,9 +2094,14 @@ export async function startTUI(agent, opts = {}) {
1946
2094
  }
1947
2095
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
1948
2096
  // 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
1949
- 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) {
1950
2102
  for (const m of opts.restored.history) {
1951
2103
  if (m.role === "user") {
2104
+ if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
1952
2105
  pushLabel(`❯ You:`, ansi.bold + C.user)
1953
2106
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
1954
2107
  } else if (m.role === "assistant") {
@@ -1962,7 +2115,48 @@ export async function startTUI(agent, opts = {}) {
1962
2115
  }
1963
2116
  pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
1964
2117
  }
2118
+ // 有归档槽位时给个提示
2119
+ if (listSlots(agent.cwd).length > 0) {
2120
+ pushLine("提示:存在归档会话,/session 可查看/切换", C.dim)
2121
+ }
1965
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
+ })()
1966
2160
  }
1967
2161
 
1968
2162
  function summarize(obj) {