thincoder 0.8.11 → 0.8.13
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 +27 -0
- package/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -10
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -21
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +11 -4
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +25 -28
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +142 -15
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +9 -3
- package/src/tools/file.mjs +114 -5
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/patch.mjs +7 -3
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +19 -10
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +17 -2
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +167 -54
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +6 -4
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +240 -167
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** /advisor command: toggle advisor on/off, select model.
|
|
2
|
+
* ctx: { agent, openPicker, pushLine } */
|
|
3
|
+
import { C } from "./ansi.mjs"
|
|
4
|
+
|
|
5
|
+
export async function handleAdvisorCommand(ctx) {
|
|
6
|
+
const { agent, openPicker, pushLine } = ctx
|
|
7
|
+
const cfg = agent.config.advisor ??= {}
|
|
8
|
+
const enabled = cfg.enabled === true
|
|
9
|
+
const curProvider = cfg.provider || agent.activeProvider
|
|
10
|
+
const curModel = cfg.model || agent.provider.model
|
|
11
|
+
|
|
12
|
+
const entries = [
|
|
13
|
+
{ type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
|
|
14
|
+
{ type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
openPicker({
|
|
18
|
+
title: "Advisor",
|
|
19
|
+
entries,
|
|
20
|
+
onSelect: async (e) => {
|
|
21
|
+
if (e.action === "toggle") {
|
|
22
|
+
cfg.enabled = !cfg.enabled
|
|
23
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
24
|
+
if (cfg.enabled) {
|
|
25
|
+
agent._pendingReminders.push("[系统提醒: Advisor 审查已开启。每轮操作后,你的输出将被审查,观察结果可能作为系统提醒注入。请批判性参考——这是观察,不是命令。]")
|
|
26
|
+
} else {
|
|
27
|
+
agent._pendingReminders.push("[系统提醒: Advisor 审查已关闭。后续轮次不再自动审查。]")
|
|
28
|
+
}
|
|
29
|
+
} else if (e.action === "model") {
|
|
30
|
+
await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function openAdvisorModelPicker(ctx) {
|
|
37
|
+
const { agent, openPicker, pushLine } = ctx
|
|
38
|
+
const providers = agent.providers || []
|
|
39
|
+
|
|
40
|
+
// Build flat list: each provider's name + a "use current model" entry
|
|
41
|
+
const entries = []
|
|
42
|
+
let idx = 0
|
|
43
|
+
for (const p of providers) {
|
|
44
|
+
const mark = p.name === agent.activeProvider ? "* " : " "
|
|
45
|
+
entries.push({ type: "item", text: `${mark}${p.name} — ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
|
|
46
|
+
idx++
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
openPicker({
|
|
50
|
+
title: "Advisor Model",
|
|
51
|
+
entries,
|
|
52
|
+
onSelect: async (e) => {
|
|
53
|
+
if (e.action === "set_provider") {
|
|
54
|
+
const cfg = agent.config.advisor ??= {}
|
|
55
|
+
if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
|
|
56
|
+
// Same as main — clear override (use main pool)
|
|
57
|
+
delete cfg.provider
|
|
58
|
+
delete cfg.model
|
|
59
|
+
pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
|
|
60
|
+
} else {
|
|
61
|
+
cfg.provider = e.provider
|
|
62
|
+
cfg.model = e.model
|
|
63
|
+
pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -4,20 +4,26 @@
|
|
|
4
4
|
export async function handleThinkCommand(ctx) {
|
|
5
5
|
const { agent, openPicker, syncProviderField } = ctx
|
|
6
6
|
const cur = agent.provider
|
|
7
|
-
const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
|
|
8
7
|
const { specForModel } = await import("../config.mjs")
|
|
9
8
|
const spec = specForModel(cur.model)
|
|
10
9
|
const isEffortOnly = spec.thinkApi === "effort"
|
|
10
|
+
const thinkOnValue = spec.thinkOnValue ?? "enabled"
|
|
11
|
+
const isCustomThink = thinkOnValue !== "enabled"
|
|
12
|
+
// "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
|
+
const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
|
|
11
14
|
const entries = []
|
|
15
|
+
// Auto-think: classify difficulty per-prompt and auto-set reasoning effort
|
|
16
|
+
const autoThinkEnabled = agent.config?.agent?.autoThink === true
|
|
17
|
+
entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
|
|
12
18
|
if (!isEffortOnly) {
|
|
13
|
-
entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
19
|
+
if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
14
20
|
}
|
|
15
|
-
if (spec.reasoningEffortEnum) {
|
|
21
|
+
if (spec.reasoningEffortEnum && !autoThinkEnabled) {
|
|
16
22
|
for (const level of spec.reasoningEffortEnum) {
|
|
17
23
|
const mark = cur.reasoningEffort === level ? "▸ " : " "
|
|
18
24
|
entries.push({ type: "item", text: `${mark}effort: ${level}`, action: "effort", level })
|
|
19
25
|
}
|
|
20
|
-
} else {
|
|
26
|
+
} else if (!autoThinkEnabled) {
|
|
21
27
|
entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
|
|
22
28
|
entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
|
|
23
29
|
}
|
|
@@ -25,7 +31,19 @@ export async function handleThinkCommand(ctx) {
|
|
|
25
31
|
title: "Think",
|
|
26
32
|
entries,
|
|
27
33
|
onSelect: async (e) => {
|
|
28
|
-
if (e.action === "
|
|
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") {
|
|
29
47
|
cur.reasoningEffort = e.level
|
|
30
48
|
await syncProviderField("reasoningEffort", e.level)
|
|
31
49
|
} else {
|
|
@@ -36,12 +54,20 @@ export async function handleThinkCommand(ctx) {
|
|
|
36
54
|
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
37
55
|
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
38
56
|
} else {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
42
65
|
await syncProviderField("thinking", cur.thinking)
|
|
43
|
-
if (
|
|
44
|
-
|
|
66
|
+
if (enable) {
|
|
67
|
+
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
68
|
+
} else {
|
|
69
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
70
|
+
}
|
|
45
71
|
}
|
|
46
72
|
}
|
|
47
73
|
},
|
package/src/tui/index.mjs
CHANGED
|
@@ -22,7 +22,12 @@ import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
|
|
|
22
22
|
import { closeAllMcp } from "../mcp.mjs"
|
|
23
23
|
import { estimateTokens } from "../context.mjs"
|
|
24
24
|
import { ansi, C } from "./ansi.mjs"
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
renderFrame, countConvLines, convCacheKey,
|
|
27
|
+
renderHeader, renderConversation, renderTodo, renderSubagent,
|
|
28
|
+
renderOutput, renderPermission, renderQueue, renderPicker,
|
|
29
|
+
renderInputBox, renderStatus,
|
|
30
|
+
} from "./render-frame.mjs"
|
|
26
31
|
import { computeLayout } from "./layout.mjs"
|
|
27
32
|
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
28
33
|
import { createWizard } from "./wizard.mjs"
|
|
@@ -63,7 +68,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
63
68
|
picker: null, // model picker { entries, lines, index, scroll, selectedLine }
|
|
64
69
|
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
65
70
|
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
66
|
-
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // cumulative token usage (shown in status bar)
|
|
71
|
+
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
|
|
67
72
|
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
68
73
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
69
74
|
completion: null, // Tab completion state { candidates, index }
|
|
@@ -74,6 +79,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
74
79
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
75
80
|
status: "Ready",
|
|
76
81
|
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
82
|
+
interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
@@ -190,7 +196,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
190
196
|
// Can't close? fine, process is exiting anyway
|
|
191
197
|
}
|
|
192
198
|
process.stdin.setRawMode(false)
|
|
193
|
-
process.stdout.write(ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
199
|
+
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
194
200
|
}
|
|
195
201
|
process.on("exit", cleanup)
|
|
196
202
|
|
|
@@ -221,66 +227,171 @@ export async function startTUI(agent, opts = {}) {
|
|
|
221
227
|
|
|
222
228
|
// ---------------------------------------------------------- Render
|
|
223
229
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
let
|
|
227
|
-
let
|
|
228
|
-
|
|
230
|
+
// Panel cache for incremental rendering: panelName → { y, h, content }
|
|
231
|
+
const panelCache = new Map()
|
|
232
|
+
let lastCols = 0, lastRows = 0
|
|
233
|
+
let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
|
|
234
|
+
const convLineCache = [] // line-level cache for conversation panel (per-line diff)
|
|
235
|
+
let renderRequested = false, renderTimer = null, lastRenderAt = 0
|
|
236
|
+
const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
|
|
229
237
|
|
|
230
238
|
function scheduleRender() {
|
|
231
239
|
if (renderTimer) return
|
|
240
|
+
const elapsed = performance.now() - lastRenderAt
|
|
241
|
+
const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
|
|
232
242
|
renderTimer = setTimeout(() => {
|
|
233
243
|
renderTimer = null
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
if (!renderRequested) return
|
|
245
|
+
renderRequested = false
|
|
246
|
+
lastRenderAt = performance.now()
|
|
247
|
+
doRender()
|
|
248
|
+
if (renderRequested) scheduleRender() // more requests arrived during render
|
|
249
|
+
}, delay)
|
|
236
250
|
}
|
|
237
251
|
|
|
252
|
+
/** Rate-limited render entry point. All call sites use this. */
|
|
238
253
|
function render() {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const winH = layout.panels.picker.h - 1
|
|
252
|
-
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
253
|
-
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
254
|
+
if (renderRequested) return
|
|
255
|
+
renderRequested = true
|
|
256
|
+
// process.nextTick merges multiple synchronous render() calls
|
|
257
|
+
// within the same tick into a single scheduleRender call.
|
|
258
|
+
process.nextTick(() => scheduleRender())
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
|
|
262
|
+
function buildPanel(name, panelLayout, lines, cacheKey) {
|
|
263
|
+
if (!panelLayout) {
|
|
264
|
+
if (panelCache.has(name)) panelCache.delete(name)
|
|
265
|
+
return null
|
|
254
266
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
const content = lines.join("\r\n")
|
|
268
|
+
const cached = panelCache.get(name)
|
|
269
|
+
const effectiveKey = cacheKey ?? content
|
|
270
|
+
if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
|
|
271
|
+
const rows = []
|
|
272
|
+
for (let i = 0; i < panelLayout.h; i++) {
|
|
273
|
+
rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
|
|
258
274
|
}
|
|
275
|
+
panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
|
|
276
|
+
return rows.join("")
|
|
277
|
+
}
|
|
259
278
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
279
|
+
/** Detect if panel layout structure changed (appeared/disappeared/shifted).
|
|
280
|
+
* Only checks panels that are ALREADY cached — new panels (not yet written)
|
|
281
|
+
* are not a structural change; the incremental path will write them naturally. */
|
|
282
|
+
function layoutStructureChanged(layout) {
|
|
283
|
+
for (const [name, cached] of panelCache) {
|
|
284
|
+
const p = layout.panels[name] ?? null
|
|
285
|
+
if (p == null) return true // cached panel disappeared → layout changed
|
|
286
|
+
if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
|
|
287
|
+
}
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function doRender() {
|
|
292
|
+
try {
|
|
293
|
+
const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
294
|
+
const layout = computeLayout(state, dims)
|
|
295
|
+
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
296
|
+
|
|
297
|
+
// Side effects: clamp scroll + overlay + update ctxCache
|
|
298
|
+
const convLines = countConvLines(state, dims.cols)
|
|
299
|
+
state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
|
|
300
|
+
if (overlay && panels.picker) {
|
|
301
|
+
const winH = panels.picker.h - 1
|
|
302
|
+
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
303
|
+
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
274
304
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
// Frame unchanged but cursor moved (e.g. arrow keys) — only reposition cursor
|
|
278
|
-
lastCursorRow = cursorRow
|
|
279
|
-
lastCursorCol = cursorCol
|
|
280
|
-
if (!(state.permission || state.question || state.picker || state.wizard?.step === "provider")) {
|
|
281
|
-
process.stdout.write(`\x1b[${cursorRow};${cursorCol}H${ansi.showCursor}`)
|
|
305
|
+
if (state.ctxCache.len !== agent.history.length) {
|
|
306
|
+
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
282
307
|
}
|
|
283
|
-
|
|
308
|
+
|
|
309
|
+
// Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
|
|
310
|
+
// Don't clear panelCache — update positions so the next incremental check
|
|
311
|
+
// sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
|
|
312
|
+
// per panel on the next frame (much cheaper than another full redraw).
|
|
313
|
+
if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
|
|
314
|
+
lastCols = dims.cols; lastRows = dims.rows
|
|
315
|
+
// Update cached panel positions (content stays stale → next frame rewrites)
|
|
316
|
+
for (const [name, panelLayout] of Object.entries(panels)) {
|
|
317
|
+
if (!panelLayout) { panelCache.delete(name); continue }
|
|
318
|
+
const cached = panelCache.get(name)
|
|
319
|
+
if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
|
|
320
|
+
}
|
|
321
|
+
const isStreaming = state.processing && !state.permission && !state.question && !state.picker
|
|
322
|
+
const isWizard = state.wizard?.step === "provider"
|
|
323
|
+
// Content + cursor in a single write. Hardware cursor stays hidden —
|
|
324
|
+
// the visual cursor is drawn in the input box as SGR reverse video.
|
|
325
|
+
// Position for IME, hide for visual (matching pi-tui).
|
|
326
|
+
if (isStreaming) {
|
|
327
|
+
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
328
|
+
} else if (isWizard) {
|
|
329
|
+
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
|
|
330
|
+
} else {
|
|
331
|
+
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
332
|
+
}
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- Incremental rendering (layout stable) ----
|
|
337
|
+
// pi-tui pattern: content inside sync-update block; cursor outside.
|
|
338
|
+
// DECSET 2026 buffers all panel writes and renders them atomically.
|
|
339
|
+
// Cursor hide/show/position MUST be outside — otherwise the terminal's
|
|
340
|
+
// internal cursor state machine and the sync render buffer can disagree.
|
|
341
|
+
const out = []
|
|
342
|
+
const push = (s) => { if (s != null) out.push(s) }
|
|
343
|
+
|
|
344
|
+
// Always-visible panels
|
|
345
|
+
push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
|
|
346
|
+
push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
|
|
347
|
+
push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
|
|
348
|
+
|
|
349
|
+
// Conversation: line-level cache — only push changed lines
|
|
350
|
+
const convKey = convCacheKey(state)
|
|
351
|
+
const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
|
|
352
|
+
if (convChanged) {
|
|
353
|
+
lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
|
|
354
|
+
const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
|
|
355
|
+
const y = panels.conversation.y + 1
|
|
356
|
+
for (let i = 0; i < lines.length; i++) {
|
|
357
|
+
if (lines[i] !== convLineCache[i]) {
|
|
358
|
+
out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
|
|
359
|
+
convLineCache[i] = lines[i]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (convLineCache.length > lines.length) {
|
|
363
|
+
for (let i = lines.length; i < convLineCache.length; i++) {
|
|
364
|
+
out.push(`\x1b[${y + i};1H\x1b[K`)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
convLineCache.length = lines.length
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Conditional panels
|
|
371
|
+
push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
|
|
372
|
+
push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
|
|
373
|
+
push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
|
|
374
|
+
push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
|
|
375
|
+
if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
|
|
376
|
+
else panelCache.delete("queue")
|
|
377
|
+
if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
|
|
378
|
+
else panelCache.delete("picker")
|
|
379
|
+
|
|
380
|
+
// Determine cursor suffix — appended to the same write() as the sync block.
|
|
381
|
+
// MUST position the cursor at the input box even when hidden: the terminal's
|
|
382
|
+
// cursor position determines where the IME candidate window appears.
|
|
383
|
+
// pi-tui's positionHardwareCursor does the same — positions first, then
|
|
384
|
+
// decides show/hide based on showHardwareCursor.
|
|
385
|
+
// Hardware cursor stays hidden — the visual cursor is drawn in the input
|
|
386
|
+
// box text as SGR reverse video (matching pi-tui's approach).
|
|
387
|
+
// We still position the hardware cursor for IME candidate window placement.
|
|
388
|
+
const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
389
|
+
const cc = 3 + inputLayout.cursorCol
|
|
390
|
+
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
391
|
+
const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
|
|
392
|
+
|
|
393
|
+
// Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
|
|
394
|
+
if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
284
395
|
} catch (e) {
|
|
285
396
|
// Don't let a render error crash the TUI
|
|
286
397
|
}
|
|
@@ -312,6 +423,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
312
423
|
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
313
424
|
if (safeDuringProcessing.has(resolved0)) {
|
|
314
425
|
await handleSlash(text)
|
|
426
|
+
render()
|
|
315
427
|
} else {
|
|
316
428
|
state.queue.push({ text })
|
|
317
429
|
render()
|
|
@@ -319,14 +431,15 @@ export async function startTUI(agent, opts = {}) {
|
|
|
319
431
|
return
|
|
320
432
|
}
|
|
321
433
|
await handleSlash(text)
|
|
434
|
+
render()
|
|
322
435
|
return
|
|
323
436
|
}
|
|
324
437
|
|
|
325
|
-
// While processing: queue for later, don't execute immediately
|
|
438
|
+
// While processing: queue for later, don't execute immediately.
|
|
439
|
+
// The queue panel (renderQueue) already shows pending items — don't also
|
|
440
|
+
// push to the conversation area, or the text scrolls up with streaming tokens.
|
|
326
441
|
if (state.processing) {
|
|
327
442
|
state.queue.push({ text })
|
|
328
|
-
pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
|
|
329
|
-
pushLine(text, C.dim)
|
|
330
443
|
render()
|
|
331
444
|
return
|
|
332
445
|
}
|
|
@@ -344,7 +457,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
344
457
|
|
|
345
458
|
// Agent loop: implemented in agent-turn.mjs
|
|
346
459
|
const turnCtx = {
|
|
347
|
-
agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
|
|
460
|
+
agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
|
|
348
461
|
askPermission, askQuestion, handleSlash: null, summarize,
|
|
349
462
|
get assistantLabeled() { return assistantLabeled },
|
|
350
463
|
set assistantLabeled(v) { assistantLabeled = v },
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -91,7 +91,10 @@ export function createKeyHandler(ctx) {
|
|
|
91
91
|
insertPastedText(state, text)
|
|
92
92
|
render()
|
|
93
93
|
}
|
|
94
|
-
}).catch(() => {
|
|
94
|
+
}).catch((e) => {
|
|
95
|
+
q._pasting = false
|
|
96
|
+
console.error(`[tui] clipboard paste failed: ${e.message}`)
|
|
97
|
+
})
|
|
95
98
|
} else if (str && !key.ctrl && !key.meta) {
|
|
96
99
|
q.answer = (q.answer ?? "") + str
|
|
97
100
|
render()
|
|
@@ -111,6 +114,38 @@ export function createKeyHandler(ctx) {
|
|
|
111
114
|
setTimeout(() => process.exit(0), 100)
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
// Ctrl+I (or Tab during processing): interrupt and inject a message
|
|
118
|
+
if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
|
|
119
|
+
if (state.processing && state.controller && !state.interruptPrompt) {
|
|
120
|
+
state.interruptPrompt = { text: "" }
|
|
121
|
+
render()
|
|
122
|
+
}
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Interrupt prompt mode: type message, Enter to inject, Esc to cancel
|
|
127
|
+
if (state.interruptPrompt) {
|
|
128
|
+
if (key.name === "escape") {
|
|
129
|
+
state.interruptPrompt = null
|
|
130
|
+
render()
|
|
131
|
+
} else if (key.name === "return") {
|
|
132
|
+
const msg = (state.interruptPrompt.text ?? "").trim()
|
|
133
|
+
state.interruptPrompt = null
|
|
134
|
+
if (msg) {
|
|
135
|
+
pushLine(` [inject] ${msg}`, C.warn)
|
|
136
|
+
state.controller.abort({ interrupt: true, message: msg })
|
|
137
|
+
render()
|
|
138
|
+
}
|
|
139
|
+
} else if (key.name === "backspace") {
|
|
140
|
+
state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
|
|
141
|
+
render()
|
|
142
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
143
|
+
state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
|
|
144
|
+
render()
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
// generic list picker: ↑↓ move, Enter confirm, Esc cancel
|
|
115
150
|
if (state.picker) {
|
|
116
151
|
const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
|
package/src/tui/layout.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
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
|
-
*
|
|
7
|
-
* header → conversation → subagent → output →
|
|
6
|
+
* header → conversation → subagent → output → todo → picker → permission → queue → input → status
|
|
7
|
+
* header → conversation → todo → subagent → output → picker → permission → queue → input → status
|
|
8
8
|
* Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
|
|
9
9
|
*/
|
|
10
10
|
import { layoutInput, wrapText } from "./render.mjs"
|
|
@@ -22,7 +22,9 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
22
22
|
const W = Math.max(20, cols - 1)
|
|
23
23
|
|
|
24
24
|
// --- input box ---
|
|
25
|
-
const
|
|
25
|
+
const inputBuf = state.interruptPrompt ? [...state.interruptPrompt.text] : state.input
|
|
26
|
+
const inputCursor = state.interruptPrompt ? inputBuf.length : state.cursor
|
|
27
|
+
const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
|
|
26
28
|
let inputOffset = 0
|
|
27
29
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
28
30
|
inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
|
|
@@ -98,8 +100,8 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
98
100
|
const conversation = { y, h: convH }; y += convH
|
|
99
101
|
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
100
102
|
const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
|
|
101
|
-
const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
|
|
102
103
|
const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
|
|
104
|
+
const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
|
|
103
105
|
const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
|
|
104
106
|
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
105
107
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -185,27 +185,27 @@ export function createPickers(ctx) {
|
|
|
185
185
|
openPicker({
|
|
186
186
|
title: "Add Provider",
|
|
187
187
|
entries: presetEntries,
|
|
188
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
188
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
189
189
|
onSelect: async (se) => {
|
|
190
190
|
if (se.kind === "custom") {
|
|
191
191
|
const name = await askQuestion("Enter provider name:")
|
|
192
|
-
if (!name) { openModelPicker().catch(() => {}); return }
|
|
193
|
-
if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch(() => {}); return }
|
|
192
|
+
if (!name) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
193
|
+
if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
194
194
|
const baseURLRaw = await askQuestion("Enter baseURL (e.g. https://api.example.com/v1):")
|
|
195
|
-
if (!baseURLRaw) { openModelPicker().catch(() => {}); return }
|
|
195
|
+
if (!baseURLRaw) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
196
196
|
const baseURL = baseURLRaw.replace(/\/+$/, "")
|
|
197
|
-
if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch(() => {}); return }
|
|
197
|
+
if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
198
198
|
const model = await askQuestion("Enter model name:")
|
|
199
|
-
if (!model) { openModelPicker().catch(() => {}); return }
|
|
199
|
+
if (!model) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
200
200
|
agent.providers.push({ name, baseURL, model })
|
|
201
201
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
202
202
|
const key = await askQuestion(`Enter API key for ${name} (leave empty to skip):`)
|
|
203
203
|
if (key) { await setProviderKey(name, key) }
|
|
204
|
-
openModelPicker().catch(() => {})
|
|
204
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
207
|
// preset
|
|
208
|
-
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
|
|
208
|
+
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
209
209
|
const preset = PRESETS[se.name]
|
|
210
210
|
const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
|
|
211
211
|
if (preset.thinking) providerCfg.thinking = preset.thinking
|
|
@@ -217,7 +217,7 @@ export function createPickers(ctx) {
|
|
|
217
217
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
218
218
|
const presetKey = await askQuestion(`Enter API key for ${se.name} (leave empty to skip):`)
|
|
219
219
|
if (presetKey) await setProviderKey(se.name, presetKey)
|
|
220
|
-
openModelPicker().catch(() => {})
|
|
220
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
221
221
|
},
|
|
222
222
|
})
|
|
223
223
|
}
|
|
@@ -225,7 +225,7 @@ export function createPickers(ctx) {
|
|
|
225
225
|
/** Remove provider (cannot remove the currently active one) */
|
|
226
226
|
async function removeProviderFlow() {
|
|
227
227
|
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
228
|
-
if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
|
|
228
|
+
if (candidates.length === 0) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
229
229
|
const removeEntries = [
|
|
230
230
|
{ type: "header", text: "Select provider to remove (current one cannot be removed)" },
|
|
231
231
|
...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
|
|
@@ -233,12 +233,12 @@ export function createPickers(ctx) {
|
|
|
233
233
|
openPicker({
|
|
234
234
|
title: "Remove Provider",
|
|
235
235
|
entries: removeEntries,
|
|
236
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
236
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
237
237
|
onSelect: async (se) => {
|
|
238
238
|
const at = agent.providers.findIndex((p) => p.name === se.name)
|
|
239
239
|
agent.providers.splice(at, 1)
|
|
240
240
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
241
|
-
openModelPicker().catch(() => {})
|
|
241
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
242
242
|
},
|
|
243
243
|
})
|
|
244
244
|
}
|
|
@@ -256,12 +256,12 @@ export function createPickers(ctx) {
|
|
|
256
256
|
openPicker({
|
|
257
257
|
title: "Configure API Key",
|
|
258
258
|
entries: keyEntries,
|
|
259
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
259
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
260
260
|
onSelect: async (se) => {
|
|
261
261
|
const key = await askQuestion(`Enter API key for ${se.name}:`)
|
|
262
|
-
if (!key) { openModelPicker().catch(() => {}); return }
|
|
262
|
+
if (!key) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
263
263
|
await setProviderKey(se.name, key)
|
|
264
|
-
openModelPicker().catch(() => {})
|
|
264
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
265
265
|
},
|
|
266
266
|
})
|
|
267
267
|
}
|