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/README.md +9 -3
- package/bin/thincoder.mjs +20 -16
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +6 -6
- package/src/agent.mjs +329 -58
- package/src/coder-overlay.md +2 -1
- package/src/config.mjs +29 -20
- package/src/context.mjs +128 -48
- package/src/explore-overlay.md +6 -2
- package/src/main-overlay.md +8 -0
- package/src/memory.mjs +677 -3
- package/src/plan-overlay.md +13 -0
- package/src/provider.mjs +73 -6
- package/src/repomap.mjs +204 -0
- package/src/session.mjs +142 -15
- package/src/skills.mjs +2 -1
- package/src/tools/bash.md +1 -0
- package/src/tools.mjs +33 -7
- package/src/tui.mjs +153 -24
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
|
-
|
|
321
|
+
if (cleanedUp) return
|
|
322
|
+
cleanedUp = true
|
|
323
|
+
// 退出前保存会话(同步写);先归档当前到槽位,再落新——不丢
|
|
306
324
|
try {
|
|
307
|
-
|
|
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
|
-
|
|
789
|
-
if (
|
|
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 (
|
|
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 (
|
|
803
|
-
if (
|
|
804
|
-
if (
|
|
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("
|
|
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
|
-
|
|
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 <目标描述>(;
|
|
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?.
|
|
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) {
|