thincoder 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +46 -19
- package/src/prompts/coder.md +5 -2
- package/src/prompts/discipline.md +8 -5
- package/src/prompts/system.md +8 -5
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +36 -4
- package/src/provider/google.mjs +197 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +120 -154
- package/src/tools/index.mjs +9 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/web.mjs +103 -82
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +12 -13
- package/src/tui/cmd-advisor.mjs +29 -41
- 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 +64 -38
- package/src/tui/key-handler.mjs +48 -18
- package/src/tui/layout.mjs +12 -2
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-frame.mjs +29 -11
- package/src/tui/slash-commands.mjs +26 -16
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
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
renderInputBox, renderStatus,
|
|
30
30
|
} from "./render-frame.mjs"
|
|
31
31
|
import { computeLayout } from "./layout.mjs"
|
|
32
|
-
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
32
|
+
import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
|
|
33
33
|
import { createWizard } from "./wizard.mjs"
|
|
34
34
|
import { createPickers } from "./pickers.mjs"
|
|
35
35
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
@@ -40,6 +40,17 @@ import { createKeyHandler } from "./key-handler.mjs"
|
|
|
40
40
|
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
41
41
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
42
42
|
|
|
43
|
+
/** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
|
|
44
|
+
export function upgradeFailureText(code, output) {
|
|
45
|
+
const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
|
|
46
|
+
return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
|
|
50
|
+
export function pendingNoticeReady(state) {
|
|
51
|
+
return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Start the TUI, taking over the terminal until exit.
|
|
45
56
|
* agent: return value of createAgent
|
|
@@ -65,7 +76,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
65
76
|
permission: null, // { name, args, resolve }
|
|
66
77
|
permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
|
|
67
78
|
question: null, // { text, options, resolve } — agent question tool callback
|
|
68
|
-
picker: null, //
|
|
79
|
+
picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
|
|
80
|
+
pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
|
|
81
|
+
pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
|
|
69
82
|
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
70
83
|
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
71
84
|
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
|
|
@@ -95,6 +108,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
95
108
|
const keyStream = new PassThrough()
|
|
96
109
|
let mousePending = "" // incomplete mouse sequence tail spanning chunks
|
|
97
110
|
let lastRenderedScroll = 0
|
|
111
|
+
// Capture terminal dimensions before raw mode & alt buffer switch.
|
|
112
|
+
// On Windows, process.stdout.columns/rows can briefly return falsy after the mode switch
|
|
113
|
+
// (ConPTY buffer transition), causing the ||80/||24 fallback to produce a cramped initial layout.
|
|
114
|
+
const startupCols = process.stdout.columns || 80
|
|
115
|
+
const startupRows = process.stdout.rows || 24
|
|
98
116
|
emitKeypressEvents(keyStream)
|
|
99
117
|
process.stdin.setRawMode(true)
|
|
100
118
|
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
|
|
@@ -292,7 +310,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
292
310
|
|
|
293
311
|
function doRender() {
|
|
294
312
|
try {
|
|
295
|
-
const dims = { cols: process.stdout.columns ||
|
|
313
|
+
const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
|
|
296
314
|
const layout = computeLayout(state, dims)
|
|
297
315
|
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
298
316
|
|
|
@@ -308,6 +326,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
308
326
|
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
309
327
|
}
|
|
310
328
|
|
|
329
|
+
// 后台更新提示:等 picker/权限确认/提问弹层全部关闭后再弹,不硬抢
|
|
330
|
+
// (key-handler 分支顺序 permission → question → picker,任一激活时弹了也摸不到)
|
|
331
|
+
if (pendingNoticeReady(state)) {
|
|
332
|
+
const notice = state.pendingNotice
|
|
333
|
+
state.pendingNotice = null
|
|
334
|
+
showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
335
|
+
}
|
|
336
|
+
|
|
311
337
|
// Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
|
|
312
338
|
// Don't clear panelCache — update positions so the next incremental check
|
|
313
339
|
// sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
|
|
@@ -325,6 +351,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
325
351
|
// Content + cursor in a single write. Hardware cursor stays hidden —
|
|
326
352
|
// the visual cursor is drawn in the input box as SGR reverse video.
|
|
327
353
|
// Position for IME, hide for visual (matching pi-tui).
|
|
354
|
+
const { frame, cursorRow, cursorCol } = renderFrame(state, agent, { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
|
|
328
355
|
if (isStreaming) {
|
|
329
356
|
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
330
357
|
} else if (isWizard) {
|
|
@@ -417,11 +444,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
417
444
|
// Slash commands: handled locally, don't enter agent loop
|
|
418
445
|
if (text.startsWith("/")) {
|
|
419
446
|
if (state.processing) {
|
|
420
|
-
// While processing:
|
|
421
|
-
//
|
|
422
|
-
const cmd0 = text.split(/\s+/)[0]
|
|
423
|
-
const
|
|
424
|
-
const resolved0 = ALIASES[cmd0] ?? cmd0
|
|
447
|
+
// While processing: allowlisted commands execute directly (they only touch
|
|
448
|
+
// local TUI/agent config, never the in-flight turn); the rest are queued
|
|
449
|
+
const cmd0 = text.split(/\s+/)[0].toLowerCase()
|
|
450
|
+
const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
|
|
425
451
|
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
426
452
|
if (safeDuringProcessing.has(resolved0)) {
|
|
427
453
|
await handleSlash(text)
|
|
@@ -472,7 +498,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
472
498
|
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
473
499
|
|
|
474
500
|
// Model picker + generic picker: implemented in pickers.mjs
|
|
475
|
-
const {
|
|
501
|
+
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
|
|
476
502
|
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
477
503
|
})
|
|
478
504
|
|
|
@@ -489,9 +515,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
489
515
|
const { handleSlash, completions, handleTab } = createSlashCommands({
|
|
490
516
|
agent, state, distillOpts,
|
|
491
517
|
pushLine, pushLabel, render,
|
|
492
|
-
|
|
518
|
+
showPicker, closePicker, askQuestion, askPermission,
|
|
493
519
|
persistRaw, syncProviderField, maskKey,
|
|
494
520
|
openModelPicker: () => openModelPicker(),
|
|
521
|
+
selectModel,
|
|
495
522
|
setProviderKey,
|
|
496
523
|
runDistill,
|
|
497
524
|
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
@@ -503,7 +530,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
503
530
|
|
|
504
531
|
// keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
|
|
505
532
|
const onKeypress = createKeyHandler({
|
|
506
|
-
agent, state, render,
|
|
533
|
+
agent, state, render, popPicker, renderPickerLines,
|
|
507
534
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
508
535
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
509
536
|
renderWizard, pushLine, cleanup,
|
|
@@ -523,6 +550,30 @@ export async function startTUI(agent, opts = {}) {
|
|
|
523
550
|
backgroundIndex({ agent, state, render })
|
|
524
551
|
|
|
525
552
|
// Check for updates (non-blocking, after startup screen)
|
|
553
|
+
// 有 picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
|
|
554
|
+
const showUpdateNotice = async (result) => {
|
|
555
|
+
const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
|
|
556
|
+
{ type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
|
|
557
|
+
{ type: "item", text: "Upgrade now", action: "upgrade" },
|
|
558
|
+
{ type: "item", text: "Later", action: "later" },
|
|
559
|
+
])
|
|
560
|
+
if (sel?.action !== "upgrade") return
|
|
561
|
+
pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
|
|
562
|
+
pushLine(`Upgrading to ${result.latest}...`, C.tool)
|
|
563
|
+
const { exec } = await import("node:child_process")
|
|
564
|
+
const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
|
|
565
|
+
let stdout = ""
|
|
566
|
+
cp.stdout?.on("data", (d) => { stdout += d })
|
|
567
|
+
cp.stderr?.on("data", (d) => { stdout += d })
|
|
568
|
+
cp.on("close", (code) => {
|
|
569
|
+
if (code === 0) {
|
|
570
|
+
pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
|
|
571
|
+
} else {
|
|
572
|
+
pushLine(upgradeFailureText(code, stdout), C.error)
|
|
573
|
+
}
|
|
574
|
+
render()
|
|
575
|
+
})
|
|
576
|
+
}
|
|
526
577
|
;(async () => {
|
|
527
578
|
try {
|
|
528
579
|
const { readFileSync } = await import("node:fs")
|
|
@@ -535,33 +586,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
535
586
|
pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
|
|
536
587
|
render()
|
|
537
588
|
} 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
|
-
})
|
|
589
|
+
state.pendingNotice = result
|
|
590
|
+
render()
|
|
565
591
|
}
|
|
566
592
|
}
|
|
567
593
|
} catch { /* network error or timeout — silently skip */ }
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
|
+
import { computeLayout } from "./layout.mjs"
|
|
3
4
|
|
|
4
5
|
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
5
6
|
* Extracted from index.mjs.
|
|
6
|
-
* ctx: { agent, state, render, renderPickerLines,
|
|
7
|
+
* ctx: { agent, state, render, renderPickerLines, popPicker,
|
|
7
8
|
* handleSlash, handleTab, submit, pasteClipboardImage,
|
|
8
9
|
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
9
10
|
* renderWizard, pushLine, cleanup } */
|
|
10
11
|
export function createKeyHandler(ctx) {
|
|
11
|
-
const { agent, state, render,
|
|
12
|
+
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
|
|
12
13
|
|
|
13
14
|
return function onKeypress(str, key = {}) {
|
|
14
15
|
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
@@ -104,6 +105,11 @@ export function createKeyHandler(ctx) {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
if (key.ctrl && key.name === "c") {
|
|
108
|
+
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
109
|
+
if (state.picker) {
|
|
110
|
+
popPicker(null)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
107
113
|
if (state.processing && state.controller) {
|
|
108
114
|
state.controller.abort()
|
|
109
115
|
pushLine("[Aborting…]", C.warn)
|
|
@@ -111,7 +117,9 @@ export function createKeyHandler(ctx) {
|
|
|
111
117
|
return
|
|
112
118
|
}
|
|
113
119
|
cleanup()
|
|
114
|
-
|
|
120
|
+
// 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
|
|
121
|
+
ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
|
|
122
|
+
ctx.exitTimer.unref?.()
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
// Ctrl+I (or Tab during processing): interrupt and inject a message
|
|
@@ -146,25 +154,47 @@ export function createKeyHandler(ctx) {
|
|
|
146
154
|
return
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
// generic list picker:
|
|
157
|
+
// generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
|
|
150
158
|
if (state.picker) {
|
|
151
|
-
const
|
|
159
|
+
const p = state.picker
|
|
160
|
+
const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
|
|
161
|
+
// 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
|
|
162
|
+
// 单一数据源,避免与 layout.mjs 公式漂移
|
|
163
|
+
const winH = Math.max(1, (computeLayout(state, { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }).panels.picker?.h ?? p.lines.length + 1) - 1)
|
|
164
|
+
const applyFilter = (f) => {
|
|
165
|
+
p.filter = f
|
|
166
|
+
p.index = 0
|
|
167
|
+
p.scroll = 0
|
|
168
|
+
renderPickerLines()
|
|
169
|
+
}
|
|
152
170
|
if (key.name === "escape") {
|
|
153
|
-
|
|
171
|
+
popPicker(null)
|
|
154
172
|
} else if (key.name === "up" && items.length) {
|
|
155
|
-
|
|
173
|
+
p.index = (p.index - 1 + items.length) % items.length
|
|
156
174
|
renderPickerLines()
|
|
157
175
|
} else if (key.name === "down" && items.length) {
|
|
158
|
-
|
|
176
|
+
p.index = (p.index + 1) % items.length
|
|
177
|
+
renderPickerLines()
|
|
178
|
+
} else if (key.name === "pageup" && items.length) {
|
|
179
|
+
p.index = Math.max(0, p.index - winH)
|
|
159
180
|
renderPickerLines()
|
|
160
|
-
} else if (key.name === "
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
181
|
+
} else if (key.name === "pagedown" && items.length) {
|
|
182
|
+
p.index = Math.min(items.length - 1, p.index + winH)
|
|
183
|
+
renderPickerLines()
|
|
184
|
+
} else if (key.name === "home" && items.length) {
|
|
185
|
+
p.index = 0
|
|
186
|
+
renderPickerLines()
|
|
187
|
+
} else if (key.name === "end" && items.length) {
|
|
188
|
+
p.index = items.length - 1
|
|
189
|
+
renderPickerLines()
|
|
190
|
+
} else if (key.name === "backspace") {
|
|
191
|
+
if (p.filter) applyFilter(p.filter.slice(0, -1))
|
|
192
|
+
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
193
|
+
popPicker(items[p.index]) // 选中即关闭
|
|
194
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
195
|
+
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
196
|
+
const text = str.replace(/[\r\n]+/g, "")
|
|
197
|
+
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
168
198
|
}
|
|
169
199
|
return
|
|
170
200
|
}
|
|
@@ -184,7 +214,7 @@ export function createKeyHandler(ctx) {
|
|
|
184
214
|
} else if (key.name === "down" && items.length) {
|
|
185
215
|
w.index = (w.index + 1) % items.length
|
|
186
216
|
renderWizard()
|
|
187
|
-
} else if (key.name === "return" && items.length) {
|
|
217
|
+
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
188
218
|
wizardChooseProvider(items[w.index])
|
|
189
219
|
}
|
|
190
220
|
return
|
|
@@ -300,7 +330,7 @@ export function createKeyHandler(ctx) {
|
|
|
300
330
|
}
|
|
301
331
|
return
|
|
302
332
|
}
|
|
303
|
-
if (key.name === "return") {
|
|
333
|
+
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
304
334
|
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
305
335
|
return
|
|
306
336
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -92,7 +92,17 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
92
92
|
|
|
93
93
|
// --- elastic panel: conversation takes remaining space ---
|
|
94
94
|
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
|
|
95
|
-
|
|
95
|
+
let convH = Math.max(1, rows - fixedH)
|
|
96
|
+
|
|
97
|
+
// 小终端高度补偿(best-effort,不保证总行数 ≤ rows):先压 conversation 到最小 1 行,
|
|
98
|
+
// 仍超出再压 picker 到最小 3 行。极端情况(permission preview + tasks 等同屏)补偿后仍可能
|
|
99
|
+
// 溢出 —— 其余面板不强行裁剪,由渲染层自行截断
|
|
100
|
+
let pickerFinalH = pickerH
|
|
101
|
+
const overflow = fixedH + convH - rows
|
|
102
|
+
if (overflow > 0 && pickerH > 0) {
|
|
103
|
+
pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
|
|
104
|
+
convH = Math.max(1, rows - (fixedH - pickerH + pickerFinalH))
|
|
105
|
+
}
|
|
96
106
|
|
|
97
107
|
// --- Y coordinates (0-indexed, +1 when used with ANSI) ---
|
|
98
108
|
let y = 0
|
|
@@ -101,7 +111,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
101
111
|
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
102
112
|
const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
|
|
103
113
|
const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
|
|
104
|
-
const picker =
|
|
114
|
+
const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
|
|
105
115
|
const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
|
|
106
116
|
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
107
117
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|