thincoder 0.10.0 → 0.11.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/README.md +1 -1
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +47 -20
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +6 -2
- package/src/prompts/discipline.md +23 -6
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +13 -6
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +42 -130
- package/src/provider/google.mjs +199 -0
- package/src/provider/sse.mjs +112 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +125 -156
- package/src/tools/index.mjs +9 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +115 -89
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +86 -75
- package/src/tui/cmd-advisor.mjs +138 -49
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +61 -215
- package/src/tui/key-handler.mjs +56 -20
- package/src/tui/layout.mjs +13 -3
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +56 -114
- package/src/tui/render-loop.mjs +181 -0
- package/src/tui/slash-commands.mjs +26 -16
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -1,19 +1,87 @@
|
|
|
1
|
+
import { C } from "./ansi.mjs"
|
|
2
|
+
|
|
1
3
|
/** /think command: toggle thinking mode, set reasoning effort.
|
|
2
4
|
* Extracted from slash-commands.mjs.
|
|
3
|
-
* ctx: { agent,
|
|
4
|
-
export async function handleThinkCommand(ctx) {
|
|
5
|
-
const { agent,
|
|
5
|
+
* ctx: { agent, showPicker, syncProviderField, pushLine } */
|
|
6
|
+
export async function handleThinkCommand(ctx, args = []) {
|
|
7
|
+
const { agent, showPicker, syncProviderField, pushLine } = ctx
|
|
6
8
|
const cur = agent.provider
|
|
7
9
|
const { specForModel } = await import("../config.mjs")
|
|
8
10
|
const spec = specForModel(cur.model)
|
|
9
11
|
const isEffortOnly = spec.thinkApi === "effort"
|
|
10
|
-
const thinkOnValue = spec.
|
|
12
|
+
const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
|
|
11
13
|
const isCustomThink = thinkOnValue !== "enabled"
|
|
14
|
+
const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
|
|
15
|
+
|
|
16
|
+
async function apply(e) {
|
|
17
|
+
if (e.action === "auto") {
|
|
18
|
+
const cfg = agent.config.agent ??= {}
|
|
19
|
+
cfg.autoThink = !cfg.autoThink
|
|
20
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
21
|
+
if (cfg.autoThink) {
|
|
22
|
+
// Turn off manual effort — auto will set it per-turn
|
|
23
|
+
delete cur.reasoningEffort
|
|
24
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
25
|
+
agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
|
|
26
|
+
} else {
|
|
27
|
+
agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
|
|
28
|
+
}
|
|
29
|
+
} else if (e.action === "effort") {
|
|
30
|
+
cur.reasoningEffort = e.level
|
|
31
|
+
await syncProviderField("reasoningEffort", e.level)
|
|
32
|
+
} else {
|
|
33
|
+
const enable = e.action === "on"
|
|
34
|
+
if (isEffortOnly) {
|
|
35
|
+
if (!enable) delete cur.reasoningEffort
|
|
36
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
37
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
38
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
39
|
+
} else {
|
|
40
|
+
if (enable) {
|
|
41
|
+
cur.thinking = { type: thinkOnValue }
|
|
42
|
+
if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
43
|
+
} else {
|
|
44
|
+
// Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
|
|
45
|
+
cur.thinking = isCustomThink ? undefined : { type: "disabled" }
|
|
46
|
+
delete cur.reasoningEffort
|
|
47
|
+
}
|
|
48
|
+
await syncProviderField("thinking", cur.thinking)
|
|
49
|
+
if (enable) {
|
|
50
|
+
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
51
|
+
} else {
|
|
52
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Direct args: /think on|off │ /think effort <level>
|
|
59
|
+
// autoThink 开启时手动值每轮被覆盖(picker 里也隐藏了开关/effort 项),直参同样拒绝
|
|
60
|
+
const autoThinkEnabled = agent.config?.agent?.autoThink === true
|
|
61
|
+
const sub = args[0]?.toLowerCase()
|
|
62
|
+
if (sub === "on" || sub === "off") {
|
|
63
|
+
if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
|
|
64
|
+
await apply({ action: sub })
|
|
65
|
+
pushLine(`Thinking: ${sub}`, C.dim)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (sub === "effort") {
|
|
69
|
+
const level = args[1]?.toLowerCase()
|
|
70
|
+
if (!level || !effortLevels.includes(level)) {
|
|
71
|
+
pushLine(`Usage: /think effort <${effortLevels.join("|")}>`, C.error)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
|
|
75
|
+
await apply({ action: "effort", level })
|
|
76
|
+
pushLine(`Thinking effort: ${level}`, C.dim)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (sub) { pushLine("Usage: /think [on|off|effort <level>]", C.error); return }
|
|
80
|
+
|
|
12
81
|
// "enabled" when thinking.type matches the model's enabled value, or when thinking is absent and the model is NOT a custom-think model (defaults to on for standard models)
|
|
13
82
|
const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
|
|
14
83
|
const entries = []
|
|
15
84
|
// Auto-think: classify difficulty per-prompt and auto-set reasoning effort
|
|
16
|
-
const autoThinkEnabled = agent.config?.agent?.autoThink === true
|
|
17
85
|
entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
|
|
18
86
|
if (!isEffortOnly) {
|
|
19
87
|
if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
@@ -27,49 +95,6 @@ export async function handleThinkCommand(ctx) {
|
|
|
27
95
|
entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
|
|
28
96
|
entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
|
|
29
97
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
entries,
|
|
33
|
-
onSelect: async (e) => {
|
|
34
|
-
if (e.action === "auto") {
|
|
35
|
-
const cfg = agent.config.agent ??= {}
|
|
36
|
-
cfg.autoThink = !cfg.autoThink
|
|
37
|
-
agent._pendingReminders = agent._pendingReminders ?? []
|
|
38
|
-
if (cfg.autoThink) {
|
|
39
|
-
// Turn off manual effort — auto will set it per-turn
|
|
40
|
-
delete cur.reasoningEffort
|
|
41
|
-
await syncProviderField("reasoningEffort", undefined)
|
|
42
|
-
agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
|
|
43
|
-
} else {
|
|
44
|
-
agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
|
|
45
|
-
}
|
|
46
|
-
} else if (e.action === "effort") {
|
|
47
|
-
cur.reasoningEffort = e.level
|
|
48
|
-
await syncProviderField("reasoningEffort", e.level)
|
|
49
|
-
} else {
|
|
50
|
-
const enable = e.action === "on"
|
|
51
|
-
if (isEffortOnly) {
|
|
52
|
-
if (!enable) delete cur.reasoningEffort
|
|
53
|
-
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
54
|
-
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
55
|
-
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
56
|
-
} else {
|
|
57
|
-
if (enable) {
|
|
58
|
-
cur.thinking = { type: thinkOnValue }
|
|
59
|
-
if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
60
|
-
} else {
|
|
61
|
-
// Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
|
|
62
|
-
cur.thinking = isCustomThink ? undefined : { type: "disabled" }
|
|
63
|
-
delete cur.reasoningEffort
|
|
64
|
-
}
|
|
65
|
-
await syncProviderField("thinking", cur.thinking)
|
|
66
|
-
if (enable) {
|
|
67
|
-
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
68
|
-
} else {
|
|
69
|
-
await syncProviderField("reasoningEffort", undefined)
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
})
|
|
98
|
+
const e = await showPicker("Think", entries)
|
|
99
|
+
if (e) await apply(e)
|
|
75
100
|
}
|
package/src/tui/cmd-undo.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export function snapshotForUndo(agent, toolName, args, cwd) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export async function handleUndoCommand(ctx) {
|
|
44
|
-
const { agent, pushLine,
|
|
44
|
+
const { agent, pushLine, showPicker } = ctx
|
|
45
45
|
const stack = agent._undoStack ?? []
|
|
46
46
|
|
|
47
47
|
if (stack.length === 0) {
|
|
@@ -65,27 +65,23 @@ export async function handleUndoCommand(ctx) {
|
|
|
65
65
|
}),
|
|
66
66
|
]
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const item = stack[e.idx]
|
|
73
|
-
const abs = join(agent.cwd, ...item.path.split("/"))
|
|
68
|
+
const e = await showPicker("Undo", entries)
|
|
69
|
+
if (!e) return
|
|
70
|
+
const item = stack[e.idx]
|
|
71
|
+
const abs = join(agent.cwd, ...item.path.split("/"))
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
},
|
|
90
|
-
})
|
|
73
|
+
try {
|
|
74
|
+
if (item.backup === null) {
|
|
75
|
+
// File was created — undo deletes it
|
|
76
|
+
if (existsSync(abs)) unlinkSync(abs)
|
|
77
|
+
} else {
|
|
78
|
+
// File was modified — undo restores original
|
|
79
|
+
writeFileSync(abs, item.backup, "utf8")
|
|
80
|
+
}
|
|
81
|
+
// Remove this and all newer entries (can't undo out of order)
|
|
82
|
+
stack.splice(e.idx)
|
|
83
|
+
pushLine(`[undo] Reverted: ${item.tool} ${item.path}`, C.tool)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
pushLine(`[undo] Failed to revert ${item.path}: ${err.message}`, C.error)
|
|
86
|
+
}
|
|
91
87
|
}
|
package/src/tui/cmd-upgrade.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
|
|
1
3
|
/** /upgrade command: check for updates and optionally upgrade.
|
|
2
|
-
* ctx: { agent, pushLine, pushLabel,
|
|
4
|
+
* ctx: { agent, pushLine, pushLabel, showPicker } */
|
|
3
5
|
export async function handleUpgradeCommand(ctx) {
|
|
4
|
-
const { pushLine, pushLabel,
|
|
6
|
+
const { pushLine, pushLabel, showPicker } = ctx
|
|
5
7
|
const { checkForUpdate } = await import("../upgrade.mjs")
|
|
6
8
|
const { readFileSync } = await import("node:fs")
|
|
7
9
|
|
|
@@ -19,29 +21,23 @@ export async function handleUpgradeCommand(ctx) {
|
|
|
19
21
|
return
|
|
20
22
|
}
|
|
21
23
|
pushLine(`thincoder ${result.latest} is available (current: ${result.local}).`, C.tool)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} else {
|
|
41
|
-
pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
},
|
|
24
|
+
const sel = await showPicker(`Update: ${result.local} → ${result.latest}`, [
|
|
25
|
+
{ type: "header", text: `New version: ${result.latest}` },
|
|
26
|
+
{ type: "item", text: "Upgrade now", action: "upgrade" },
|
|
27
|
+
{ type: "item", text: "Later", action: "later" },
|
|
28
|
+
])
|
|
29
|
+
if (sel?.action !== "upgrade") return
|
|
30
|
+
pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
|
|
31
|
+
pushLine(`Upgrading to ${result.latest}...`, C.tool)
|
|
32
|
+
const { exec } = await import("node:child_process")
|
|
33
|
+
const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
|
|
34
|
+
cp.stdout?.on("data", () => {})
|
|
35
|
+
cp.stderr?.on("data", () => {})
|
|
36
|
+
cp.on("close", (code) => {
|
|
37
|
+
if (code === 0) {
|
|
38
|
+
pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
|
|
39
|
+
} else {
|
|
40
|
+
pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
|
|
41
|
+
}
|
|
46
42
|
})
|
|
47
43
|
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Large logic blocks extracted to independent modules:
|
|
7
7
|
* agent-turn.mjs — agent loop + callback construction
|
|
8
|
-
*
|
|
8
|
+
* render-loop.mjs — frame scheduler + incremental panel rendering
|
|
9
9
|
* startup.mjs — startup screen + session restore + background indexing
|
|
10
10
|
* interaction.mjs — permission approval + Q&A
|
|
11
11
|
* pickers.mjs — generic list picker + model picker
|
|
@@ -20,16 +20,9 @@ import { emitKeypressEvents } from "node:readline"
|
|
|
20
20
|
import { PassThrough } from "node:stream"
|
|
21
21
|
import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
|
|
22
22
|
import { closeAllMcp } from "../mcp.mjs"
|
|
23
|
-
import { estimateTokens } from "../context.mjs"
|
|
24
23
|
import { ansi, C } from "./ansi.mjs"
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
renderHeader, renderConversation, renderTodo, renderSubagent,
|
|
28
|
-
renderOutput, renderPermission, renderQueue, renderPicker,
|
|
29
|
-
renderInputBox, renderStatus,
|
|
30
|
-
} from "./render-frame.mjs"
|
|
31
|
-
import { computeLayout } from "./layout.mjs"
|
|
32
|
-
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
24
|
+
import { createRenderLoop } from "./render-loop.mjs"
|
|
25
|
+
import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
|
|
33
26
|
import { createWizard } from "./wizard.mjs"
|
|
34
27
|
import { createPickers } from "./pickers.mjs"
|
|
35
28
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
@@ -40,6 +33,17 @@ import { createKeyHandler } from "./key-handler.mjs"
|
|
|
40
33
|
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
41
34
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
42
35
|
|
|
36
|
+
/** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
|
|
37
|
+
export function upgradeFailureText(code, output) {
|
|
38
|
+
const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
|
|
39
|
+
return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
|
|
43
|
+
export function pendingNoticeReady(state) {
|
|
44
|
+
return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
|
|
45
|
+
}
|
|
46
|
+
|
|
43
47
|
/**
|
|
44
48
|
* Start the TUI, taking over the terminal until exit.
|
|
45
49
|
* agent: return value of createAgent
|
|
@@ -65,7 +69,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
65
69
|
permission: null, // { name, args, resolve }
|
|
66
70
|
permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
|
|
67
71
|
question: null, // { text, options, resolve } — agent question tool callback
|
|
68
|
-
picker: null, //
|
|
72
|
+
picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
|
|
73
|
+
pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
|
|
74
|
+
pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
|
|
69
75
|
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
70
76
|
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
71
77
|
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
|
|
@@ -95,6 +101,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
95
101
|
const keyStream = new PassThrough()
|
|
96
102
|
let mousePending = "" // incomplete mouse sequence tail spanning chunks
|
|
97
103
|
let lastRenderedScroll = 0
|
|
104
|
+
// Capture terminal dimensions before raw mode & alt buffer switch.
|
|
105
|
+
// On Windows, process.stdout.columns/rows can briefly return falsy after the mode switch
|
|
106
|
+
// (ConPTY buffer transition), causing the ||80/||24 fallback to produce a cramped initial layout.
|
|
107
|
+
const startupCols = process.stdout.columns || 80
|
|
108
|
+
const startupRows = process.stdout.rows || 24
|
|
98
109
|
emitKeypressEvents(keyStream)
|
|
99
110
|
process.stdin.setRawMode(true)
|
|
100
111
|
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
|
|
@@ -229,175 +240,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
229
240
|
|
|
230
241
|
// ---------------------------------------------------------- Render
|
|
231
242
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
let renderRequested = false, renderTimer = null, lastRenderAt = 0
|
|
238
|
-
const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
|
|
239
|
-
|
|
240
|
-
function scheduleRender() {
|
|
241
|
-
if (renderTimer) return
|
|
242
|
-
const elapsed = performance.now() - lastRenderAt
|
|
243
|
-
const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
|
|
244
|
-
renderTimer = setTimeout(() => {
|
|
245
|
-
renderTimer = null
|
|
246
|
-
if (!renderRequested) return
|
|
247
|
-
renderRequested = false
|
|
248
|
-
lastRenderAt = performance.now()
|
|
249
|
-
doRender()
|
|
250
|
-
if (renderRequested) scheduleRender() // more requests arrived during render
|
|
251
|
-
}, delay)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Rate-limited render entry point. All call sites use this. */
|
|
255
|
-
function render() {
|
|
256
|
-
if (renderRequested) return
|
|
257
|
-
renderRequested = true
|
|
258
|
-
// process.nextTick merges multiple synchronous render() calls
|
|
259
|
-
// within the same tick into a single scheduleRender call.
|
|
260
|
-
process.nextTick(() => scheduleRender())
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
|
|
264
|
-
function buildPanel(name, panelLayout, lines, cacheKey) {
|
|
265
|
-
if (!panelLayout) {
|
|
266
|
-
if (panelCache.has(name)) panelCache.delete(name)
|
|
267
|
-
return null
|
|
268
|
-
}
|
|
269
|
-
const content = lines.join("\r\n")
|
|
270
|
-
const cached = panelCache.get(name)
|
|
271
|
-
const effectiveKey = cacheKey ?? content
|
|
272
|
-
if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
|
|
273
|
-
const rows = []
|
|
274
|
-
for (let i = 0; i < panelLayout.h; i++) {
|
|
275
|
-
rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
|
|
276
|
-
}
|
|
277
|
-
panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
|
|
278
|
-
return rows.join("")
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Detect if panel layout structure changed (appeared/disappeared/shifted).
|
|
282
|
-
* Only checks panels that are ALREADY cached — new panels (not yet written)
|
|
283
|
-
* are not a structural change; the incremental path will write them naturally. */
|
|
284
|
-
function layoutStructureChanged(layout) {
|
|
285
|
-
for (const [name, cached] of panelCache) {
|
|
286
|
-
const p = layout.panels[name] ?? null
|
|
287
|
-
if (p == null) return true // cached panel disappeared → layout changed
|
|
288
|
-
if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
|
|
289
|
-
}
|
|
290
|
-
return false
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function doRender() {
|
|
294
|
-
try {
|
|
295
|
-
const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
296
|
-
const layout = computeLayout(state, dims)
|
|
297
|
-
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
298
|
-
|
|
299
|
-
// Side effects: clamp scroll + overlay + update ctxCache
|
|
300
|
-
const convLines = countConvLines(state, dims.cols)
|
|
301
|
-
state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
|
|
302
|
-
if (overlay && panels.picker) {
|
|
303
|
-
const winH = panels.picker.h - 1
|
|
304
|
-
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
305
|
-
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
306
|
-
}
|
|
307
|
-
if (state.ctxCache.len !== agent.history.length) {
|
|
308
|
-
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
|
|
312
|
-
// Don't clear panelCache — update positions so the next incremental check
|
|
313
|
-
// sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
|
|
314
|
-
// per panel on the next frame (much cheaper than another full redraw).
|
|
315
|
-
if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
|
|
316
|
-
lastCols = dims.cols; lastRows = dims.rows
|
|
317
|
-
// Update cached panel positions (content stays stale → next frame rewrites)
|
|
318
|
-
for (const [name, panelLayout] of Object.entries(panels)) {
|
|
319
|
-
if (!panelLayout) { panelCache.delete(name); continue }
|
|
320
|
-
const cached = panelCache.get(name)
|
|
321
|
-
if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
|
|
322
|
-
}
|
|
323
|
-
const isStreaming = state.processing && !state.permission && !state.question && !state.picker
|
|
324
|
-
const isWizard = state.wizard?.step === "provider"
|
|
325
|
-
// Content + cursor in a single write. Hardware cursor stays hidden —
|
|
326
|
-
// the visual cursor is drawn in the input box as SGR reverse video.
|
|
327
|
-
// Position for IME, hide for visual (matching pi-tui).
|
|
328
|
-
if (isStreaming) {
|
|
329
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
330
|
-
} else if (isWizard) {
|
|
331
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
|
|
332
|
-
} else {
|
|
333
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
334
|
-
}
|
|
335
|
-
return
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ---- Incremental rendering (layout stable) ----
|
|
339
|
-
// pi-tui pattern: content inside sync-update block; cursor outside.
|
|
340
|
-
// DECSET 2026 buffers all panel writes and renders them atomically.
|
|
341
|
-
// Cursor hide/show/position MUST be outside — otherwise the terminal's
|
|
342
|
-
// internal cursor state machine and the sync render buffer can disagree.
|
|
343
|
-
const out = []
|
|
344
|
-
const push = (s) => { if (s != null) out.push(s) }
|
|
345
|
-
|
|
346
|
-
// Always-visible panels
|
|
347
|
-
push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
|
|
348
|
-
push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
|
|
349
|
-
push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
|
|
350
|
-
|
|
351
|
-
// Conversation: line-level cache — only push changed lines
|
|
352
|
-
const convKey = convCacheKey(state)
|
|
353
|
-
const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
|
|
354
|
-
if (convChanged) {
|
|
355
|
-
lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
|
|
356
|
-
const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
|
|
357
|
-
const y = panels.conversation.y + 1
|
|
358
|
-
for (let i = 0; i < lines.length; i++) {
|
|
359
|
-
if (lines[i] !== convLineCache[i]) {
|
|
360
|
-
out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
|
|
361
|
-
convLineCache[i] = lines[i]
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
if (convLineCache.length > lines.length) {
|
|
365
|
-
for (let i = lines.length; i < convLineCache.length; i++) {
|
|
366
|
-
out.push(`\x1b[${y + i};1H\x1b[K`)
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
convLineCache.length = lines.length
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Conditional panels
|
|
373
|
-
push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
|
|
374
|
-
push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
|
|
375
|
-
push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
|
|
376
|
-
push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
|
|
377
|
-
if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
|
|
378
|
-
else panelCache.delete("queue")
|
|
379
|
-
if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
|
|
380
|
-
else panelCache.delete("picker")
|
|
381
|
-
|
|
382
|
-
// Determine cursor suffix — appended to the same write() as the sync block.
|
|
383
|
-
// MUST position the cursor at the input box even when hidden: the terminal's
|
|
384
|
-
// cursor position determines where the IME candidate window appears.
|
|
385
|
-
// pi-tui's positionHardwareCursor does the same — positions first, then
|
|
386
|
-
// decides show/hide based on showHardwareCursor.
|
|
387
|
-
// Hardware cursor stays hidden — the visual cursor is drawn in the input
|
|
388
|
-
// box text as SGR reverse video (matching pi-tui's approach).
|
|
389
|
-
// We still position the hardware cursor for IME candidate window placement.
|
|
390
|
-
const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
391
|
-
const cc = 3 + inputLayout.cursorCol
|
|
392
|
-
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
393
|
-
const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
|
|
394
|
-
|
|
395
|
-
// Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
|
|
396
|
-
if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
397
|
-
} catch (e) {
|
|
398
|
-
// Don't let a render error crash the TUI
|
|
399
|
-
}
|
|
400
|
-
}
|
|
243
|
+
const renderLoop = createRenderLoop(state, agent,
|
|
244
|
+
{ startupDims: { cols: startupCols, rows: startupRows }, SLASH_COMMANDS,
|
|
245
|
+
pendingNoticeReady, get showUpdateNotice() { return showUpdateNotice } },
|
|
246
|
+
pushLine)
|
|
247
|
+
const { render, scheduleRender } = renderLoop
|
|
401
248
|
|
|
402
249
|
process.stdout.on("resize", () => {
|
|
403
250
|
try { render() } catch { /* resize error — ignore */ }
|
|
@@ -417,11 +264,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
417
264
|
// Slash commands: handled locally, don't enter agent loop
|
|
418
265
|
if (text.startsWith("/")) {
|
|
419
266
|
if (state.processing) {
|
|
420
|
-
// While processing:
|
|
421
|
-
//
|
|
422
|
-
const cmd0 = text.split(/\s+/)[0]
|
|
423
|
-
const
|
|
424
|
-
const resolved0 = ALIASES[cmd0] ?? cmd0
|
|
267
|
+
// While processing: allowlisted commands execute directly (they only touch
|
|
268
|
+
// local TUI/agent config, never the in-flight turn); the rest are queued
|
|
269
|
+
const cmd0 = text.split(/\s+/)[0].toLowerCase()
|
|
270
|
+
const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
|
|
425
271
|
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
426
272
|
if (safeDuringProcessing.has(resolved0)) {
|
|
427
273
|
await handleSlash(text)
|
|
@@ -472,7 +318,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
472
318
|
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
473
319
|
|
|
474
320
|
// Model picker + generic picker: implemented in pickers.mjs
|
|
475
|
-
const {
|
|
321
|
+
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
|
|
476
322
|
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
477
323
|
})
|
|
478
324
|
|
|
@@ -489,9 +335,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
489
335
|
const { handleSlash, completions, handleTab } = createSlashCommands({
|
|
490
336
|
agent, state, distillOpts,
|
|
491
337
|
pushLine, pushLabel, render,
|
|
492
|
-
|
|
338
|
+
showPicker, closePicker, askQuestion, askPermission,
|
|
493
339
|
persistRaw, syncProviderField, maskKey,
|
|
494
340
|
openModelPicker: () => openModelPicker(),
|
|
341
|
+
selectModel,
|
|
495
342
|
setProviderKey,
|
|
496
343
|
runDistill,
|
|
497
344
|
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
@@ -503,7 +350,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
503
350
|
|
|
504
351
|
// keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
|
|
505
352
|
const onKeypress = createKeyHandler({
|
|
506
|
-
agent, state, render,
|
|
353
|
+
agent, state, render, popPicker, renderPickerLines,
|
|
507
354
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
508
355
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
509
356
|
renderWizard, pushLine, cleanup,
|
|
@@ -523,6 +370,30 @@ export async function startTUI(agent, opts = {}) {
|
|
|
523
370
|
backgroundIndex({ agent, state, render })
|
|
524
371
|
|
|
525
372
|
// Check for updates (non-blocking, after startup screen)
|
|
373
|
+
// 有 picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
|
|
374
|
+
const showUpdateNotice = async (result) => {
|
|
375
|
+
const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
|
|
376
|
+
{ type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
|
|
377
|
+
{ type: "item", text: "Upgrade now", action: "upgrade" },
|
|
378
|
+
{ type: "item", text: "Later", action: "later" },
|
|
379
|
+
])
|
|
380
|
+
if (sel?.action !== "upgrade") return
|
|
381
|
+
pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
|
|
382
|
+
pushLine(`Upgrading to ${result.latest}...`, C.tool)
|
|
383
|
+
const { exec } = await import("node:child_process")
|
|
384
|
+
const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
|
|
385
|
+
let stdout = ""
|
|
386
|
+
cp.stdout?.on("data", (d) => { stdout += d })
|
|
387
|
+
cp.stderr?.on("data", (d) => { stdout += d })
|
|
388
|
+
cp.on("close", (code) => {
|
|
389
|
+
if (code === 0) {
|
|
390
|
+
pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
|
|
391
|
+
} else {
|
|
392
|
+
pushLine(upgradeFailureText(code, stdout), C.error)
|
|
393
|
+
}
|
|
394
|
+
render()
|
|
395
|
+
})
|
|
396
|
+
}
|
|
526
397
|
;(async () => {
|
|
527
398
|
try {
|
|
528
399
|
const { readFileSync } = await import("node:fs")
|
|
@@ -535,33 +406,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
535
406
|
pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
|
|
536
407
|
render()
|
|
537
408
|
} else {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
entries: [
|
|
541
|
-
{ type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
|
|
542
|
-
{ type: "item", text: "Upgrade now", action: "upgrade" },
|
|
543
|
-
{ type: "item", text: "Later", action: "later" },
|
|
544
|
-
],
|
|
545
|
-
onSelect: async (sel) => {
|
|
546
|
-
if (sel.action === "upgrade") {
|
|
547
|
-
pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
|
|
548
|
-
pushLine(`Upgrading to ${result.latest}...`, C.tool)
|
|
549
|
-
const { exec } = await import("node:child_process")
|
|
550
|
-
const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
|
|
551
|
-
let stdout = ""
|
|
552
|
-
cp.stdout?.on("data", (d) => { stdout += d })
|
|
553
|
-
cp.stderr?.on("data", (d) => { stdout += d })
|
|
554
|
-
cp.on("close", (code) => {
|
|
555
|
-
if (code === 0) {
|
|
556
|
-
pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
|
|
557
|
-
} else {
|
|
558
|
-
pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
|
|
559
|
-
}
|
|
560
|
-
render()
|
|
561
|
-
})
|
|
562
|
-
}
|
|
563
|
-
},
|
|
564
|
-
})
|
|
409
|
+
state.pendingNotice = result
|
|
410
|
+
render()
|
|
565
411
|
}
|
|
566
412
|
}
|
|
567
413
|
} catch { /* network error or timeout — silently skip */ }
|