thincoder 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/config.mjs +1 -1
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +1 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +5 -1
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +6 -126
- package/src/provider/google.mjs +4 -2
- package/src/provider/sse.mjs +112 -0
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/git.mjs +9 -6
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +76 -64
- package/src/tui/cmd-advisor.mjs +119 -18
- package/src/tui/index.mjs +7 -187
- package/src/tui/key-handler.mjs +8 -2
- package/src/tui/layout.mjs +1 -1
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +27 -103
- package/src/tui/render-loop.mjs +181 -0
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -1,56 +1,157 @@
|
|
|
1
|
-
/** /advisor command: toggle advisor on/off, select model.
|
|
2
|
-
* ctx: { agent, showPicker, pushLine } */
|
|
1
|
+
/** /advisor command: toggle advisor on/off, select model, configure thinking.
|
|
2
|
+
* ctx: { agent, showPicker, pushLine, persistRaw } */
|
|
3
3
|
import { C } from "./ansi.mjs"
|
|
4
4
|
|
|
5
5
|
export async function handleAdvisorCommand(ctx) {
|
|
6
6
|
const { agent, showPicker, pushLine } = ctx
|
|
7
7
|
const cfg = agent.config.advisor ??= {}
|
|
8
8
|
const enabled = cfg.enabled === true
|
|
9
|
-
const curProvider = cfg.provider ||
|
|
9
|
+
const curProvider = cfg.provider || "(main)"
|
|
10
10
|
const curModel = cfg.model || agent.provider.model
|
|
11
|
+
const thinkInfo = cfg.thinking === null ? "off"
|
|
12
|
+
: cfg.thinking?.type === "disabled" ? "off"
|
|
13
|
+
: cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
|
|
14
|
+
: cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
|
|
11
15
|
|
|
12
16
|
const entries = [
|
|
13
17
|
{ type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
|
|
14
|
-
{ type: "item", text: `Model: ${
|
|
18
|
+
{ type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
|
|
19
|
+
{ type: "item", text: `Thinking: ${thinkInfo}`, action: "thinking" },
|
|
15
20
|
]
|
|
16
21
|
|
|
17
22
|
const e = await showPicker("Advisor", entries)
|
|
18
23
|
if (!e) return
|
|
24
|
+
|
|
25
|
+
const persist = async () => {
|
|
26
|
+
if (ctx.persistRaw) {
|
|
27
|
+
await ctx.persistRaw((raw) => {
|
|
28
|
+
raw.agent ??= {}
|
|
29
|
+
raw.agent.advisor = cfg
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
if (e.action === "toggle") {
|
|
20
35
|
cfg.enabled = !cfg.enabled
|
|
21
36
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
22
37
|
if (cfg.enabled) {
|
|
23
|
-
agent._pendingReminders.push("[System reminder: Advisor review is now ON.
|
|
38
|
+
agent._pendingReminders.push("[System reminder: Advisor review is now ON. You can call the `advisor` tool to get an independent code review before finalising your work. The advisor is an independent read-only sub-agent that explores the codebase, runs git diff, reads files, and traces callers via grep/lsp.]")
|
|
24
39
|
} else {
|
|
25
|
-
agent._pendingReminders.push("[System reminder: Advisor review is now OFF.
|
|
40
|
+
agent._pendingReminders.push("[System reminder: Advisor review is now OFF. The `advisor` tool will not produce results.]")
|
|
26
41
|
}
|
|
42
|
+
await persist().catch(err => pushLine(`[error] Advisor toggle: ${err.message}`, C.error))
|
|
27
43
|
} else if (e.action === "model") {
|
|
28
|
-
await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
44
|
+
await openAdvisorModelPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
45
|
+
} else if (e.action === "thinking") {
|
|
46
|
+
await openAdvisorThinkingPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
29
47
|
}
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
async function openAdvisorModelPicker(ctx) {
|
|
50
|
+
async function openAdvisorModelPicker(ctx, persist) {
|
|
33
51
|
const { agent, showPicker, pushLine } = ctx
|
|
34
52
|
const providers = agent.providers || []
|
|
53
|
+
const cfg = agent.config.advisor ??= {}
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
55
|
+
const entries = [
|
|
56
|
+
{ type: "item", text: "Use main model", action: "inherit", marker: !cfg.provider ? "●" : "" },
|
|
57
|
+
]
|
|
38
58
|
for (const p of providers) {
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
entries.push({ type: "header", text: p.name, note: `${p.baseURL}${agent.activeProvider === p.name ? " ← active" : ""} loading…` })
|
|
60
|
+
const mark = cfg.provider === p.name && cfg.model === p.model ? "● " : " "
|
|
61
|
+
entries.push({ type: "item", text: `${mark}${p.model}`, action: "switch", provider: p.name, model: p.model })
|
|
41
62
|
}
|
|
42
63
|
|
|
64
|
+
// Fetch models first, then show picker
|
|
65
|
+
await fetchAdvisorModels(entries, providers, agent)
|
|
66
|
+
|
|
43
67
|
const e = await showPicker("Advisor Model", entries)
|
|
44
|
-
if (e
|
|
45
|
-
|
|
46
|
-
if (e.
|
|
47
|
-
// Same as main — clear override (use main pool)
|
|
68
|
+
if (!e) return
|
|
69
|
+
|
|
70
|
+
if (e.action === "inherit") {
|
|
48
71
|
delete cfg.provider
|
|
49
72
|
delete cfg.model
|
|
50
|
-
pushLine(
|
|
51
|
-
} else {
|
|
73
|
+
pushLine("Advisor: using main model", C.dim)
|
|
74
|
+
} else if (e.action === "switch") {
|
|
52
75
|
cfg.provider = e.provider
|
|
53
76
|
cfg.model = e.model
|
|
54
77
|
pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
|
|
55
78
|
}
|
|
79
|
+
await persist()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchAdvisorModels(entries, providers, agent) {
|
|
83
|
+
const { listModels } = await import("../provider/index.mjs")
|
|
84
|
+
await Promise.all(providers.map(async (p) => {
|
|
85
|
+
try {
|
|
86
|
+
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
|
|
87
|
+
let apiKey = p.apiKey
|
|
88
|
+
if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
|
|
89
|
+
if (!apiKey) apiKey = process.env.THINCODER_API_KEY
|
|
90
|
+
const models = await listModels({ baseURL: p.baseURL, apiKey: apiKey ?? "" }, { signal: AbortSignal.timeout(10000) })
|
|
91
|
+
const at = entries.findLastIndex((e) => e.type === "header" && e.text === p.name)
|
|
92
|
+
if (at < 0) return
|
|
93
|
+
entries.splice(at + 2, 0, ...models
|
|
94
|
+
.filter((m) => m !== p.model)
|
|
95
|
+
.map((m) => ({ type: "item", text: ` ${m}`, action: "switch", provider: p.name, model: m })))
|
|
96
|
+
const header = entries[at]
|
|
97
|
+
header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""}`
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const header = entries.find((e) => e.type === "header" && e.text === p.name)
|
|
100
|
+
if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""} (fetch failed: ${err.message.slice(0, 40)})`
|
|
101
|
+
}
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function openAdvisorThinkingPicker(ctx, persist) {
|
|
106
|
+
const { agent, showPicker, pushLine } = ctx
|
|
107
|
+
const { specForModel } = await import("../config.mjs")
|
|
108
|
+
const cfg = agent.config.advisor ??= {}
|
|
109
|
+
|
|
110
|
+
const providerForDefaults = cfg.provider
|
|
111
|
+
? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
|
|
112
|
+
: agent.provider
|
|
113
|
+
const effectiveModel = cfg.model || providerForDefaults.model
|
|
114
|
+
const spec = specForModel(effectiveModel)
|
|
115
|
+
const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
|
|
116
|
+
const isCustomThink = thinkOnValue !== "enabled"
|
|
117
|
+
const isEffortOnly = spec.thinkApi === "effort"
|
|
118
|
+
const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
|
|
119
|
+
|
|
120
|
+
const curEffort = cfg.reasoningEffort ?? providerForDefaults.reasoningEffort
|
|
121
|
+
const curThinking = cfg.thinking ?? providerForDefaults.thinking
|
|
122
|
+
const thinkingEnabled = curThinking?.type === thinkOnValue
|
|
123
|
+
|| (curThinking?.type === undefined && !isCustomThink)
|
|
124
|
+
|
|
125
|
+
const entries = [
|
|
126
|
+
{ type: "item", text: "Use main model settings", action: "inherit" },
|
|
127
|
+
]
|
|
128
|
+
if (!isEffortOnly) {
|
|
129
|
+
entries.push({ type: "header", text: "Thinking mode" })
|
|
130
|
+
entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
|
|
131
|
+
entries.push({ type: "item", text: `Disabled ${curThinking?.type === "disabled" || curThinking === null ? "← current" : ""}`, action: "think_off" })
|
|
132
|
+
}
|
|
133
|
+
entries.push({ type: "header", text: "Reasoning effort" })
|
|
134
|
+
for (const level of effortLevels) {
|
|
135
|
+
entries.push({ type: "item", text: `${level} ${curEffort === level ? "← current" : ""}`, action: `effort_${level}` })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const e = await showPicker("Advisor Thinking", entries)
|
|
139
|
+
if (!e) return
|
|
140
|
+
|
|
141
|
+
if (e.action === "inherit") {
|
|
142
|
+
delete cfg.thinking
|
|
143
|
+
delete cfg.reasoningEffort
|
|
144
|
+
pushLine("Advisor: using main model thinking settings", C.dim)
|
|
145
|
+
} else if (e.action === "think_on") {
|
|
146
|
+
cfg.thinking = { type: thinkOnValue }
|
|
147
|
+
if (isEffortOnly) delete cfg.thinking
|
|
148
|
+
pushLine(`Advisor: thinking ON (${thinkOnValue})`, C.dim)
|
|
149
|
+
} else if (e.action === "think_off") {
|
|
150
|
+
cfg.thinking = isCustomThink ? null : { type: "disabled" }
|
|
151
|
+
pushLine("Advisor: thinking OFF", C.dim)
|
|
152
|
+
} else if (e.action.startsWith("effort_")) {
|
|
153
|
+
cfg.reasoningEffort = e.action.slice(7)
|
|
154
|
+
pushLine(`Advisor: reasoning effort = ${cfg.reasoningEffort}`, C.dim)
|
|
155
|
+
}
|
|
156
|
+
await persist()
|
|
56
157
|
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Large logic blocks extracted to independent modules:
|
|
7
7
|
* agent-turn.mjs — agent loop + callback construction
|
|
8
|
-
*
|
|
8
|
+
* render-loop.mjs — frame scheduler + incremental panel rendering
|
|
9
9
|
* startup.mjs — startup screen + session restore + background indexing
|
|
10
10
|
* interaction.mjs — permission approval + Q&A
|
|
11
11
|
* pickers.mjs — generic list picker + model picker
|
|
@@ -20,15 +20,8 @@ import { emitKeypressEvents } from "node:readline"
|
|
|
20
20
|
import { PassThrough } from "node:stream"
|
|
21
21
|
import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
|
|
22
22
|
import { closeAllMcp } from "../mcp.mjs"
|
|
23
|
-
import { estimateTokens } from "../context.mjs"
|
|
24
23
|
import { ansi, C } from "./ansi.mjs"
|
|
25
|
-
import {
|
|
26
|
-
renderFrame, countConvLines, convCacheKey,
|
|
27
|
-
renderHeader, renderConversation, renderTodo, renderSubagent,
|
|
28
|
-
renderOutput, renderPermission, renderQueue, renderPicker,
|
|
29
|
-
renderInputBox, renderStatus,
|
|
30
|
-
} from "./render-frame.mjs"
|
|
31
|
-
import { computeLayout } from "./layout.mjs"
|
|
24
|
+
import { createRenderLoop } from "./render-loop.mjs"
|
|
32
25
|
import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
|
|
33
26
|
import { createWizard } from "./wizard.mjs"
|
|
34
27
|
import { createPickers } from "./pickers.mjs"
|
|
@@ -247,184 +240,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
247
240
|
|
|
248
241
|
// ---------------------------------------------------------- Render
|
|
249
242
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
let renderRequested = false, renderTimer = null, lastRenderAt = 0
|
|
256
|
-
const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
|
|
257
|
-
|
|
258
|
-
function scheduleRender() {
|
|
259
|
-
if (renderTimer) return
|
|
260
|
-
const elapsed = performance.now() - lastRenderAt
|
|
261
|
-
const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
|
|
262
|
-
renderTimer = setTimeout(() => {
|
|
263
|
-
renderTimer = null
|
|
264
|
-
if (!renderRequested) return
|
|
265
|
-
renderRequested = false
|
|
266
|
-
lastRenderAt = performance.now()
|
|
267
|
-
doRender()
|
|
268
|
-
if (renderRequested) scheduleRender() // more requests arrived during render
|
|
269
|
-
}, delay)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Rate-limited render entry point. All call sites use this. */
|
|
273
|
-
function render() {
|
|
274
|
-
if (renderRequested) return
|
|
275
|
-
renderRequested = true
|
|
276
|
-
// process.nextTick merges multiple synchronous render() calls
|
|
277
|
-
// within the same tick into a single scheduleRender call.
|
|
278
|
-
process.nextTick(() => scheduleRender())
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
|
|
282
|
-
function buildPanel(name, panelLayout, lines, cacheKey) {
|
|
283
|
-
if (!panelLayout) {
|
|
284
|
-
if (panelCache.has(name)) panelCache.delete(name)
|
|
285
|
-
return null
|
|
286
|
-
}
|
|
287
|
-
const content = lines.join("\r\n")
|
|
288
|
-
const cached = panelCache.get(name)
|
|
289
|
-
const effectiveKey = cacheKey ?? content
|
|
290
|
-
if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
|
|
291
|
-
const rows = []
|
|
292
|
-
for (let i = 0; i < panelLayout.h; i++) {
|
|
293
|
-
rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
|
|
294
|
-
}
|
|
295
|
-
panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
|
|
296
|
-
return rows.join("")
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Detect if panel layout structure changed (appeared/disappeared/shifted).
|
|
300
|
-
* Only checks panels that are ALREADY cached — new panels (not yet written)
|
|
301
|
-
* are not a structural change; the incremental path will write them naturally. */
|
|
302
|
-
function layoutStructureChanged(layout) {
|
|
303
|
-
for (const [name, cached] of panelCache) {
|
|
304
|
-
const p = layout.panels[name] ?? null
|
|
305
|
-
if (p == null) return true // cached panel disappeared → layout changed
|
|
306
|
-
if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
|
|
307
|
-
}
|
|
308
|
-
return false
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function doRender() {
|
|
312
|
-
try {
|
|
313
|
-
const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
|
|
314
|
-
const layout = computeLayout(state, dims)
|
|
315
|
-
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
316
|
-
|
|
317
|
-
// Side effects: clamp scroll + overlay + update ctxCache
|
|
318
|
-
const convLines = countConvLines(state, dims.cols)
|
|
319
|
-
state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
|
|
320
|
-
if (overlay && panels.picker) {
|
|
321
|
-
const winH = panels.picker.h - 1
|
|
322
|
-
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
323
|
-
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
324
|
-
}
|
|
325
|
-
if (state.ctxCache.len !== agent.history.length) {
|
|
326
|
-
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
327
|
-
}
|
|
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
|
-
|
|
337
|
-
// Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
|
|
338
|
-
// Don't clear panelCache — update positions so the next incremental check
|
|
339
|
-
// sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
|
|
340
|
-
// per panel on the next frame (much cheaper than another full redraw).
|
|
341
|
-
if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
|
|
342
|
-
lastCols = dims.cols; lastRows = dims.rows
|
|
343
|
-
// Update cached panel positions (content stays stale → next frame rewrites)
|
|
344
|
-
for (const [name, panelLayout] of Object.entries(panels)) {
|
|
345
|
-
if (!panelLayout) { panelCache.delete(name); continue }
|
|
346
|
-
const cached = panelCache.get(name)
|
|
347
|
-
if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
|
|
348
|
-
}
|
|
349
|
-
const isStreaming = state.processing && !state.permission && !state.question && !state.picker
|
|
350
|
-
const isWizard = state.wizard?.step === "provider"
|
|
351
|
-
// Content + cursor in a single write. Hardware cursor stays hidden —
|
|
352
|
-
// the visual cursor is drawn in the input box as SGR reverse video.
|
|
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 })
|
|
355
|
-
if (isStreaming) {
|
|
356
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
357
|
-
} else if (isWizard) {
|
|
358
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
|
|
359
|
-
} else {
|
|
360
|
-
process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
|
|
361
|
-
}
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// ---- Incremental rendering (layout stable) ----
|
|
366
|
-
// pi-tui pattern: content inside sync-update block; cursor outside.
|
|
367
|
-
// DECSET 2026 buffers all panel writes and renders them atomically.
|
|
368
|
-
// Cursor hide/show/position MUST be outside — otherwise the terminal's
|
|
369
|
-
// internal cursor state machine and the sync render buffer can disagree.
|
|
370
|
-
const out = []
|
|
371
|
-
const push = (s) => { if (s != null) out.push(s) }
|
|
372
|
-
|
|
373
|
-
// Always-visible panels
|
|
374
|
-
push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
|
|
375
|
-
push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
|
|
376
|
-
push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
|
|
377
|
-
|
|
378
|
-
// Conversation: line-level cache — only push changed lines
|
|
379
|
-
const convKey = convCacheKey(state)
|
|
380
|
-
const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
|
|
381
|
-
if (convChanged) {
|
|
382
|
-
lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
|
|
383
|
-
const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
|
|
384
|
-
const y = panels.conversation.y + 1
|
|
385
|
-
for (let i = 0; i < lines.length; i++) {
|
|
386
|
-
if (lines[i] !== convLineCache[i]) {
|
|
387
|
-
out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
|
|
388
|
-
convLineCache[i] = lines[i]
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (convLineCache.length > lines.length) {
|
|
392
|
-
for (let i = lines.length; i < convLineCache.length; i++) {
|
|
393
|
-
out.push(`\x1b[${y + i};1H\x1b[K`)
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
convLineCache.length = lines.length
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Conditional panels
|
|
400
|
-
push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
|
|
401
|
-
push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
|
|
402
|
-
push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
|
|
403
|
-
push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
|
|
404
|
-
if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
|
|
405
|
-
else panelCache.delete("queue")
|
|
406
|
-
if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
|
|
407
|
-
else panelCache.delete("picker")
|
|
408
|
-
|
|
409
|
-
// Determine cursor suffix — appended to the same write() as the sync block.
|
|
410
|
-
// MUST position the cursor at the input box even when hidden: the terminal's
|
|
411
|
-
// cursor position determines where the IME candidate window appears.
|
|
412
|
-
// pi-tui's positionHardwareCursor does the same — positions first, then
|
|
413
|
-
// decides show/hide based on showHardwareCursor.
|
|
414
|
-
// Hardware cursor stays hidden — the visual cursor is drawn in the input
|
|
415
|
-
// box text as SGR reverse video (matching pi-tui's approach).
|
|
416
|
-
// We still position the hardware cursor for IME candidate window placement.
|
|
417
|
-
const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
418
|
-
const cc = 3 + inputLayout.cursorCol
|
|
419
|
-
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
420
|
-
const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
|
|
421
|
-
|
|
422
|
-
// Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
|
|
423
|
-
if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
424
|
-
} catch (e) {
|
|
425
|
-
// Don't let a render error crash the TUI
|
|
426
|
-
}
|
|
427
|
-
}
|
|
243
|
+
const renderLoop = createRenderLoop(state, agent,
|
|
244
|
+
{ startupDims: { cols: startupCols, rows: startupRows }, SLASH_COMMANDS,
|
|
245
|
+
pendingNoticeReady, get showUpdateNotice() { return showUpdateNotice } },
|
|
246
|
+
pushLine)
|
|
247
|
+
const { render, scheduleRender } = renderLoop
|
|
428
248
|
|
|
429
249
|
process.stdout.on("resize", () => {
|
|
430
250
|
try { render() } catch { /* resize error — ignore */ }
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -140,8 +140,14 @@ export function createKeyHandler(ctx) {
|
|
|
140
140
|
const msg = (state.interruptPrompt.text ?? "").trim()
|
|
141
141
|
state.interruptPrompt = null
|
|
142
142
|
if (msg) {
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// Guard: if the turn already finished while the user was typing, the controller
|
|
144
|
+
// may have been replaced or already aborted — don't abort a live turn by mistake.
|
|
145
|
+
if (state.processing && state.controller && !state.controller.signal.aborted) {
|
|
146
|
+
pushLine(` [inject] ${msg}`, C.warn)
|
|
147
|
+
state.controller.abort({ interrupt: true, message: msg })
|
|
148
|
+
} else {
|
|
149
|
+
pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
|
|
150
|
+
}
|
|
145
151
|
render()
|
|
146
152
|
}
|
|
147
153
|
} else if (key.name === "backspace") {
|
package/src/tui/layout.mjs
CHANGED
|
@@ -70,7 +70,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
70
70
|
: 0
|
|
71
71
|
|
|
72
72
|
// Tool output panels: max 8 lines per panel, capped at reasonable total
|
|
73
|
-
const panels = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
73
|
+
const panels = Object.values(state.outputPanels).filter((p) => !p.done || p._pendingDone)
|
|
74
74
|
const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
|
|
75
75
|
|
|
76
76
|
// Permission preview (height depends on wrapped content)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-conversation.mjs — conversation panel line builder
|
|
3
|
+
* Extracted from render-frame.mjs.
|
|
4
|
+
*/
|
|
5
|
+
import { ansi, C } from "./ansi.mjs"
|
|
6
|
+
import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
|
|
7
|
+
|
|
8
|
+
let _convCache = { key: "", cols: 0, lines: [] }
|
|
9
|
+
|
|
10
|
+
export function convCacheKey(state) {
|
|
11
|
+
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
12
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildConvLines(state, cols) {
|
|
16
|
+
const key = convCacheKey(state)
|
|
17
|
+
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
18
|
+
|
|
19
|
+
const convLines = []
|
|
20
|
+
for (const l of state.lines) {
|
|
21
|
+
for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
|
|
22
|
+
for (const wrapped of wrapText(line, cols - 1)) {
|
|
23
|
+
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (state.reasoning) {
|
|
28
|
+
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
29
|
+
convLines.push({ text: wrapped, color: C.reason })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (state.streaming) {
|
|
33
|
+
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
34
|
+
for (const wrapped of wrapText(line, cols - 1)) {
|
|
35
|
+
convLines.push({ text: wrapped, color: C.text })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const allStreams = Object.values(state.toolStreams).join("")
|
|
40
|
+
if (allStreams) {
|
|
41
|
+
const tail = sanitizeDisplay(allStreams.slice(-4000))
|
|
42
|
+
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
43
|
+
convLines.push({ text: wrapped, color: C.dim })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fold long blocks (> 8 consecutive dim lines)
|
|
48
|
+
const FOLD_LINES = 8
|
|
49
|
+
let foldCounter = 0
|
|
50
|
+
const folded = []
|
|
51
|
+
let i = 0
|
|
52
|
+
while (i < convLines.length) {
|
|
53
|
+
const line = convLines[i]
|
|
54
|
+
if (line.color === C.dim) {
|
|
55
|
+
let j = i
|
|
56
|
+
while (j < convLines.length && convLines[j].color === C.dim) j++
|
|
57
|
+
const blockLen = j - i
|
|
58
|
+
if (blockLen > FOLD_LINES) {
|
|
59
|
+
const foldKey = `fold-${foldCounter++}`
|
|
60
|
+
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
61
|
+
folded.push(convLines[i])
|
|
62
|
+
if (blockLen > 2) folded.push(convLines[i + 1])
|
|
63
|
+
folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
|
|
64
|
+
i = j
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
folded.push(line)
|
|
70
|
+
i++
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_convCache = { key, cols, lines: folded }
|
|
74
|
+
return folded
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function countConvLines(state, cols) {
|
|
78
|
+
return buildConvLines(state, cols).length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderConversation(state, cols, visibleH, scroll) {
|
|
82
|
+
const convLines = buildConvLines(state, cols)
|
|
83
|
+
const maxScroll = Math.max(0, convLines.length - visibleH)
|
|
84
|
+
const clamped = Math.min(scroll, maxScroll)
|
|
85
|
+
const end = convLines.length - clamped
|
|
86
|
+
const visible = convLines.slice(Math.max(0, end - visibleH), end)
|
|
87
|
+
const pad = visibleH - visible.length
|
|
88
|
+
const out = []
|
|
89
|
+
for (let p = 0; p < pad; p++) out.push("")
|
|
90
|
+
for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
|
|
91
|
+
return out
|
|
92
|
+
}
|