thincoder 0.12.53 → 0.12.58
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 +74 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +16 -3
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +110 -22
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +17 -3
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +90 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +282 -306
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -8
- package/src/tools/git.mjs +55 -177
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +28 -27
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +41 -2
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +40 -16
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +61 -16
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tui/index.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { createRenderLoop } from "./render-loop.mjs"
|
|
|
25
25
|
import { makeDimsState } from "./dims.mjs"
|
|
26
26
|
import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
|
|
27
27
|
import { createWizard } from "./wizard.mjs"
|
|
28
|
+
import { writeStartupSequence, createExitCleanup } from "./tui-lifecycle.mjs"
|
|
28
29
|
import { createPickers } from "./pickers.mjs"
|
|
29
30
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
30
31
|
import { createInteraction } from "./interaction.mjs"
|
|
@@ -47,6 +48,22 @@ export function pendingNoticeReady(state) {
|
|
|
47
48
|
return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
|
|
53
|
+
* provider 无效(`_providerInvalid` 标记或 provider 为 null)→ 先弹模型选择 picker
|
|
54
|
+
* (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
|
|
55
|
+
* 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 选择或
|
|
56
|
+
* /provider 配置")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
|
|
57
|
+
*/
|
|
58
|
+
export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
|
|
59
|
+
if (!(agent._providerInvalid || !agent.provider)) return false
|
|
60
|
+
await openModelPicker()
|
|
61
|
+
if (!agent.provider) {
|
|
62
|
+
pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/**
|
|
51
68
|
* Start the TUI, taking over the terminal until exit.
|
|
52
69
|
* agent: return value of createAgent
|
|
@@ -129,7 +146,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
129
146
|
// kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
|
|
130
147
|
// modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
|
|
131
148
|
// translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
|
|
132
|
-
|
|
149
|
+
writeStartupSequence()
|
|
133
150
|
|
|
134
151
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
135
152
|
|
|
@@ -220,7 +237,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
220
237
|
|
|
221
238
|
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
222
239
|
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
223
|
-
//
|
|
240
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
224
241
|
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
225
242
|
const button = Number(m[1])
|
|
226
243
|
if (button === 64 || button === 65) {
|
|
@@ -250,9 +267,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
253
|
-
//
|
|
270
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
254
271
|
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
255
|
-
//
|
|
272
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
256
273
|
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
257
274
|
if (tail) {
|
|
258
275
|
mousePending = tail[0]
|
|
@@ -273,27 +290,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
273
290
|
}
|
|
274
291
|
})
|
|
275
292
|
|
|
276
|
-
|
|
277
|
-
const cleanup = () => {
|
|
278
|
-
if (cleanedUp) return
|
|
279
|
-
cleanedUp = true
|
|
280
|
-
// Save session before exit (synchronous write).
|
|
281
|
-
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
282
|
-
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
283
|
-
try {
|
|
284
|
-
saveSession(agent)
|
|
285
|
-
} catch {
|
|
286
|
-
// Save failure shouldn't block exit
|
|
287
|
-
}
|
|
288
|
-
// Kill MCP stdio subprocesses, don't leave orphans
|
|
289
|
-
try {
|
|
290
|
-
closeAllMcp(agent)
|
|
291
|
-
} catch {
|
|
292
|
-
// Can't close? fine, process is exiting anyway
|
|
293
|
-
}
|
|
294
|
-
process.stdin.setRawMode(false)
|
|
295
|
-
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
296
|
-
}
|
|
293
|
+
const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
|
|
297
294
|
process.on("exit", cleanup)
|
|
298
295
|
|
|
299
296
|
const pushLine = (text, color, kind) => {
|
|
@@ -387,7 +384,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
387
384
|
}
|
|
388
385
|
|
|
389
386
|
// Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
|
|
390
|
-
const { askPermission, askQuestion } = createInteraction({
|
|
387
|
+
const { askPermission, askQuestion, askBatchPermission } = createInteraction({
|
|
391
388
|
agent, state, pushLine, pushLabel, render, summarize,
|
|
392
389
|
})
|
|
393
390
|
|
|
@@ -397,7 +394,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
397
394
|
// Agent loop: implemented in agent-turn.mjs
|
|
398
395
|
const turnCtx = {
|
|
399
396
|
agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
|
|
400
|
-
askPermission, askQuestion, handleSlash: null,
|
|
397
|
+
askPermission, askQuestion, askBatchPermission, handleSlash: null,
|
|
401
398
|
get assistantLabeled() { return assistantLabeled },
|
|
402
399
|
set assistantLabeled(v) { assistantLabeled = v },
|
|
403
400
|
}
|
|
@@ -462,6 +459,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
462
459
|
|
|
463
460
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
464
461
|
|
|
462
|
+
// SESSION.md §8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
|
|
463
|
+
// → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
|
|
464
|
+
await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
|
|
465
|
+
|
|
465
466
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
466
467
|
backgroundIndex({ agent, state, render })
|
|
467
468
|
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -62,6 +62,33 @@ export function createInteraction(ctx) {
|
|
|
62
62
|
})
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
|
|
66
|
+
* non-readonly tools from the same toolCalls array — approve all / one by one /
|
|
67
|
+
* deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
|
|
68
|
+
const BATCH_PREVIEW_LINES_PER_TOOL = 8
|
|
69
|
+
function askBatchPermission({ tools, count }) {
|
|
70
|
+
if (agent.autoApprove) {
|
|
71
|
+
pushLine(` [auto] ${count} tools: ${tools.map((t) => t.name).join(", ")}`, C.warn)
|
|
72
|
+
return Promise.resolve("approveAll")
|
|
73
|
+
}
|
|
74
|
+
const names = [...new Set(tools.map((t) => t.name))]
|
|
75
|
+
const label = `${count} tools need permission: ${names.join(", ")}`
|
|
76
|
+
// Preview: one compact block per tool (name header + first N formatted lines)
|
|
77
|
+
state.permissionPreview = [
|
|
78
|
+
label,
|
|
79
|
+
...tools.flatMap((t) => {
|
|
80
|
+
const lines = formatPermission(t.name, t.args)
|
|
81
|
+
const shown = lines.slice(0, BATCH_PREVIEW_LINES_PER_TOOL)
|
|
82
|
+
return [` ${t.name}:`, ...shown.map((l) => ` ${l}`), ...(lines.length > shown.length ? [` … (${lines.length - shown.length} more lines)`] : [])]
|
|
83
|
+
}),
|
|
84
|
+
]
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
state.permission = { name: label, args: {}, resolve, batch: { tools, count } }
|
|
87
|
+
state.status = `Waiting: ${label}`
|
|
88
|
+
render()
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
function askQuestion(text, options = []) {
|
|
66
93
|
// only one question at a time: question is a read-only tool on a parallel channel,
|
|
67
94
|
// a second concurrent one is rejected directly; otherwise the later one overwrites
|
|
@@ -91,5 +118,5 @@ export function createInteraction(ctx) {
|
|
|
91
118
|
})
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
return { askPermission, askQuestion, formatPermission }
|
|
121
|
+
return { askPermission, askQuestion, formatPermission, askBatchPermission }
|
|
95
122
|
}
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -26,15 +26,27 @@ export function createKeyHandler(ctx) {
|
|
|
26
26
|
|
|
27
27
|
return function onKeypress(str, key = {}) {
|
|
28
28
|
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
29
|
+
// batch state (§16 D-B1): a approve all / o one by one / n deny (Esc = deny)
|
|
29
30
|
if (state.permission) {
|
|
30
31
|
const answer = (str || "").toLowerCase()
|
|
31
32
|
const isContinue = state.permission.name === "continue"
|
|
32
|
-
const
|
|
33
|
+
const isBatch = Boolean(state.permission.batch)
|
|
34
|
+
const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
|
|
33
35
|
if (validKeys.includes(answer) || key.name === "escape") {
|
|
34
36
|
const { resolve, name } = state.permission
|
|
35
37
|
state.permission = null
|
|
36
38
|
state.permissionPreview = []
|
|
37
39
|
state.status = "Processing..."
|
|
40
|
+
if (isBatch) {
|
|
41
|
+
// Merged batch ask: resolve the verdict string; dispatch applies it
|
|
42
|
+
// (approveAll = batch-scope allowance only, NOT the persistent AUTO flag).
|
|
43
|
+
const verdict = answer === "a" ? "approveAll" : answer === "o" ? "oneByOne" : "deny"
|
|
44
|
+
const tone = verdict === "approveAll" ? C.dim : C.error
|
|
45
|
+
pushLine(` [${verdict === "approveAll" ? "approved" : verdict === "deny" ? "denied" : "one by one"}] ${name}`, tone)
|
|
46
|
+
resolve(verdict)
|
|
47
|
+
render()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
38
50
|
if (answer === "a" && !isContinue) {
|
|
39
51
|
agent.autoApprove = true
|
|
40
52
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
@@ -255,7 +267,7 @@ export function createKeyHandler(ctx) {
|
|
|
255
267
|
} else if (str && !key.ctrl && !key.meta) {
|
|
256
268
|
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
257
269
|
const text = str.replace(/[\r\n]+/g, "")
|
|
258
|
-
//
|
|
270
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
259
271
|
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
260
272
|
}
|
|
261
273
|
return
|
package/src/tui/layout.mjs
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
* Computes position and height of each panel from state + terminal dimensions.
|
|
4
4
|
* Does not modify state — side effects are performed by the caller before rendering.
|
|
5
5
|
*
|
|
6
|
-
* header → conversation → todo → picker → permission → queue → input → status
|
|
7
|
-
*
|
|
8
|
-
* (AGENT-LOOP.md §7.2
|
|
6
|
+
* header → conversation → subagent 面板 → todo → picker → permission → queue → input → status
|
|
7
|
+
* Running subagent activity renders in a FIXED bottom panel between the
|
|
8
|
+
* conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
|
|
9
|
+
* (the conversation shrinks); compressed away first on small terminals (to 0 =
|
|
10
|
+
* hidden, data stays in the buffer). Done children are frozen into the
|
|
11
|
+
* conversation stream (§7.2 D4, unchanged). Output panels abolished (§7.2 D6).
|
|
9
12
|
* Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
|
|
10
13
|
*/
|
|
11
14
|
import { layoutInput, wrapText } from "./render.mjs"
|
|
12
15
|
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
16
|
+
import { renderSubagentPanel } from "./subagent-panel.mjs"
|
|
13
17
|
|
|
14
18
|
/** 防御:question options 声明为 string[],但 LLM 可能误传对象;取 label/text/title 兜底,避免渲染 "[object Object]"。 */
|
|
15
19
|
function optText(opt) {
|
|
@@ -75,9 +79,20 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
75
79
|
const taskPanelH = visibleTasks.length > 0 ? visibleTasks.length + 1 : 0
|
|
76
80
|
// Squeeze target: the divider line yields first under small terminals (the
|
|
77
81
|
// task rows themselves never compress away — put() truncates by panel h).
|
|
78
|
-
let todoFinalH = taskPanelH
|
|
79
82
|
|
|
80
|
-
// Subagent
|
|
83
|
+
// Subagent panel (§7.2.1 D1): RUNNING blocks only, between conversation and
|
|
84
|
+
// todo. Height = the FULL rendered height of every running block (F2 — fully
|
|
85
|
+
// adaptive, no cap; the conversation shrinks accordingly). Precomputed here
|
|
86
|
+
// via renderSubagentPanel (neutral module, no layout↔render-frame cycle);
|
|
87
|
+
// render-frame puts `subagentLines` directly (no double render). Done
|
|
88
|
+
// children are frozen into state.lines (subagent-blocks.mjs
|
|
89
|
+
// freezeSubTaskLines) and excluded here — the panel only shows running blocks.
|
|
90
|
+
let subagentLines = []
|
|
91
|
+
let subagentH = 0
|
|
92
|
+
if (Object.values(state.subTasks ?? {}).some((s) => !s.done)) {
|
|
93
|
+
subagentLines = renderSubagentPanel(state, cols, rows)
|
|
94
|
+
subagentH = subagentLines.length
|
|
95
|
+
}
|
|
81
96
|
|
|
82
97
|
// Permission preview (height depends on wrapped content)
|
|
83
98
|
let permPreviewLines = []
|
|
@@ -97,32 +112,47 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
97
112
|
const queueH = state.queue.length > 0 && state.processing ? 1 : 0
|
|
98
113
|
|
|
99
114
|
// --- elastic panel: conversation takes remaining space ---
|
|
100
|
-
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH
|
|
115
|
+
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH + subagentH
|
|
101
116
|
let convH = Math.max(1, rows - fixedH)
|
|
102
117
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
118
|
+
// 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
|
|
119
|
+
// 再压 conversation 到最小 1 行,再压 picker 到最小 3 行,仍溢出再压
|
|
120
|
+
// permission preview 到最小 1 行(仅标题)——输入框/状态栏/会话区保留。
|
|
121
|
+
let subagentFinalH = subagentH
|
|
105
122
|
let pickerFinalH = pickerH
|
|
106
123
|
let permFinalH = permPreviewH
|
|
124
|
+
let todoFinalH = taskPanelH
|
|
107
125
|
const overflow = fixedH + convH - rows
|
|
108
126
|
if (overflow > 0) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
permFinalH = Math.max(1, permPreviewH - remaining)
|
|
117
|
-
convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
|
|
127
|
+
// 压缩链第 1 级(§7.2.1 NF1/评审 #2 措辞统一):subagent 面板最先让位——
|
|
128
|
+
// 可压缩至 0 隐藏(运行中活动仍在缓冲区不丢),输入框/状态栏/会话区不可挤没。
|
|
129
|
+
let afterSub = fixedH
|
|
130
|
+
if (subagentH > 0) {
|
|
131
|
+
subagentFinalH = Math.max(0, subagentH - overflow)
|
|
132
|
+
afterSub = fixedH - subagentH + subagentFinalH
|
|
133
|
+
convH = Math.max(1, rows - afterSub)
|
|
118
134
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
// 压缩链第 2/3 级(既有逻辑):picker → 最小 3 行,permission → 最小 1 行
|
|
136
|
+
const overflow2 = afterSub + convH - rows
|
|
137
|
+
if (overflow2 > 0) {
|
|
138
|
+
if (pickerH > 0) {
|
|
139
|
+
pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow2)
|
|
140
|
+
}
|
|
141
|
+
const afterPicker = afterSub - pickerH + pickerFinalH
|
|
142
|
+
convH = Math.max(1, rows - afterPicker)
|
|
143
|
+
const remaining = afterPicker + convH - rows
|
|
144
|
+
if (remaining > 0 && permPreviewH > 0) {
|
|
145
|
+
permFinalH = Math.max(1, permPreviewH - remaining)
|
|
146
|
+
convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
|
|
147
|
+
}
|
|
148
|
+
// 压缩链末级:todo 面板的分隔线行让位(任务行保留——put 按 h 截断自动
|
|
149
|
+
// 丢弃第一行的分隔线,2026-08-30 用户请求加的 divider 不得在小终端挤掉输入框)。
|
|
150
|
+
const afterPerm = afterPicker - permPreviewH + permFinalH
|
|
151
|
+
const finalOverflow = afterPerm + convH - rows
|
|
152
|
+
if (finalOverflow > 0 && taskPanelH > visibleTasks.length) {
|
|
153
|
+
todoFinalH = Math.max(visibleTasks.length, taskPanelH - finalOverflow)
|
|
154
|
+
convH = Math.max(1, rows - (afterPerm - taskPanelH + todoFinalH))
|
|
155
|
+
}
|
|
126
156
|
}
|
|
127
157
|
}
|
|
128
158
|
|
|
@@ -130,6 +160,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
130
160
|
let y = 0
|
|
131
161
|
const header = { y, h: headerH }; y += headerH
|
|
132
162
|
const conversation = { y, h: convH }; y += convH
|
|
163
|
+
const subagent = subagentFinalH > 0 ? { y, h: subagentFinalH } : null; y += subagentFinalH
|
|
133
164
|
const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
|
|
134
165
|
const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
|
|
135
166
|
const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
|
|
@@ -139,13 +170,38 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
139
170
|
|
|
140
171
|
return {
|
|
141
172
|
W, cols, rows,
|
|
142
|
-
panels: { header, conversation, picker, todo, permission, queue, inputBox, status },
|
|
173
|
+
panels: { header, conversation, subagent, picker, todo, permission, queue, inputBox, status },
|
|
143
174
|
// precomputed content (affects height, reused during render)
|
|
144
175
|
inputLayout,
|
|
145
176
|
inputOffset,
|
|
146
177
|
boxLines,
|
|
147
178
|
visibleTasks,
|
|
148
179
|
permPreviewLines,
|
|
180
|
+
subagentLines,
|
|
149
181
|
overlay,
|
|
150
182
|
}
|
|
151
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* §7.2.1 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
|
|
187
|
+
* 实际显示的行——**保底截断**:分隔线 + 末尾 (h-1) 行(最新启动区块优先;小终端上仍
|
|
188
|
+
* 能看到最新子 agent 活动,旧 slice(0,h) 保留顶部会把最新活动裁掉)。分隔线始终保留
|
|
189
|
+
* (面板边界语义)。h ≥ 全长 → 原样;h = 0 → [](layout 侧 panels.subagent 已为 null,
|
|
190
|
+
* 防御分支);h = 1 → 只显示分隔线(极端小终端)。
|
|
191
|
+
*/
|
|
192
|
+
export function subagentVisibleLines(subagentLines, h) {
|
|
193
|
+
if (h >= subagentLines.length) return subagentLines
|
|
194
|
+
if (h <= 0) return []
|
|
195
|
+
if (h === 1) return [subagentLines[0]]
|
|
196
|
+
return [subagentLines[0], ...subagentLines.slice(-(h - 1))]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 与 subagentVisibleLines 同一几何契约:可见面板行(0-based 行内坐标)→
|
|
200
|
+
* subagentLines 索引(mouse 命中映射用——保底截断后可见行 ≠ 前 h 行);
|
|
201
|
+
* localRow 越界 → -1。 */
|
|
202
|
+
export function subagentLineIndex(subagentLines, h, localRow) {
|
|
203
|
+
if (localRow < 0 || localRow >= h) return -1
|
|
204
|
+
if (h >= subagentLines.length) return localRow
|
|
205
|
+
if (localRow === 0) return 0
|
|
206
|
+
return subagentLines.length - h + localRow
|
|
207
|
+
}
|
package/src/tui/mouse.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - picker option click = select it
|
|
15
15
|
* - folded-block hint click = expand it
|
|
16
16
|
*/
|
|
17
|
-
import { computeLayout } from "./layout.mjs"
|
|
17
|
+
import { computeLayout, subagentLineIndex } from "./layout.mjs"
|
|
18
18
|
import { buildConvLines, convViewport } from "./render-conversation.mjs"
|
|
19
19
|
import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
|
|
20
20
|
|
|
@@ -29,6 +29,24 @@ export function handleWheel(ctx, button, col, row) {
|
|
|
29
29
|
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
30
30
|
const layout = computeLayout(state, dims)
|
|
31
31
|
const P = layout.panels
|
|
32
|
+
// §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
|
|
33
|
+
// 滚会话(F3,与 todo 面板同型);命中展开区块内容行(_foldBlock 标记)→
|
|
34
|
+
// 块内滚动(现状能力不丢)。
|
|
35
|
+
if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
|
|
36
|
+
// 评审 #4:面板部分压缩(保底截断)后可见行 = 分隔线 + 末尾行——命中映射经
|
|
37
|
+
// subagentLineIndex(与 render-frame 同一几何契约),不再把行内坐标直接当索引用。
|
|
38
|
+
const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
|
|
39
|
+
if (!lineEl?._foldBlock || !lineEl._foldTotal) return false
|
|
40
|
+
// 穿出语义与会话区块一致:块内到边界(顶滚上 / 底滚下)→ 交还会话滚动
|
|
41
|
+
const before = foldScrollOffset(state, lineEl._foldBlock)
|
|
42
|
+
const winH = lineEl._foldWindow ?? 1
|
|
43
|
+
const total = lineEl._foldTotal
|
|
44
|
+
if (dir < 0 && before <= 0) return false
|
|
45
|
+
if (dir > 0 && before >= total - winH) return false
|
|
46
|
+
scrollFoldBlock(state, lineEl._foldBlock, dir, 3)
|
|
47
|
+
ctx.render?.()
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
32
50
|
if (r < P.conversation.y || r >= P.conversation.y + P.conversation.h) return false
|
|
33
51
|
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
34
52
|
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
@@ -54,7 +72,7 @@ export function handleWheel(ctx, button, col, row) {
|
|
|
54
72
|
/** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
|
|
55
73
|
export function parseMouseClicks(text) {
|
|
56
74
|
const out = []
|
|
57
|
-
//
|
|
75
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
58
76
|
for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
|
|
59
77
|
out.push({ col: Number(m[1]), row: Number(m[2]) })
|
|
60
78
|
}
|
|
@@ -101,6 +119,27 @@ export function handleMouseClick(ctx, col, row) {
|
|
|
101
119
|
return true
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
// ── §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——折叠/展开/翻窗
|
|
123
|
+
// 坐标映射到面板行(与 todo 面板同型;layout.subagentLines = 面板渲染行)。
|
|
124
|
+
if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
|
|
125
|
+
// 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
|
|
126
|
+
// (subagentLineIndex:分隔线 + 末尾区块行优先)。
|
|
127
|
+
const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
|
|
128
|
+
if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
|
|
129
|
+
// ▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
|
|
130
|
+
scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
|
|
131
|
+
lineEl._foldScrollUp ? -1 : 1, lineEl._foldWindow ?? 1,
|
|
132
|
+
typeof lineEl._foldTotal === "number" ? Math.max(0, lineEl._foldTotal - (lineEl._foldWindow ?? 1)) : undefined)
|
|
133
|
+
render()
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
if (!lineEl?._foldToggle) return false
|
|
137
|
+
// 双向切换(fold-block.mjs 单源):折叠头展开 / ▼ 控制收起
|
|
138
|
+
toggleFoldBlock(state, lineEl._foldToggle)
|
|
139
|
+
render()
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
104
143
|
// ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
|
|
105
144
|
if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
|
|
106
145
|
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sliceByWidth } from "./render.mjs"
|
|
2
|
-
import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
|
|
2
|
+
import { PROVIDER_PRESETS as PRESETS, providerSpec } from "../config.mjs"
|
|
3
3
|
import { computeLayout } from "./layout.mjs"
|
|
4
4
|
|
|
5
5
|
/** Generic list picker + model/provider management.
|
|
@@ -121,6 +121,14 @@ export function createPickers(ctx) {
|
|
|
121
121
|
return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** 模型信息显示的 context 窗口(PROVIDER.md §15 D-C5):K 单位形态跟随覆盖值
|
|
125
|
+
* (config K 是二进制 K:128 → 128×1024 = 131072 → 显示 "128K");≥1M tokens 用 M 形态
|
|
126
|
+
* (1_000_000 → "1M"),与 spec 值的大窗口惯例一致。 */
|
|
127
|
+
function fmtContextK(tokens) {
|
|
128
|
+
if (tokens >= 1_000_000) return `${Math.round(tokens / 1_048_576)}M`
|
|
129
|
+
return `${Math.round(tokens / 1024)}K`
|
|
130
|
+
}
|
|
131
|
+
|
|
124
132
|
/** Get API key for a provider (config.json only — env vars are not a key source) */
|
|
125
133
|
function getApiKey(providerName, providerConfig) {
|
|
126
134
|
return providerConfig.apiKey
|
|
@@ -148,6 +156,8 @@ export function createPickers(ctx) {
|
|
|
148
156
|
await removeProviderFlow()
|
|
149
157
|
} else if (e.action === "key") {
|
|
150
158
|
await setKeyFlow()
|
|
159
|
+
} else if (e.action === "context") {
|
|
160
|
+
await setContextFlow()
|
|
151
161
|
}
|
|
152
162
|
}
|
|
153
163
|
}
|
|
@@ -188,9 +198,11 @@ export function createPickers(ctx) {
|
|
|
188
198
|
const marker = active ? "●" : ""
|
|
189
199
|
const note = active ? " ← current" : ""
|
|
190
200
|
const keyStatus = p.apiKey ? "" : " (no key)"
|
|
201
|
+
// context 窗口显示(PROVIDER.md §15 D-C5/T-C6):跟随 providers[].context 覆盖
|
|
202
|
+
const ctxTag = ` (ctx ${fmtContextK(providerSpec(p).context)})`
|
|
191
203
|
entries.push({
|
|
192
204
|
type: "item",
|
|
193
|
-
text: `${p.name.padEnd(12)} ${currentModel}${note}`,
|
|
205
|
+
text: `${p.name.padEnd(12)} ${currentModel}${ctxTag}${note}`,
|
|
194
206
|
action: "open-models",
|
|
195
207
|
provider: p.name,
|
|
196
208
|
marker,
|
|
@@ -201,6 +213,7 @@ export function createPickers(ctx) {
|
|
|
201
213
|
entries.push({ type: "item", text: "Add provider…", action: "add" })
|
|
202
214
|
if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
|
|
203
215
|
entries.push({ type: "item", text: "Set / change API key…", action: "key" })
|
|
216
|
+
entries.push({ type: "item", text: "Set context window (K units)…", action: "context" })
|
|
204
217
|
return entries
|
|
205
218
|
}
|
|
206
219
|
|
|
@@ -294,7 +307,8 @@ export function createPickers(ctx) {
|
|
|
294
307
|
agent.provider = { ...target }
|
|
295
308
|
if (agent.config?.agent?.compactThresholdAuto) {
|
|
296
309
|
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
297
|
-
|
|
310
|
+
// provider 对象(非 model 字符串)——阈值跟随 providers[].context 覆盖(PROVIDER.md §15 T-C2)
|
|
311
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, target).value
|
|
298
312
|
}
|
|
299
313
|
await persistRaw((raw) => {
|
|
300
314
|
// 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
|
|
@@ -382,6 +396,50 @@ export function createPickers(ctx) {
|
|
|
382
396
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
383
397
|
}
|
|
384
398
|
|
|
399
|
+
/** /model provider 管理:context 窗口字段(K 单位,PROVIDER.md §15 D-C4/D-C5)——
|
|
400
|
+
* picker 选 provider + 表单输入(复用 syncProviderField 的落盘模式:改 agent.providers 目标项
|
|
401
|
+
* → persistRaw 全量写盘);空输入清空(回 spec 值);非法输入报错不落盘(D-C1 语义同 loadConfig)。 */
|
|
402
|
+
async function setContextFlow() {
|
|
403
|
+
const se = await showPicker("Set Context Window", [
|
|
404
|
+
{ type: "header", text: "Select provider" },
|
|
405
|
+
...agent.providers.map((p) => ({
|
|
406
|
+
type: "item",
|
|
407
|
+
text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
|
|
408
|
+
name: p.name,
|
|
409
|
+
})),
|
|
410
|
+
])
|
|
411
|
+
if (!se?.name) return // Esc
|
|
412
|
+
const target = agent.providers.find((p) => p.name === se.name)
|
|
413
|
+
if (!target) return
|
|
414
|
+
const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
|
|
415
|
+
const val = (await askQuestion(
|
|
416
|
+
`Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
|
|
417
|
+
))?.trim() ?? ""
|
|
418
|
+
if (val === "") {
|
|
419
|
+
delete target.context
|
|
420
|
+
} else {
|
|
421
|
+
const n = Number(val)
|
|
422
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
423
|
+
pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
target.context = n
|
|
427
|
+
}
|
|
428
|
+
if (se.name === agent.activeProvider) {
|
|
429
|
+
// 运行时 provider 同步(同 setProviderKey 先例:只补 context,不重建对象以免丢 activeModel 覆盖)
|
|
430
|
+
if (target.context === undefined) delete agent.provider.context
|
|
431
|
+
else agent.provider.context = target.context
|
|
432
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
433
|
+
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
434
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
438
|
+
pushLine(target.context !== undefined
|
|
439
|
+
? `ctx = ${target.context}K (${target.context * 1024} tokens)`
|
|
440
|
+
: `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
|
|
441
|
+
}
|
|
442
|
+
|
|
385
443
|
|
|
386
444
|
/** Slot-bound model picker: two-level provider → model selection that RETURNS
|
|
387
445
|
* { provider, model } instead of writing main-session state — used by /submodel
|
|
@@ -411,5 +469,5 @@ export function createPickers(ctx) {
|
|
|
411
469
|
}
|
|
412
470
|
}
|
|
413
471
|
|
|
414
|
-
return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
|
|
472
|
+
return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
|
|
415
473
|
}
|