thincoder 0.11.0 → 0.12.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 +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +535 -72
- package/src/agent/helpers.mjs +18 -5
- 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 +80 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +20 -0
- package/src/config.mjs +1 -1
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.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/skills.mjs +67 -31
- 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 +43 -2
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +130 -73
- package/src/tui/cmd-advisor.mjs +237 -41
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +9 -188
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +8 -4
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +94 -168
- package/src/tui/render-loop.mjs +110 -0
package/src/tui/render-frame.mjs
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* The legacy renderFrame() wrapper is kept for compatibility.
|
|
8
8
|
*/
|
|
9
9
|
import { ansi, C, ESC } from "./ansi.mjs"
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay,
|
|
13
|
-
} from "./render.mjs"
|
|
10
|
+
import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
|
|
11
|
+
import { sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay } from "./render.mjs"
|
|
14
12
|
import { specForModel } from "../config.mjs"
|
|
13
|
+
import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
|
|
15
14
|
import { basename } from "node:path"
|
|
16
15
|
|
|
16
|
+
export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
|
|
17
|
+
|
|
17
18
|
// ---------- status bar slash-command hints ----------
|
|
18
19
|
const SLASH_HINTS = {
|
|
19
20
|
"/config": "open config menu",
|
|
@@ -44,30 +45,6 @@ export function renderHeader(agent, cols) {
|
|
|
44
45
|
return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
* Compute a cheap cache key for the conversation panel.
|
|
49
|
-
* Structural hints that change ONLY when the conversation actually changes.
|
|
50
|
-
* Used by the incremental renderer to skip rebuilding convLines when nothing changed.
|
|
51
|
-
*/
|
|
52
|
-
export function convCacheKey(state) {
|
|
53
|
-
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
54
|
-
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Conversation panel (scrollable, variable height). Returns exactly `visibleH` lines. */
|
|
58
|
-
export function renderConversation(state, cols, visibleH, scroll) {
|
|
59
|
-
const convLines = buildConvLines(state, cols)
|
|
60
|
-
const maxScroll = Math.max(0, convLines.length - visibleH)
|
|
61
|
-
const clamped = Math.min(scroll, maxScroll)
|
|
62
|
-
const end = convLines.length - clamped
|
|
63
|
-
const visible = convLines.slice(Math.max(0, end - visibleH), end)
|
|
64
|
-
const pad = visibleH - visible.length
|
|
65
|
-
const out = []
|
|
66
|
-
for (let i = 0; i < pad; i++) out.push("")
|
|
67
|
-
for (const l of visible) out.push(`${l.color}${l.text}${ansi.reset}`)
|
|
68
|
-
return out
|
|
69
|
-
}
|
|
70
|
-
|
|
71
48
|
/** Todo/task panel. Returns empty array when no tasks visible. */
|
|
72
49
|
export function renderTodo(visibleTasks, cols) {
|
|
73
50
|
return visibleTasks.map((t) => {
|
|
@@ -106,24 +83,47 @@ export function renderSubagent(allSubs, W) {
|
|
|
106
83
|
return out
|
|
107
84
|
}
|
|
108
85
|
|
|
109
|
-
/**
|
|
86
|
+
/** Per-kind panel colors: reasoning faint, tool progress cyan, main output gray. */
|
|
87
|
+
const PANEL_KIND_COLORS = { think: C.reason, tool: C.tool, text: C.dim }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Flatten panel parts into display lines, tracking each line's kind.
|
|
91
|
+
* A part not starting mid-line continues the previous line (kind of the line's first fragment wins).
|
|
92
|
+
*/
|
|
93
|
+
function panelLines(p) {
|
|
94
|
+
const lines = []
|
|
95
|
+
for (const part of p.parts ?? []) {
|
|
96
|
+
part.text.split("\n").forEach((seg, j) => {
|
|
97
|
+
if (j === 0 && lines.length > 0) {
|
|
98
|
+
const last = lines[lines.length - 1]
|
|
99
|
+
// Empty trailing line = previous part ended exactly at a line break — the new
|
|
100
|
+
// part owns this line's kind. Otherwise it's a genuine mid-line continuation.
|
|
101
|
+
if (last.text === "") last.kind = part.kind
|
|
102
|
+
last.text += seg
|
|
103
|
+
} else {
|
|
104
|
+
lines.push({ kind: part.kind, text: seg })
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return lines.filter((l) => l.text.trim())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Tool output panels (streaming output like tail -f). Returns empty when no visible output. */
|
|
110
112
|
export function renderOutput(state, W, panelH) {
|
|
111
|
-
|
|
113
|
+
// Same visibility predicate as computeLayout: done panels linger until closeAt
|
|
114
|
+
const active = Object.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
112
115
|
if (active.length === 0) return []
|
|
113
116
|
const out = []
|
|
114
117
|
const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
out.push(`${
|
|
118
|
+
const perPanel = active.map((p) => panelLines(p).slice(-linesPerPanel))
|
|
119
|
+
for (const lines of perPanel) {
|
|
120
|
+
for (const l of lines) {
|
|
121
|
+
const color = PANEL_KIND_COLORS[l.kind] ?? C.dim
|
|
122
|
+
out.push(`${color} │ ${sliceByWidth(sanitizeDisplay(l.text), W - 5)}${ansi.reset}`)
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
// Fill remaining rows to match panelH exactly
|
|
123
|
-
const used =
|
|
124
|
-
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
125
|
-
return s + tl.length
|
|
126
|
-
}, 0)
|
|
126
|
+
const used = perPanel.reduce((s, lines) => s + lines.length, 0)
|
|
127
127
|
for (let i = used; i < panelH; i++) out.push("")
|
|
128
128
|
return out
|
|
129
129
|
}
|
|
@@ -204,17 +204,36 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
204
204
|
|
|
205
205
|
for (let li = 0; li < boxLines.length; li++) {
|
|
206
206
|
const l = boxLines[li]
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
const original = sliceByWidth(l, W - 4)
|
|
208
|
+
let content = original
|
|
209
|
+
const contentWidth = stringWidth(original)
|
|
210
|
+
let fillLen = W - 4 - contentWidth
|
|
209
211
|
|
|
210
212
|
if (li === curLine && curCol >= 0) {
|
|
211
|
-
const beforeWidth = Math.min(curCol,
|
|
213
|
+
const beforeWidth = Math.min(curCol, contentWidth)
|
|
212
214
|
const before = sliceByWidth(content, beforeWidth)
|
|
213
215
|
const atIdx = before.length // character index (not display width — CJK chars diverge)
|
|
214
216
|
const at = content[atIdx] ?? " "
|
|
215
217
|
const after = content.slice(atIdx + 1)
|
|
216
218
|
content = before + `${ansi.reset}\x1b[7m${at}\x1b[27m${ansi.reset}` + after
|
|
219
|
+
// Cursor at end of input: at=[\x20] adds one display column — compensate fill.
|
|
220
|
+
// When fill is already 0 (content fills the line), don't add the extra space at all
|
|
221
|
+
// — instead, move the cursor to the last real character.
|
|
222
|
+
if (atIdx >= original.length) {
|
|
223
|
+
if (fillLen > 0) {
|
|
224
|
+
fillLen = Math.max(0, fillLen - 1)
|
|
225
|
+
} else {
|
|
226
|
+
// No room for extra space: place cursor on last character instead
|
|
227
|
+
const lastIdx = original.length - 1
|
|
228
|
+
if (lastIdx >= 0) {
|
|
229
|
+
const before2 = sliceByWidth(original, stringWidth(original.slice(0, lastIdx)))
|
|
230
|
+
const at2 = original[lastIdx] ?? " "
|
|
231
|
+
content = before2 + `${ansi.reset}\x1b[7m${at2}\x1b[27m${ansi.reset}`
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
217
235
|
}
|
|
236
|
+
const fill = " ".repeat(Math.max(0, fillLen))
|
|
218
237
|
|
|
219
238
|
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}`)
|
|
220
239
|
}
|
|
@@ -234,158 +253,65 @@ export function renderStatus(state, agent, cols, slashCommands) {
|
|
|
234
253
|
}
|
|
235
254
|
|
|
236
255
|
// ====================================================================
|
|
237
|
-
//
|
|
256
|
+
// Frame composition
|
|
238
257
|
// ====================================================================
|
|
239
258
|
|
|
240
259
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
260
|
+
* Compose the whole screen as a rows array, placing each panel at its
|
|
261
|
+
* layout-computed y coordinate. Single source of truth for what the screen
|
|
262
|
+
* should look like — the render loop diffs these rows against what it last
|
|
263
|
+
* wrote and repaints only changed rows (absolute positioning).
|
|
264
|
+
*
|
|
265
|
+
* @returns {{ rows: string[], cursorRow: number, cursorCol: number, layout: object }}
|
|
244
266
|
*/
|
|
245
|
-
export function
|
|
267
|
+
export function renderRows(state, agent, opts) {
|
|
246
268
|
const cols = opts.cols || 80
|
|
247
269
|
const rows = opts.rows || 24
|
|
248
270
|
const slashCommands = opts.slashCommands ?? []
|
|
249
|
-
const platform = opts.platform ?? process.platform
|
|
250
271
|
|
|
251
272
|
const layout = computeLayout(state, { cols, rows })
|
|
252
273
|
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
253
274
|
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
out.push(`${renderHeader(agent, cols)}\x1b[K`)
|
|
259
|
-
|
|
260
|
-
// conversation
|
|
261
|
-
for (const l of renderConversation(state, cols, panels.conversation.h, state.scroll)) {
|
|
262
|
-
out.push(`${l}\x1b[K`)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// picker
|
|
266
|
-
if (panels.picker) {
|
|
267
|
-
for (const l of renderPicker(state, cols, panels.picker, overlay)) {
|
|
268
|
-
out.push(`${l}\x1b[K`)
|
|
275
|
+
const screen = new Array(rows).fill("")
|
|
276
|
+
const put = (y, lines) => {
|
|
277
|
+
for (let i = 0; i < lines.length && y + i < rows; i++) {
|
|
278
|
+
if (y + i >= 0) screen[y + i] = `${lines[i]}\x1b[K`
|
|
269
279
|
}
|
|
270
280
|
}
|
|
271
281
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (panels.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
for (const l of renderOutput(state, W, panels.output.h)) out.push(`${l}\x1b[K`)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// permission preview
|
|
286
|
-
if (panels.permission) {
|
|
287
|
-
for (const l of renderPermission(permPreviewLines)) out.push(`${l}\x1b[K`)
|
|
288
|
-
}
|
|
282
|
+
put(panels.header.y, [renderHeader(agent, cols)])
|
|
283
|
+
put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll))
|
|
284
|
+
if (panels.subagent) put(panels.subagent.y, renderSubagent(allSubs, W))
|
|
285
|
+
if (panels.output) put(panels.output.y, renderOutput(state, W, panels.output.h))
|
|
286
|
+
if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
|
|
287
|
+
if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
|
|
288
|
+
if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
|
|
289
|
+
if (panels.queue) put(panels.queue.y, [renderQueue(state, W)])
|
|
290
|
+
put(panels.inputBox.y, renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset))
|
|
291
|
+
put(panels.status.y, [renderStatus(state, agent, cols, slashCommands)])
|
|
289
292
|
|
|
290
|
-
|
|
291
|
-
if (panels.queue) {
|
|
292
|
-
const qLine = renderQueue(state, W)
|
|
293
|
-
if (qLine) out.push(`${qLine}\x1b[K`)
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// input box
|
|
297
|
-
for (const l of renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset)) out.push(`${l}\x1b[K`)
|
|
298
|
-
|
|
299
|
-
// status bar
|
|
300
|
-
out.push(`${renderStatus(state, agent, cols, slashCommands)}\x1b[K`)
|
|
301
|
-
|
|
302
|
-
const frame = out.join("\r\n")
|
|
303
|
-
|
|
304
|
-
// cursor position
|
|
293
|
+
let cursorRow = 0, cursorCol = 0
|
|
305
294
|
if (!state.permission && !state.question && !state.picker && state.wizard?.step !== "provider") {
|
|
306
295
|
cursorRow = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
307
296
|
cursorCol = 3 + inputLayout.cursorCol
|
|
308
297
|
}
|
|
309
298
|
|
|
310
|
-
return {
|
|
299
|
+
return { rows: screen, cursorRow, cursorCol, layout }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Render one frame, returns { frame, cursorRow, cursorCol }.
|
|
304
|
+
* @deprecated Use renderRows + row-diff (render-loop). Kept for tests/legacy callers.
|
|
305
|
+
*/
|
|
306
|
+
export function renderFrame(state, agent, opts) {
|
|
307
|
+
const { rows, cursorRow, cursorCol } = renderRows(state, agent, opts)
|
|
308
|
+
return { frame: rows.join("\r\n"), cursorRow, cursorCol }
|
|
311
309
|
}
|
|
312
310
|
|
|
313
311
|
// ====================================================================
|
|
314
312
|
// Internal helpers (unchanged from original)
|
|
315
313
|
// ====================================================================
|
|
316
314
|
|
|
317
|
-
export function countConvLines(state, cols) {
|
|
318
|
-
return buildConvLines(state, cols).length
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
let _convCache = { key: "", cols: 0, lines: [] }
|
|
322
|
-
function buildConvLines(state, cols) {
|
|
323
|
-
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
324
|
-
const key = convCacheKey(state)
|
|
325
|
-
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
326
|
-
|
|
327
|
-
const convLines = []
|
|
328
|
-
for (const l of state.lines) {
|
|
329
|
-
for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
|
|
330
|
-
for (const wrapped of wrapText(line, cols - 1)) {
|
|
331
|
-
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// Messages after the conversation (streaming / thinking / tool output):
|
|
336
|
-
// appended after history lines so they appear at the bottom.
|
|
337
|
-
if (state.reasoning) {
|
|
338
|
-
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
339
|
-
convLines.push({ text: wrapped, color: C.reason })
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (state.streaming) {
|
|
343
|
-
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
344
|
-
for (const wrapped of wrapText(line, cols - 1)) {
|
|
345
|
-
convLines.push({ text: wrapped, color: C.text })
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
const allStreams = Object.values(state.toolStreams).join("")
|
|
350
|
-
if (allStreams) {
|
|
351
|
-
const tail = sanitizeDisplay(allStreams.slice(-4000))
|
|
352
|
-
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
353
|
-
convLines.push({ text: wrapped, color: C.dim })
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ---- Fold long blocks (> 8 consecutive dim lines) ----
|
|
358
|
-
const FOLD_LINES = 8
|
|
359
|
-
let foldCounter = 0
|
|
360
|
-
const folded = []
|
|
361
|
-
let i = 0
|
|
362
|
-
while (i < convLines.length) {
|
|
363
|
-
const line = convLines[i]
|
|
364
|
-
// Only fold dim-colored lines (tool results, subagent previews)
|
|
365
|
-
if (line.color === C.dim) {
|
|
366
|
-
let j = i
|
|
367
|
-
while (j < convLines.length && convLines[j].color === C.dim) j++
|
|
368
|
-
const blockLen = j - i
|
|
369
|
-
if (blockLen > FOLD_LINES) {
|
|
370
|
-
const foldKey = `fold-${foldCounter++}`
|
|
371
|
-
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
372
|
-
// Show first 2 lines + fold hint
|
|
373
|
-
folded.push(convLines[i])
|
|
374
|
-
if (blockLen > 2) folded.push(convLines[i + 1])
|
|
375
|
-
folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
|
|
376
|
-
i = j
|
|
377
|
-
continue
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
folded.push(line)
|
|
382
|
-
i++
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
_convCache = { key, cols, lines: folded }
|
|
386
|
-
return folded
|
|
387
|
-
}
|
|
388
|
-
|
|
389
315
|
function inputBoxStyle(state) {
|
|
390
316
|
let borderColor = C.tool
|
|
391
317
|
let title
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-loop.mjs — frame scheduler + row-diff rendering
|
|
3
|
+
*
|
|
4
|
+
* Single render path: every frame recomputes the whole screen via renderRows
|
|
5
|
+
* (pure function of state), diffs against the previously written rows, and
|
|
6
|
+
* repaints only changed rows with absolute cursor positioning.
|
|
7
|
+
*
|
|
8
|
+
* This replaces the old dual-path design (full redraw vs per-panel incremental
|
|
9
|
+
* caches) which required fragile manual cache invalidation — the source of
|
|
10
|
+
* repeated "panel renders blank / frozen" bugs. Row content IS the cache key:
|
|
11
|
+
* if a row should look different, it is rewritten; otherwise untouched.
|
|
12
|
+
*
|
|
13
|
+
* Conversation wrapping stays memoized inside render-conversation (convCacheKey),
|
|
14
|
+
* so recomputing the frame each paint is cheap.
|
|
15
|
+
*/
|
|
16
|
+
import { countConvLines, renderRows } from "./render-frame.mjs"
|
|
17
|
+
import { estimateTokens } from "../context.mjs"
|
|
18
|
+
import { ansi, C } from "./ansi.mjs"
|
|
19
|
+
|
|
20
|
+
const MIN_RENDER_INTERVAL_MS = 16
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the render loop closure. Returns { render, scheduleRender }.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} state — TUI state
|
|
26
|
+
* @param {object} agent — agent instance
|
|
27
|
+
* @param {object} ctx — mutable context: { startupDims, SLASH_COMMANDS, showUpdateNotice }
|
|
28
|
+
* @param {Function} pushLine — for error logging
|
|
29
|
+
* @param {Function} [write] — frame output (default process.stdout.write); injectable for tests
|
|
30
|
+
*/
|
|
31
|
+
export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => process.stdout.write(s)) {
|
|
32
|
+
const { startupDims, SLASH_COMMANDS } = ctx
|
|
33
|
+
let prevRows = []
|
|
34
|
+
let lastCols = 0, lastRows = 0
|
|
35
|
+
let renderRequested = false, renderTimer = null, lastRenderAt = 0
|
|
36
|
+
|
|
37
|
+
function scheduleRender() {
|
|
38
|
+
if (renderTimer) return
|
|
39
|
+
const elapsed = performance.now() - lastRenderAt
|
|
40
|
+
const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
|
|
41
|
+
renderTimer = setTimeout(() => {
|
|
42
|
+
renderTimer = null
|
|
43
|
+
if (!renderRequested) return
|
|
44
|
+
renderRequested = false
|
|
45
|
+
lastRenderAt = performance.now()
|
|
46
|
+
doRender()
|
|
47
|
+
if (renderRequested) scheduleRender()
|
|
48
|
+
}, delay)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function render() {
|
|
52
|
+
if (renderRequested) return
|
|
53
|
+
renderRequested = true
|
|
54
|
+
process.nextTick(() => scheduleRender())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function doRender() {
|
|
58
|
+
try {
|
|
59
|
+
const dims = { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
|
|
60
|
+
|
|
61
|
+
// Expired output panels: prune once their close grace elapsed (done + closeAt in the past)
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
for (const [name, p] of Object.entries(state.outputPanels)) {
|
|
64
|
+
if (p.done && (p.closeAt ?? 0) <= now) delete state.outputPanels[name]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { rows, cursorRow, cursorCol, layout } = renderRows(state, agent,
|
|
68
|
+
{ cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
|
|
69
|
+
|
|
70
|
+
// Clamp scroll (bookkeeping for the status-bar hint; renderConversation clamps internally too)
|
|
71
|
+
state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols) - layout.panels.conversation.h))
|
|
72
|
+
|
|
73
|
+
if (state.ctxCache.len !== agent.history.length) {
|
|
74
|
+
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deferred upgrade notice — pop when no overlay is active
|
|
78
|
+
if (ctx.pendingNoticeReady(state)) {
|
|
79
|
+
const notice = state.pendingNotice
|
|
80
|
+
state.pendingNotice = null
|
|
81
|
+
ctx.showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Full repaint on terminal resize (row model no longer matches the physical screen)
|
|
85
|
+
const fullRepaint = dims.cols !== lastCols || dims.rows !== lastRows || rows.length !== prevRows.length
|
|
86
|
+
lastCols = dims.cols; lastRows = dims.rows
|
|
87
|
+
|
|
88
|
+
const out = []
|
|
89
|
+
if (fullRepaint) {
|
|
90
|
+
// No trailing newline after the last row — it sits on the terminal's bottom
|
|
91
|
+
// row and a trailing \r\n would scroll the whole screen up by one line.
|
|
92
|
+
out.push(ansi.home, rows.join("\r\n"), ansi.clearToEnd)
|
|
93
|
+
} else {
|
|
94
|
+
for (let i = 0; i < rows.length; i++) {
|
|
95
|
+
if (rows[i] !== prevRows[i]) out.push(`\x1b[${i + 1};1H${rows[i]}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
prevRows = rows
|
|
99
|
+
|
|
100
|
+
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
101
|
+
const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
|
|
102
|
+
|
|
103
|
+
if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// Don't let a render error crash the TUI
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { render, scheduleRender }
|
|
110
|
+
}
|