thincoder 0.8.12 → 0.9.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/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"
@@ -75,6 +80,8 @@ export async function startTUI(agent, opts = {}) {
75
80
  status: "Ready",
76
81
  queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
77
82
  interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
83
+ expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
84
+ foldEnabled: true, // global fold toggle — /fold on|off
78
85
  }
79
86
 
80
87
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
@@ -191,7 +198,7 @@ export async function startTUI(agent, opts = {}) {
191
198
  // Can't close? fine, process is exiting anyway
192
199
  }
193
200
  process.stdin.setRawMode(false)
194
- process.stdout.write(ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
201
+ process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
195
202
  }
196
203
  process.on("exit", cleanup)
197
204
 
@@ -222,66 +229,171 @@ export async function startTUI(agent, opts = {}) {
222
229
 
223
230
  // ---------------------------------------------------------- Render
224
231
 
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
232
+ // Panel cache for incremental rendering: panelName { y, h, content }
233
+ const panelCache = new Map()
234
+ let lastCols = 0, lastRows = 0
235
+ let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
236
+ const convLineCache = [] // line-level cache for conversation panel (per-line diff)
237
+ let renderRequested = false, renderTimer = null, lastRenderAt = 0
238
+ const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
230
239
 
231
240
  function scheduleRender() {
232
241
  if (renderTimer) return
242
+ const elapsed = performance.now() - lastRenderAt
243
+ const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
233
244
  renderTimer = setTimeout(() => {
234
245
  renderTimer = null
235
- render()
236
- }, 40)
246
+ if (!renderRequested) return
247
+ renderRequested = false
248
+ lastRenderAt = performance.now()
249
+ doRender()
250
+ if (renderRequested) scheduleRender() // more requests arrived during render
251
+ }, delay)
237
252
  }
238
253
 
254
+ /** Rate-limited render entry point. All call sites use this. */
239
255
  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
