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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +535 -72
  4. package/src/agent/helpers.mjs +18 -5
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +80 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +20 -0
  15. package/src/config.mjs +1 -1
  16. package/src/mcp/transport-stdio.mjs +4 -3
  17. package/src/mcp.mjs +1 -1
  18. package/src/prompts/advisor-round1.md +23 -0
  19. package/src/prompts/advisor-round2.md +26 -0
  20. package/src/prompts/advisor-round3.md +24 -0
  21. package/src/prompts/coder.md +1 -0
  22. package/src/prompts/discipline.md +15 -1
  23. package/src/prompts/explore.md +2 -0
  24. package/src/prompts/plan.md +2 -0
  25. package/src/prompts/system.md +5 -1
  26. package/src/provider/anthropic.mjs +4 -4
  27. package/src/provider/core.mjs +6 -126
  28. package/src/provider/google.mjs +4 -2
  29. package/src/provider/sse.mjs +112 -0
  30. package/src/skills.mjs +67 -31
  31. package/src/tools/bash.md +8 -0
  32. package/src/tools/codemode.mjs +5 -16
  33. package/src/tools/edit.md +8 -0
  34. package/src/tools/git.mjs +9 -6
  35. package/src/tools/read.md +7 -0
  36. package/src/tools/shared.mjs +43 -2
  37. package/src/tools/system.mjs +14 -10
  38. package/src/tools/web.mjs +21 -16
  39. package/src/tui/agent-turn.mjs +130 -73
  40. package/src/tui/cmd-advisor.mjs +237 -41
  41. package/src/tui/cmd-auto.mjs +6 -8
  42. package/src/tui/cmd-mcp.mjs +4 -2
  43. package/src/tui/cmd-plan.mjs +6 -8
  44. package/src/tui/cmd-think.mjs +93 -70
  45. package/src/tui/index.mjs +9 -188
  46. package/src/tui/interaction.mjs +2 -1
  47. package/src/tui/key-handler.mjs +8 -4
  48. package/src/tui/layout.mjs +3 -2
  49. package/src/tui/render-conversation.mjs +92 -0
  50. package/src/tui/render-frame.mjs +94 -168
  51. package/src/tui/render-loop.mjs +110 -0
@@ -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 { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
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
- /** Tool output panels (streaming output like tail -f). Returns empty when no active output. */
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
- const active = Object.values(state.outputPanels).filter((p) => !p.done)
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
- for (const p of active) {
116
- const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
117
- const tail = textLines.slice(-linesPerPanel)
118
- for (const line of tail) {
119
- out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}`)
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 = active.reduce((s, p) => {
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
- let content = sliceByWidth(l, W - 4)
208
- const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
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, stringWidth(content))
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
- // Legacy: full-frame renderer (wraps individual panel functions)
256
+ // Frame composition
238
257
  // ====================================================================
239
258
 
240
259
  /**
241
- * Render one frame, returns { frame, cursorRow, cursorCol }.
242
- * Pure function: does not modify state/agent.
243
- * @deprecated Prefer individual panel functions for incremental rendering.
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 renderFrame(state, agent, opts) {
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 out = [ansi.home]
255
- let cursorRow = 0, cursorCol = 0
256
-
257
- // header
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
- // todo
273
- for (const l of renderTodo(visibleTasks, cols)) out.push(`${l}\x1b[K`)
274
-
275
- // subagent
276
- if (panels.subagent) {
277
- for (const l of renderSubagent(allSubs, W)) out.push(`${l}\x1b[K`)
278
- }
279
-
280
- // output panels
281
- if (panels.output) {
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
- // queue preview
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 { frame, cursorRow, cursorCol }
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
+ }