thincoder 0.12.64 → 0.12.66
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/CHANGELOG.md +32 -0
- package/README.md +102 -42
- package/bin/thincoder.mjs +18 -0
- package/package.json +2 -2
- package/src/acp/handlers-session.mjs +25 -1
- package/src/acp/handlers-slots.mjs +1 -1
- package/src/tui/agent-turn.mjs +5 -2
- package/src/tui/cmd-advisor.mjs +1 -1
- package/src/tui/cmd-eng.mjs +2 -1
- package/src/tui/cmd-mcp-form.mjs +11 -5
- package/src/tui/conversation-writer.mjs +62 -0
- package/src/tui/index.mjs +43 -305
- package/src/tui/input-face.mjs +187 -0
- package/src/tui/key-handler-busy.mjs +42 -0
- package/src/tui/key-handler-ctrlc.mjs +118 -0
- package/src/tui/key-handler-edit.mjs +115 -0
- package/src/tui/key-handler-modals.mjs +87 -0
- package/src/tui/key-handler-scroll.mjs +110 -0
- package/src/tui/key-handler.mjs +32 -382
- package/src/tui/render-frame.mjs +8 -3
- package/src/tui/subagent-freeze.mjs +3 -3
- package/src/tui/suspension-drive.mjs +10 -9
- package/src/tui/tui-state.mjs +65 -0
- package/src/tui/turn-face.mjs +64 -0
package/src/tui/cmd-mcp-form.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { C } from "./ansi.mjs"
|
|
2
|
+
// #58(hygiene-sweep 批):headers/env 现值脱敏判据 = 核 settings 同源单点(不再各自为政)。
|
|
3
|
+
import { isSensitiveKey } from "@thincoder/core/agent-tools/settings.mjs"
|
|
2
4
|
|
|
3
5
|
/** MCP.md §5 v2(D-1):edit/add 统一字段 picker 表单机制。
|
|
4
6
|
* fieldPicker 循环:picker 列字段行(label + 当前值打码)+ `✓ Save & test` 末行;
|
|
@@ -111,15 +113,19 @@ function formEntries(entry, transport, mode) {
|
|
|
111
113
|
]
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
/**
|
|
116
|
+
/** #58(hygiene-sweep 批):headers/env 现值列示——敏感键名(谓词 = 核 settings 同源
|
|
117
|
+
* `isSensitiveKey`)的值位一律 `••••`(人读面不再明文);非敏感键名保可读(`k=v`)。 */
|
|
118
|
+
function currentPairs(v) {
|
|
119
|
+
const pairs = Object.entries(v ?? {}).map(([k, val]) => (isSensitiveKey(k) ? `${k}=••••` : `${k}=${val}`))
|
|
120
|
+
return pairs.length ? pairs.join(", ") : "none"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 字段输入提示 `(current: …)`——token 打码(maskToken)、headers/env 列键值对(敏感值位遮)、
|
|
115
124
|
* args 空格串接;空值 → "none"。 */
|
|
116
125
|
function currentText(entry, field) {
|
|
117
126
|
const v = entry[field]
|
|
118
127
|
if (field === "token") return v ? maskToken(v) : "none"
|
|
119
|
-
if (field === "headers" || field === "env")
|
|
120
|
-
const pairs = Object.entries(v ?? {}).map(([k, val]) => `${k}=${val}`)
|
|
121
|
-
return pairs.length ? pairs.join(", ") : "none"
|
|
122
|
-
}
|
|
128
|
+
if (field === "headers" || field === "env") return currentPairs(v)
|
|
123
129
|
if (field === "args") return (v ?? []).length ? v.join(" ") : "none"
|
|
124
130
|
return String(v ?? "") || "none"
|
|
125
131
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
|
|
3
|
+
import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
|
|
4
|
+
|
|
5
|
+
/** 对话写入面(2026-09-22 structure-debt §2.2 · #159):pushLine(行额度单点)/ pushLabel
|
|
6
|
+
* (消息标签 + 呼吸空行)/ ensureAssistantLabel(回合内单次 assistant 标签位)——三块自
|
|
7
|
+
* index.mjs startTUI 逐字搬移(仅去包裹)。
|
|
8
|
+
* `render` 由调用侧以惰性转发承接(renderLoop 于 startTUI 后段装配——TDZ 语义保持)。
|
|
9
|
+
* @param {{ state: object, render: Function }} ctx
|
|
10
|
+
* @returns {{ pushLine: Function, pushLabel: Function, ensureAssistantLabel: Function,
|
|
11
|
+
* assistantLabeled: boolean }} */
|
|
12
|
+
export function createConversationWriter({ state, render }) {
|
|
13
|
+
const pushLine = (text, color, kind) => {
|
|
14
|
+
// TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):行额度单点——行文本过 capLine
|
|
15
|
+
// (LINE_MAX_CHARS——单行巨内容/无换行巨 chunk 的堵口)+ 总量账 + 预算对账。
|
|
16
|
+
const line = { text: capLine(text), color, _kind: kind }
|
|
17
|
+
state.lines.push(line)
|
|
18
|
+
accountLine(state, line)
|
|
19
|
+
if (state.lines.length > 5000) {
|
|
20
|
+
state.lines.splice(0, 1000)
|
|
21
|
+
state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
|
|
22
|
+
// 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
|
|
23
|
+
// 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
|
|
24
|
+
// = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
|
|
25
|
+
shiftFreezeAnchors(state, 1000)
|
|
26
|
+
accountAll(state) // 行对象集合已变(splice/unshift 直写)——直算重对账
|
|
27
|
+
}
|
|
28
|
+
syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
|
|
29
|
+
render()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Message block label: blank line + label line. Breathing space between user/assistant messages */
|
|
33
|
+
const pushLabel = (text, color) => {
|
|
34
|
+
// 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
|
|
35
|
+
if (state.lines.length > 0) {
|
|
36
|
+
const blank = { text: "", color: C.dim }
|
|
37
|
+
state.lines.push(blank)
|
|
38
|
+
accountLine(state, blank)
|
|
39
|
+
}
|
|
40
|
+
const line = { text: capLine(text), color }
|
|
41
|
+
state.lines.push(line)
|
|
42
|
+
accountLine(state, line)
|
|
43
|
+
syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
|
|
44
|
+
render()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
48
|
+
let assistantLabeled = false
|
|
49
|
+
const ensureAssistantLabel = () => {
|
|
50
|
+
if (!assistantLabeled) {
|
|
51
|
+
assistantLabeled = true
|
|
52
|
+
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
pushLine,
|
|
57
|
+
pushLabel,
|
|
58
|
+
ensureAssistantLabel,
|
|
59
|
+
get assistantLabeled() { return assistantLabeled },
|
|
60
|
+
set assistantLabeled(v) { assistantLabeled = v },
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -16,32 +16,32 @@
|
|
|
16
16
|
* distill-cmd.mjs — /distill command
|
|
17
17
|
* mouse.mjs — SGR mouse parsing + dispatch assembly (createMouseDispatch)
|
|
18
18
|
* update-notice.mjs — background update notice + startup check
|
|
19
|
+
* tui-state.mjs — TUI state literal factory(structure-debt #159)
|
|
20
|
+
* input-face.mjs — 输入面:stdin 流 + data 处理器 + 键盘/鼠标后置挂载入口(二段接口)
|
|
21
|
+
* conversation-writer.mjs — 对话写入面:pushLine / pushLabel / assistant 标签位
|
|
22
|
+
* turn-face.mjs — 回合面:submit / interaction / 粘贴 / turnCtx / turn
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
|
-
import { emitKeypressEvents } from "node:readline"
|
|
22
|
-
import { readFileSync } from "node:fs"
|
|
23
|
-
import { PassThrough } from "node:stream"
|
|
24
25
|
import { saveSession } from "@thincoder/core/session.mjs"
|
|
25
26
|
import { closeAllMcp } from "@thincoder/core/mcp.mjs"
|
|
26
|
-
import { ansi, C } from "./ansi.mjs"
|
|
27
27
|
import { createRenderLoop } from "./render-loop.mjs"
|
|
28
|
-
import {
|
|
28
|
+
import { ansi, C } from "./ansi.mjs"
|
|
29
29
|
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
30
30
|
import { createWizard } from "./wizard.mjs"
|
|
31
|
-
import {
|
|
31
|
+
import { createExitCleanup } from "./tui-lifecycle.mjs"
|
|
32
32
|
import { createPickers } from "./pickers.mjs"
|
|
33
33
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
import { runAgentTurn } from "./agent-turn.mjs"
|
|
38
|
-
import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
|
|
34
|
+
// F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18):核薄函数——/exit 与 Ctrl+C×2
|
|
35
|
+
// 同一退出语义双入口;退出恒达(永不抛出);零触碰 marker 面(F-XR2 路标保留)。
|
|
36
|
+
import { releaseClaimsAll } from "@thincoder/core/session-slots-manifest.mjs"
|
|
39
37
|
import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
|
|
40
|
-
import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
|
|
41
|
-
import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
|
|
42
38
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
43
39
|
import { createUpdateNotice, pendingNoticeReady } from "./update-notice.mjs"
|
|
44
40
|
import { startLedgerSurface } from "./ledger-surface.mjs"
|
|
41
|
+
import { createTuiState } from "./tui-state.mjs"
|
|
42
|
+
import { createInputFace } from "./input-face.mjs"
|
|
43
|
+
import { createConversationWriter } from "./conversation-writer.mjs"
|
|
44
|
+
import { createTurnFace } from "./turn-face.mjs"
|
|
45
45
|
|
|
46
46
|
export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
|
|
47
47
|
|
|
@@ -85,60 +85,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
85
85
|
// watchdog, agent-turn finally — never in the render path (2026-08-30).
|
|
86
86
|
const startupCols = process.stdout.columns || 80
|
|
87
87
|
const startupRows = process.stdout.rows || 24
|
|
88
|
-
|
|
89
|
-
const state = {
|
|
90
|
-
lines: [], // conversation lines: { text, color }
|
|
91
|
-
streaming: "", // current streaming buffer
|
|
92
|
-
_advisorBlocks: [], // advisor ordered blocks: [{ kind: "think"|"text", text }] — preserves emission order (think ↔ tool interleaving)
|
|
93
|
-
input: [], // input buffer (codepoint array)
|
|
94
|
-
cursor: 0,
|
|
95
|
-
history: [],
|
|
96
|
-
historyIndex: -1,
|
|
97
|
-
_draft: null, // stashed unsent input while navigating history (restored on down past newest)
|
|
98
|
-
scroll: 0, // scroll lines from bottom upward
|
|
99
|
-
_foldScroll: new Map(), // 2026-08-31 块内滚动:foldKey → 窗口 offset(展开块 ▲▼ 翻窗)
|
|
100
|
-
_followTail: true, // 2026-08-31 流式跟随:渲染前 scroll=0;用户上滚暂停、到底/新消息恢复
|
|
101
|
-
processing: false,
|
|
102
|
-
controller: null, // AbortController for current agent run
|
|
103
|
-
permission: null, // { name, args, resolve }
|
|
104
|
-
permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
|
|
105
|
-
question: null, // { text, options, resolve } — agent question tool callback
|
|
106
|
-
picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
|
|
107
|
-
pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
|
|
108
|
-
pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
|
|
109
|
-
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
110
|
-
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
111
|
-
dims: makeDimsState({ cols: startupCols, rows: startupRows }), // terminal dims single source (Windows ConPTY instability, 2026-08-30) — seeded pre-raw-mode, re-sampled by event hooks only (startup retry / resize / idle watchdog)
|
|
112
|
-
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
|
|
113
|
-
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
114
|
-
reasoning: "", // thinking stream buffer (dimmed display)
|
|
115
|
-
completion: null, // Tab completion state { candidates, index }
|
|
116
|
-
|
|
117
|
-
subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch, awaitingDigest(§17 挂起中间态), _freezeAt(冻结锚点), stopped, children: [](SUBAGENT-TAIL:嵌套子代理**守护载体**——内容行并入本块 blocks——subagent-children.mjs) } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer(SUBAGENT-TAIL 单环:内层行同环计数、单载体最旧先行——TUI.md §6)
|
|
118
|
-
currentTool: null, // currently executing tool name (shown in status bar)
|
|
119
|
-
processingStarted: 0, // current turn start time (status bar timer)
|
|
120
|
-
status: "Ready",
|
|
121
|
-
ledger: { marker: null, warn: false, scannedAt: 0 }, // LEDGER-SURFACE(§2.30.3.4):L1 标记位——ledger-surface 写 / render-frame 读
|
|
122
|
-
queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
|
|
123
|
-
interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
|
|
124
|
-
attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
|
|
125
|
-
search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
|
|
126
|
-
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
127
|
-
foldEnabled: true, // global fold toggle — /fold on|off
|
|
128
|
-
exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
|
|
129
|
-
// Lazy history window (parity with VS Code): only the latest messages are
|
|
130
|
-
// materialized on restore; PgUp-at-top loads earlier pages via loadOlder.
|
|
131
|
-
_historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
|
|
132
|
-
_historyTotal: 0, // total messages in the restored session
|
|
133
|
-
_hasOlder: false, // more earlier messages remain unloaded
|
|
134
|
-
_linesChars: 0, // TUI-OOM-ROOTCAUSE(§15.3.2):state.lines 全部行与载体文本总量(字符账——唯一新增状态位)
|
|
135
|
-
_agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
|
|
136
|
-
// ① SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
|
|
137
|
-
// ② CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
|
|
138
|
-
// action:"panel" 经 ctx.state(= agent._tuiState)**读时现算**面板块(computePanelBlocks
|
|
139
|
-
// ——subTasks 活值纯推导——单账本)。headless/VS Code 无此装配 = 无面板 → panel 动作
|
|
140
|
-
// 恒降级池视图(CLI-only 完整能力——AC-P4)。
|
|
141
|
-
}
|
|
88
|
+
const state = createTuiState({ cols: startupCols, rows: startupRows, agent })
|
|
142
89
|
state._agent = agent
|
|
143
90
|
agent._tuiState = state // F-3:面板视图现算通道(替代退役的手工面板镜像)
|
|
144
91
|
|
|
@@ -147,188 +94,23 @@ export async function startTUI(agent, opts = {}) {
|
|
|
147
94
|
state.tasks = []
|
|
148
95
|
}
|
|
149
96
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Keyboard enhancement — enable BOTH protocols (unsupported terminals ignore them):
|
|
159
|
-
// kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
|
|
160
|
-
// modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
|
|
161
|
-
// translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
|
|
162
|
-
writeStartupSequence()
|
|
163
|
-
// 启动加载画面(2026-09-21 用户):冷启动剩余期(memory 同步 / 索引扫描)非黑。
|
|
164
|
-
// 静态读(render-frame 先例——module-load 一次 ✗ 首帧 render 覆盖本行 ✗ 无需清除逻辑)。
|
|
165
|
-
{
|
|
166
|
-
let v = ""
|
|
167
|
-
try { v = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version } catch { /* 尽力面 */ }
|
|
168
|
-
writeLoadingLine(undefined, v)
|
|
169
|
-
}
|
|
170
|
-
setTuiActive(true) // R25(F-R25a):终端接管完成——置 TUI 活动态(崩溃钩子恢复判定源)
|
|
171
|
-
|
|
172
|
-
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
173
|
-
|
|
174
|
-
let pasteMode = false
|
|
175
|
-
let pasteAccum = ""
|
|
176
|
-
|
|
177
|
-
process.stdin.on("data", (chunk) => {
|
|
178
|
-
try {
|
|
179
|
-
|
|
180
|
-
let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
|
|
181
|
-
mousePending = ""
|
|
182
|
-
|
|
183
|
-
// 第 33 批(TUI §14.3(e) 鼠标点):stdin 数据到达即用户在场 ⇒ 清 attention 位
|
|
184
|
-
// (单点覆盖下方滚轮分支与 onMouseClick;键盘入口在 key-handler)。
|
|
185
|
-
clearAttention(state, render)
|
|
186
|
-
|
|
187
|
-
// Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
|
|
188
|
-
// Route pasted content to the active text target (question answer / input box) in one shot,
|
|
189
|
-
// avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
|
|
190
|
-
if (pasteMode) {
|
|
191
|
-
const endIdx = text.indexOf("\x1b[201~")
|
|
192
|
-
if (endIdx >= 0) {
|
|
193
|
-
pasteAccum += text.slice(0, endIdx)
|
|
194
|
-
pasteMode = false
|
|
195
|
-
const pasted = pasteAccum
|
|
196
|
-
pasteAccum = ""
|
|
197
|
-
if (pasted) {
|
|
198
|
-
insertPastedText(state, pasted)
|
|
199
|
-
render()
|
|
200
|
-
}
|
|
201
|
-
text = text.slice(endIdx + 6)
|
|
202
|
-
} else {
|
|
203
|
-
pasteAccum += text
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Check for paste start (may appear mid-chunk alongside other input)
|
|
209
|
-
const pasteStartIdx = text.indexOf("\x1b[200~")
|
|
210
|
-
if (pasteStartIdx >= 0) {
|
|
211
|
-
const before = text.slice(0, pasteStartIdx)
|
|
212
|
-
const after = text.slice(pasteStartIdx + 6)
|
|
213
|
-
const endIdx = after.indexOf("\x1b[201~")
|
|
214
|
-
if (endIdx >= 0) {
|
|
215
|
-
// Paste begin and end in the same chunk: insert pasted content directly
|
|
216
|
-
const pasted = after.slice(0, endIdx)
|
|
217
|
-
if (pasted) {
|
|
218
|
-
insertPastedText(state, pasted)
|
|
219
|
-
render()
|
|
220
|
-
}
|
|
221
|
-
text = before + after.slice(endIdx + 6)
|
|
222
|
-
} else {
|
|
223
|
-
// Paste spans multiple chunks: write prefix, enter paste mode
|
|
224
|
-
if (before) keyStream.write(before)
|
|
225
|
-
pasteMode = true
|
|
226
|
-
pasteAccum = after
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
232
|
-
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
233
|
-
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
234
|
-
const button = Number(m[1])
|
|
235
|
-
if (button === 64 || button === 65) {
|
|
236
|
-
// F-3 sane-gate ③(RESIZE-MOUSE-LEAK-FIX):越界 wheel 禁止会话滚动——handleWheel
|
|
237
|
-
// 对越界返回未消费会穿到 fallback 滚动(mouse.mjs 内 gate 覆盖不到此路径)
|
|
238
|
-
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
239
|
-
if (mouseOob(Number(m[2]), Number(m[3]), dims)) continue
|
|
240
|
-
const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
|
|
241
|
-
if (!consumed) {
|
|
242
|
-
if (button === 64) {
|
|
243
|
-
state.scroll += 3
|
|
244
|
-
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随(不抢视角)
|
|
245
|
-
// 2026-08-31 用户约定修复:滚动到头自动加载(原来只挂 PgUp 键——违约)
|
|
246
|
-
if (state._hasOlder && state.scroll >= convMaxScroll(state)) loadOlder()
|
|
247
|
-
} else {
|
|
248
|
-
state.scroll = Math.max(0, state.scroll - 3)
|
|
249
|
-
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Left-click: \x1b[<0;col;rowM → picker selection / line action menu
|
|
256
|
-
for (const click of parseMouseClicks(text)) {
|
|
257
|
-
try {
|
|
258
|
-
onMouseClick(click.col, click.row)
|
|
259
|
-
} catch (e) {
|
|
260
|
-
pushLine(`[mouse] ${e.message || e}`, C.error)
|
|
261
|
-
render()
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
266
|
-
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
267
|
-
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
268
|
-
if (tail) {
|
|
269
|
-
mousePending = tail[0]
|
|
270
|
-
text = text.slice(0, -tail[0].length)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
|
|
274
|
-
text = translateShiftEnter(text)
|
|
275
|
-
text = stripKeyboardProtocol(text)
|
|
276
|
-
|
|
277
|
-
if (state.scroll !== lastRenderedScroll) {
|
|
278
|
-
lastRenderedScroll = state.scroll
|
|
279
|
-
render()
|
|
280
|
-
}
|
|
281
|
-
if (text) keyStream.write(text)
|
|
282
|
-
} catch (e) {
|
|
283
|
-
pushLine(`[input-error] ${e.message || e}`, C.error)
|
|
284
|
-
}
|
|
97
|
+
// 输入面(input-face.mjs,原址 :153 早挂载):stdin 流过滤 / raw mode / 启动序列 / 解码器 +
|
|
98
|
+
// data 处理器整块;render / pushLine / loadOlder 以惰性取值器承接(晚定义名——TDZ 语义保持),
|
|
99
|
+
// 键盘 ③ 与鼠标 ④ 后置挂载入口在命令层之后按原址调用(构造期读值面——见 invariant §2.2)。
|
|
100
|
+
const inputFace = createInputFace({
|
|
101
|
+
state,
|
|
102
|
+
get render() { return render },
|
|
103
|
+
get pushLine() { return pushLine },
|
|
104
|
+
get loadOlder() { return loadOlder },
|
|
285
105
|
})
|
|
286
106
|
|
|
287
107
|
const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
|
|
288
108
|
process.on("exit", cleanup)
|
|
289
109
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
state.lines.push(line)
|
|
295
|
-
accountLine(state, line)
|
|
296
|
-
if (state.lines.length > 5000) {
|
|
297
|
-
state.lines.splice(0, 1000)
|
|
298
|
-
state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
|
|
299
|
-
// 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
|
|
300
|
-
// 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
|
|
301
|
-
// = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
|
|
302
|
-
shiftFreezeAnchors(state, 1000)
|
|
303
|
-
accountAll(state) // 行对象集合已变(splice/unshift 直写)——直算重对账
|
|
304
|
-
}
|
|
305
|
-
syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
|
|
306
|
-
render()
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** Message block label: blank line + label line. Breathing space between user/assistant messages */
|
|
310
|
-
const pushLabel = (text, color) => {
|
|
311
|
-
// 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
|
|
312
|
-
if (state.lines.length > 0) {
|
|
313
|
-
const blank = { text: "", color: C.dim }
|
|
314
|
-
state.lines.push(blank)
|
|
315
|
-
accountLine(state, blank)
|
|
316
|
-
}
|
|
317
|
-
const line = { text: capLine(text), color }
|
|
318
|
-
state.lines.push(line)
|
|
319
|
-
accountLine(state, line)
|
|
320
|
-
syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
|
|
321
|
-
render()
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
325
|
-
let assistantLabeled = false
|
|
326
|
-
const ensureAssistantLabel = () => {
|
|
327
|
-
if (!assistantLabeled) {
|
|
328
|
-
assistantLabeled = true
|
|
329
|
-
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
330
|
-
}
|
|
331
|
-
}
|
|
110
|
+
// 对话写入面(conversation-writer.mjs,原址 :293):行额度单点 + 消息标签 + assistant 标签位
|
|
111
|
+
// (render 以惰性转发承接——renderLoop 于下方装配)
|
|
112
|
+
const conversation = createConversationWriter({ state, render: () => render() })
|
|
113
|
+
const { pushLine, pushLabel, ensureAssistantLabel } = conversation
|
|
332
114
|
|
|
333
115
|
// ---------------------------------------------------------- Render
|
|
334
116
|
|
|
@@ -358,56 +140,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
358
140
|
try { state.dims.refresh(); render() } catch { /* resize error — ignore */ }
|
|
359
141
|
})
|
|
360
142
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (!text) return
|
|
366
|
-
// INPUT-LOCK 防御(C'——2026-09-09)+ INPUT-LOCK-BEHAVIOR-REVISED(2026-09-09——白名单
|
|
367
|
-
// 已删——busy 全拒):提交吞的门禁在 key-handler(busy Enter 已吞 + 提示,文本保留)——
|
|
368
|
-
// submit 只在非 busy 期达此;防御直呼/上游改动:busy 期拒绝(不清输入框不吞内容)。
|
|
369
|
-
if (state.processing) {
|
|
370
|
-
pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
|
|
371
|
-
render()
|
|
372
|
-
return
|
|
373
|
-
}
|
|
374
|
-
state.input = []
|
|
375
|
-
state.cursor = 0
|
|
376
|
-
const wasInHistory = state.historyIndex !== -1
|
|
377
|
-
state.history.push(text) // review #3 fix: single push (was duplicated — every submit appeared twice in ↑/↓ history)
|
|
378
|
-
state.historyIndex = -1
|
|
379
|
-
if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
|
|
380
|
-
state.scroll = 0
|
|
381
|
-
state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
|
|
382
|
-
|
|
383
|
-
// Slash commands: handled locally, don't enter agent loop. busy 期提交在 key-handler
|
|
384
|
-
// 门禁被吞(白名单已删——斜杠同吞——INPUT-LOCK-BEHAVIOR-REVISED)——submit 仅非 busy 期可达。
|
|
385
|
-
if (text.startsWith("/")) {
|
|
386
|
-
await handleSlash(text)
|
|
387
|
-
render()
|
|
388
|
-
return
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
await turn(text)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
|
|
395
|
-
const { askPermission, askQuestion, askBatchPermission } = createInteraction({
|
|
396
|
-
agent, state, pushLine, pushLabel, render, summarize,
|
|
143
|
+
// 回合面(turn-face.mjs,原址 :366):submit / interaction(权限 + 提问)/ 图片粘贴 / turnCtx / turn
|
|
144
|
+
const { submit, turnCtx, askPermission, askQuestion, pasteClipboardImage } = createTurnFace({
|
|
145
|
+
agent, state, pushLine, pushLabel, render, ensureAssistantLabel, summarize, conversation,
|
|
146
|
+
handleSlash: (t) => handleSlash(t),
|
|
397
147
|
})
|
|
398
148
|
|
|
399
|
-
// Clipboard image paste: implemented in clipboard.mjs
|
|
400
|
-
const pasteClipboardImage = () => pasteClipboardImageImpl({ agent, state, pushLine, render })
|
|
401
|
-
|
|
402
|
-
// Agent loop: implemented in agent-turn.mjs
|
|
403
|
-
const turnCtx = {
|
|
404
|
-
agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
|
|
405
|
-
askPermission, askQuestion, askBatchPermission, handleSlash: null,
|
|
406
|
-
get assistantLabeled() { return assistantLabeled },
|
|
407
|
-
set assistantLabeled(v) { assistantLabeled = v },
|
|
408
|
-
}
|
|
409
|
-
const turn = (text) => runAgentTurn(turnCtx, text)
|
|
410
|
-
|
|
411
149
|
// ---------------------------------------------------------- Slash Commands
|
|
412
150
|
|
|
413
151
|
// Config helpers: implemented in config-helpers.mjs
|
|
@@ -438,31 +176,28 @@ export async function startTUI(agent, opts = {}) {
|
|
|
438
176
|
setProviderKey,
|
|
439
177
|
pickModelForSlot,
|
|
440
178
|
runDistill,
|
|
441
|
-
exit: () => {
|
|
179
|
+
exit: () => {
|
|
180
|
+
cleanup()
|
|
181
|
+
// F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18):同 key-handler 退出分支——
|
|
182
|
+
// 先释放(同步完成)后定时器注册(D-SE42——100ms 窗零竞态)。
|
|
183
|
+
releaseClaimsAll(process.cwd())
|
|
184
|
+
setTimeout(() => process.exit(0), 100)
|
|
185
|
+
},
|
|
442
186
|
})
|
|
443
187
|
// handleSlash is referenced by turnCtx (circular dep: submit → turn → handleSlash), backfilled here
|
|
444
188
|
turnCtx.handleSlash = handleSlash
|
|
445
189
|
|
|
446
190
|
// ---------------------------------------------------------- Keyboard / Mouse
|
|
447
191
|
|
|
448
|
-
|
|
449
|
-
const onKeypress = createKeyHandler({
|
|
192
|
+
inputFace.mountKeys({
|
|
450
193
|
agent, state, render, popPicker, renderPickerLines,
|
|
451
194
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
452
195
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
453
196
|
renderWizard, pushLine, cleanup, showPicker, loadOlder,
|
|
454
197
|
})
|
|
455
|
-
keyStream.on("keypress", (str, key) => {
|
|
456
|
-
try {
|
|
457
|
-
onKeypress(str, key)
|
|
458
|
-
} catch (e) {
|
|
459
|
-
pushLine(`[input-error] ${e.message || e}`, C.error)
|
|
460
|
-
render()
|
|
461
|
-
}
|
|
462
|
-
})
|
|
463
198
|
|
|
464
199
|
// 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
|
|
465
|
-
|
|
200
|
+
inputFace.mountMouse({ agent, state, pushLine, render, popPicker })
|
|
466
201
|
|
|
467
202
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
468
203
|
|
|
@@ -473,7 +208,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
473
208
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
474
209
|
// LEDGER-SURFACE(§2.30.3.4):台账可见面——首扫(setImmediate)+ 周期;dispose 挂进程退出(:278 cleanup 先例)
|
|
475
210
|
const ledgerSurface = startLedgerSurface({ state, agent, pushLine, render })
|
|
476
|
-
|
|
211
|
+
// 前置缺陷最小修(2026-09-22 structure-debt · 父侧授权 · 只修调用点):K-LX3 归核后
|
|
212
|
+
// `startLedgerSurface` 返回 **Promise**(resolve 出 `{ dispose }`)——原同步 `.dispose()`
|
|
213
|
+
// 令每次退钩抛 TypeError(`node test-startup.mjs` 前置红,HEAD 逐字同款);按 Promise 承接。
|
|
214
|
+
process.on("exit", () => { void Promise.resolve(ledgerSurface).then((s) => s?.dispose?.()) })
|
|
477
215
|
backgroundIndex({ agent, state, render })
|
|
478
216
|
|
|
479
217
|
// Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
|