thincoder 0.8.12 → 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/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 { renderFrame, countConvLines } from "./render-frame.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"
26
31
  import { computeLayout } from "./layout.mjs"
27
32
  import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
28
33
  import { createWizard } from "./wizard.mjs"
@@ -191,7 +196,7 @@ export async function startTUI(agent, opts = {}) {
191
196
  // Can't close? fine, process is exiting anyway
192
197
  }
193
198
  process.stdin.setRawMode(false)
194
- 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)
195
200
  }
196
201
  process.on("exit", cleanup)
197
202
 
@@ -222,66 +227,171 @@ export async function startTUI(agent, opts = {}) {
222
227
 
223
228
  // ---------------------------------------------------------- Render
224
229
 
225
- // Frame dedup + streaming rate limit: skip re-rendering unchanged frames (prevents flicker);
226
- // merge token flood to ~25fps
227
- let lastFrame = ""
228
- let lastCursorRow = -1, lastCursorCol = -1
229
- let renderTimer = null
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
230
237
 
231
238
  function scheduleRender() {
232
239
  if (renderTimer) return
240
+ const elapsed = performance.now() - lastRenderAt
241
+ const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
233
242
  renderTimer = setTimeout(() => {
234
243
  renderTimer = null
235
- render()
236
- }, 40)
244
+ if (!renderRequested) return
245
+ renderRequested = false
246
+ lastRenderAt = performance.now()
247
+ doRender()
248
+ if (renderRequested) scheduleRender() // more requests arrived during render
249
+ }, delay)
237
250
  }
238
251
 
252
+ /** Rate-limited render entry point. All call sites use this. */
239
253
  function render() {
240
- try {
241
- // Side effects: reset scroll + update ctxCache + clamp overlay scroll
242
- // (renderFrame is pure, side effects concentrated here)
243
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
244
- const layout = computeLayout(state, dims)
245
- // clamp conversation scroll
246
- const convLines = countConvLines(state, dims.cols)
247
- const maxScroll = Math.max(0, convLines - layout.panels.conversation.h)
248
- state.scroll = Math.min(state.scroll, maxScroll)
249
- // clamp overlay scroll
250
- const overlay = state.picker ?? state.wizard
251
- if (overlay && layout.panels.picker) {
252
- const winH = layout.panels.picker.h - 1
253
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
254
- 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
255
266
  }
256
- // update ctxCache
257
- if (state.ctxCache.len !== agent.history.length) {
258
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
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`)
259
274
  }
275
+ panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
276
+ return rows.join("")
277
+ }
260
278
 
261
- const { frame, cursorRow, cursorCol } = renderFrame(state, agent, {
262
- ...dims,
263
- slashCommands: SLASH_COMMANDS,
264
- })
265
- if (frame !== lastFrame) {
266
- lastFrame = frame
267
- // Single write: home + hide cursor + frame + clear-tail + cursor position — prevents flicker
268
- let out = ansi.home + ansi.hideCursor + frame + ansi.clearToEnd
269
- if (state.permission || state.question || state.picker || state.wizard?.step === "provider") {
270
- out += ansi.hideCursor
271
- } else {
272
- out += `\x1b[${cursorRow};${cursorCol}H${ansi.showCursor}`
273
- lastCursorRow = cursorRow
274
- lastCursorCol = cursorCol
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
275
304
  }
276
- process.stdout.write(out)
277
- } else if (cursorRow !== lastCursorRow || cursorCol !== lastCursorCol) {
278
- // Frame unchanged but cursor moved (e.g. arrow keys) — only reposition cursor
279
- lastCursorRow = cursorRow
280
- lastCursorCol = cursorCol
281
- if (!(state.permission || state.question || state.picker || state.wizard?.step === "provider")) {
282
- 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) }
283
307
  }
284
- }
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)
285
395
  } catch (e) {
286
396
  // Don't let a render error crash the TUI
287
397
  }
@@ -313,6 +423,7 @@ export async function startTUI(agent, opts = {}) {
313
423
  const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
314
424
  if (safeDuringProcessing.has(resolved0)) {
315
425
  await handleSlash(text)
426
+ render()
316
427
  } else {
317
428
  state.queue.push({ text })
318
429
  render()
@@ -320,14 +431,15 @@ export async function startTUI(agent, opts = {}) {
320
431
  return
321
432
  }
322
433
  await handleSlash(text)
434
+ render()
323
435
  return
324
436
  }
325
437
 
326
- // 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.
327
441
  if (state.processing) {
328
442
  state.queue.push({ text })
329
- pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
330
- pushLine(text, C.dim)
331
443
  render()
332
444
  return
333
445
  }
@@ -345,7 +457,7 @@ export async function startTUI(agent, opts = {}) {
345
457
 
346
458
  // Agent loop: implemented in agent-turn.mjs
347
459
  const turnCtx = {
348
- agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
460
+ agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
349
461
  askPermission, askQuestion, handleSlash: null, summarize,
350
462
  get assistantLabeled() { return assistantLabeled },
351
463
  set assistantLabeled(v) { assistantLabeled = v },
@@ -114,8 +114,8 @@ export function createKeyHandler(ctx) {
114
114
  setTimeout(() => process.exit(0), 100)
115
115
  }
116
116
 
117
- // Ctrl+I: interrupt current generation and inject a message (time-travel inject)
118
- if (key.ctrl && !key.alt && key.name === "i") {
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
119
  if (state.processing && state.controller && !state.interruptPrompt) {
120
120
  state.interruptPrompt = { text: "" }
121
121
  render()
@@ -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
- * Panel layout (top to bottom):
7
- * header → conversation → subagent → output → todo → permission → queue → input → status
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"
@@ -100,8 +100,8 @@ export function computeLayout(state, { cols, rows }) {
100
100
  const conversation = { y, h: convH }; y += convH
101
101
  const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
102
102
  const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
103
- const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
104
103
  const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
104
+ const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
105
105
  const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
106
106
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
107
107
  const inputBox = { y, h: inputBoxH }; y += inputBoxH