256
+ if (renderRequested) return
257
+ renderRequested = true
258
+ // process.nextTick merges multiple synchronous render() calls
259
+ // within the same tick into a single scheduleRender call.
260
+ process.nextTick(() => scheduleRender())
261
+ }
262
+
263
+ /** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
264
+ function buildPanel(name, panelLayout, lines, cacheKey) {
265
+ if (!panelLayout) {
266
+ if (panelCache.has(name)) panelCache.delete(name)
267
+ return null
255
268
  }
256
- // update ctxCache
257
- if (state.ctxCache.len !== agent.history.length) {
258
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
269
+ const content = lines.join("\r\n")
270
+ const cached = panelCache.get(name)
271
+ const effectiveKey = cacheKey ?? content
272
+ if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
273
+ const rows = []
274
+ for (let i = 0; i < panelLayout.h; i++) {
275
+ rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
259
276
  }
277
+ panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
278
+ return rows.join("")
279
+ }
260
280
 
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
281
+ /** Detect if panel layout structure changed (appeared/disappeared/shifted).
282
+ * Only checks panels that are ALREADY cached — new panels (not yet written)
283
+ * are not a structural change; the incremental path will write them naturally. */
284
+ function layoutStructureChanged(layout) {
285
+ for (const [name, cached] of panelCache) {
286
+ const p = layout.panels[name] ?? null
287
+ if (p == null) return true // cached panel disappeared layout changed
288
+ if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
289
+ }
290
+ return false
291
+ }
292
+
293
+ function doRender() {
294
+ try {
295
+ const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
296
+ const layout = computeLayout(state, dims)
297
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
298
+
299
+ // Side effects: clamp scroll + overlay + update ctxCache
300
+ const convLines = countConvLines(state, dims.cols)
301
+ state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
302
+ if (overlay && panels.picker) {
303
+ const winH = panels.picker.h - 1
304
+ if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
305
+ if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
275
306
  }
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}`)
307
+ if (state.ctxCache.len !== agent.history.length) {
308
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
283
309
  }
284
- }
310
+
311
+ // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
312
+ // Don't clear panelCache — update positions so the next incremental check
313
+ // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
314
+ // per panel on the next frame (much cheaper than another full redraw).
315
+ if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
316
+ lastCols = dims.cols; lastRows = dims.rows
317
+ // Update cached panel positions (content stays stale → next frame rewrites)
318
+ for (const [name, panelLayout] of Object.entries(panels)) {
319
+ if (!panelLayout) { panelCache.delete(name); continue }
320
+ const cached = panelCache.get(name)
321
+ if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
322
+ }
323
+ const isStreaming = state.processing && !state.permission && !state.question && !state.picker
324
+ const isWizard = state.wizard?.step === "provider"
325
+ // Content + cursor in a single write. Hardware cursor stays hidden —
326
+ // the visual cursor is drawn in the input box as SGR reverse video.
327
+ // Position for IME, hide for visual (matching pi-tui).
328
+ if (isStreaming) {
329
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
330
+ } else if (isWizard) {
331
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
332
+ } else {
333
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
334
+ }
335
+ return
336
+ }
337
+
338
+ // ---- Incremental rendering (layout stable) ----
339
+ // pi-tui pattern: content inside sync-update block; cursor outside.
340
+ // DECSET 2026 buffers all panel writes and renders them atomically.
341
+ // Cursor hide/show/position MUST be outside — otherwise the terminal's
342
+ // internal cursor state machine and the sync render buffer can disagree.
343
+ const out = []
344
+ const push = (s) => { if (s != null) out.push(s) }
345
+
346
+ // Always-visible panels
347
+ push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
348
+ push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
349
+ push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
350
+
351
+ // Conversation: line-level cache — only push changed lines
352
+ const convKey = convCacheKey(state)
353
+ const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
354
+ if (convChanged) {
355
+ lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
356
+ const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
357
+ const y = panels.conversation.y + 1
358
+ for (let i = 0; i < lines.length; i++) {
359
+ if (lines[i] !== convLineCache[i]) {
360
+ out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
361
+ convLineCache[i] = lines[i]
362
+ }
363
+ }
364
+ if (convLineCache.length > lines.length) {
365
+ for (let i = lines.length; i < convLineCache.length; i++) {
366
+ out.push(`\x1b[${y + i};1H\x1b[K`)
367
+ }
368
+ }
369
+ convLineCache.length = lines.length
370
+ }
371
+
372
+ // Conditional panels
373
+ push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
374
+ push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
375
+ push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
376
+ push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
377
+ if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
378
+ else panelCache.delete("queue")
379
+ if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
380
+ else panelCache.delete("picker")
381
+
382
+ // Determine cursor suffix — appended to the same write() as the sync block.
383
+ // MUST position the cursor at the input box even when hidden: the terminal's
384
+ // cursor position determines where the IME candidate window appears.
385
+ // pi-tui's positionHardwareCursor does the same — positions first, then
386
+ // decides show/hide based on showHardwareCursor.
387
+ // Hardware cursor stays hidden — the visual cursor is drawn in the input
388
+ // box text as SGR reverse video (matching pi-tui's approach).
389
+ // We still position the hardware cursor for IME candidate window placement.
390
+ const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
391
+ const cc = 3 + inputLayout.cursorCol
392
+ const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
393
+ const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
394
+
395
+ // Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
396
+ if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
285
397
  } catch (e) {
286
398
  // Don't let a render error crash the TUI
287
399
  }
@@ -313,6 +425,7 @@ export async function startTUI(agent, opts = {}) {
313
425
  const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
314
426
  if (safeDuringProcessing.has(resolved0)) {
315
427
  await handleSlash(text)
428
+ render()
316
429
  } else {
317
430
  state.queue.push({ text })
318
431
  render()
@@ -320,14 +433,15 @@ export async function startTUI(agent, opts = {}) {
320
433
  return
321
434
  }
322
435
  await handleSlash(text)
436
+ render()
323
437
  return
324
438
  }
325
439
 
326
- // While processing: queue for later, don't execute immediately
440
+ // While processing: queue for later, don't execute immediately.
441
+ // The queue panel (renderQueue) already shows pending items — don't also
442
+ // push to the conversation area, or the text scrolls up with streaming tokens.
327
443
  if (state.processing) {
328
444
  state.queue.push({ text })
329
- pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
330
- pushLine(text, C.dim)
331
445
  render()
332
446
  return
333
447
  }
@@ -345,7 +459,7 @@ export async function startTUI(agent, opts = {}) {
345
459
 
346
460
  // Agent loop: implemented in agent-turn.mjs
347
461
  const turnCtx = {
348
- agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
462
+ agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
349
463
  askPermission, askQuestion, handleSlash: null, summarize,
350
464
  get assistantLabeled() { return assistantLabeled },
351
465
  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