thincoder 0.11.1 → 0.12.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.
@@ -1,14 +1,19 @@
1
1
  /**
2
- * render-loop.mjs — frame scheduler + incremental panel rendering
3
- * Extracted from src/tui/index.mjs to keep the TUI entry point under 500 lines.
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.
4
15
  */
5
- import { computeLayout } from "./layout.mjs"
6
- import {
7
- renderFrame, countConvLines, convCacheKey,
8
- renderHeader, renderConversation, renderTodo, renderSubagent,
9
- renderOutput, renderPermission, renderQueue, renderPicker,
10
- renderInputBox, renderStatus,
11
- } from "./render-frame.mjs"
16
+ import { countConvLines, renderRows } from "./render-frame.mjs"
12
17
  import { estimateTokens } from "../context.mjs"
13
18
  import { ansi, C } from "./ansi.mjs"
14
19
 
@@ -16,18 +21,17 @@ const MIN_RENDER_INTERVAL_MS = 16
16
21
 
17
22
  /**
18
23
  * Create the render loop closure. Returns { render, scheduleRender }.
19
- *
24
+ *
20
25
  * @param {object} state — TUI state
21
26
  * @param {object} agent — agent instance
22
27
  * @param {object} ctx — mutable context: { startupDims, SLASH_COMMANDS, showUpdateNotice }
23
28
  * @param {Function} pushLine — for error logging
29
+ * @param {Function} [write] — frame output (default process.stdout.write); injectable for tests
24
30
  */
25
- export function createRenderLoop(state, agent, ctx, pushLine) {
31
+ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => process.stdout.write(s)) {
26
32
  const { startupDims, SLASH_COMMANDS } = ctx
27
- const panelCache = new Map()
33
+ let prevRows = []
28
34
  let lastCols = 0, lastRows = 0
29
- let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
30
- const convLineCache = []
31
35
  let renderRequested = false, renderTimer = null, lastRenderAt = 0
32
36
 
33
37
  function scheduleRender() {
@@ -50,46 +54,22 @@ export function createRenderLoop(state, agent, ctx, pushLine) {
50
54
  process.nextTick(() => scheduleRender())
51
55
  }
52
56
 
53
- function buildPanel(name, panelLayout, lines, cacheKey) {
54
- if (!panelLayout) {
55
- if (panelCache.has(name)) panelCache.delete(name)
56
- return null
57
- }
58
- const content = lines.join("\r\n")
59
- const cached = panelCache.get(name)
60
- const effectiveKey = cacheKey ?? content
61
- if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
62
- const rows = []
63
- for (let i = 0; i < panelLayout.h; i++) {
64
- rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
65
- }
66
- panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
67
- return rows.join("")
68
- }
69
-
70
- function layoutStructureChanged(layout) {
71
- for (const [name, cached] of panelCache) {
72
- const p = layout.panels[name] ?? null
73
- if (p == null) return true
74
- if (p.y !== cached.y || p.h !== cached.h) return true
75
- }
76
- return false
77
- }
78
-
79
57
  function doRender() {
80
58
  try {
81
- const startupCols = startupDims.cols, startupRows = startupDims.rows
82
- const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
83
- const layout = computeLayout(state, dims)
84
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
59
+ const dims = { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
85
60
 
86
- const convLines = countConvLines(state, dims.cols)
87
- state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
88
- if (overlay && panels.picker) {
89
- const winH = panels.picker.h - 1
90
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
91
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
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]
92
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
+
93
73
  if (state.ctxCache.len !== agent.history.length) {
94
74
  state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
95
75
  }
@@ -101,77 +81,26 @@ export function createRenderLoop(state, agent, ctx, pushLine) {
101
81
  ctx.showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
102
82
  }
103
83
 
104
- // Terminal resize or panel layout shift full redraw
105
- if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
106
- lastCols = dims.cols; lastRows = dims.rows
107
- for (const [name, panelLayout] of Object.entries(panels)) {
108
- if (!panelLayout) { panelCache.delete(name); continue }
109
- const cached = panelCache.get(name)
110
- if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
111
- }
112
- const isStreaming = state.processing && !state.permission && !state.question && !state.picker
113
- const isWizard = state.wizard?.step === "provider"
114
- const { frame, cursorRow, cursorCol } = renderFrame(state, agent, { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
115
- if (isStreaming) {
116
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
117
- } else if (isWizard) {
118
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
119
- } else {
120
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
121
- }
122
- return
123
- }
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
124
87
 
125
- // ---- Incremental rendering (layout stable) ----
126
88
  const out = []
127
- const push = (s) => { if (s != null) out.push(s) }
128
-
129
- push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
130
- push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
131
- push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
132
-
133
- const convKey = convCacheKey(state)
134
- const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
135
- if (convChanged) {
136
- lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
137
- const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
138
- const y = panels.conversation.y + 1
139
- for (let i = 0; i < lines.length; i++) {
140
- if (lines[i] !== convLineCache[i]) {
141
- out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
142
- convLineCache[i] = lines[i]
143
- }
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]}`)
144
96
  }
145
- if (convLineCache.length > lines.length) {
146
- for (let i = lines.length; i < convLineCache.length; i++) {
147
- out.push(`\x1b[${y + i};1H\x1b[K`)
148
- }
149
- }
150
- convLineCache.length = lines.length
151
- }
152
-
153
- push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
154
- push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
155
- push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0),
156
- // Key by append counter, not text length: panel text is capped at 4000 chars (agent-turn),
157
- // so length stops changing once the cap is hit and the panel would freeze mid-stream.
158
- Object.values(state.outputPanels).filter(p => !p.done).map(p => p.seq ?? 0).join(",")))
159
- push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
160
- if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
161
- else panelCache.delete("queue")
162
- if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
163
- else panelCache.delete("picker")
164
-
165
- for (const p of Object.values(state.outputPanels)) {
166
- if (p._pendingDone) { p.done = true; delete p._pendingDone }
167
97
  }
98
+ prevRows = rows
168
99
 
169
- const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
170
- const cc = 3 + inputLayout.cursorCol
171
100
  const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
172
- const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
101
+ const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
173
102
 
174
- if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
103
+ if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
175
104
  } catch (e) {
176
105
  // Don't let a render error crash the TUI
177
106
  }