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/README.md +27 -0
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +20 -2
- package/src/agent/setup.mjs +0 -3
- package/src/agent-tools/goal.mjs +38 -1
- package/src/agent.mjs +6 -7
- package/src/auto-think.mjs +1 -1
- package/src/config.mjs +2 -0
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/hooks.mjs +93 -0
- package/src/mcp/transport-http.mjs +3 -2
- package/src/memory/docs.mjs +1 -2
- package/src/provider/core.mjs +8 -1
- package/src/session.mjs +8 -2
- package/src/tools/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- package/src/tools/file.mjs +37 -9
- package/src/tools/patch.mjs +7 -3
- package/src/tools/repomap.mjs +7 -3
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +8 -0
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +2 -2
- package/src/tui/cmd-fold.mjs +21 -0
- package/src/tui/cmd-mcp.mjs +42 -1
- package/src/tui/cmd-undo.mjs +91 -0
- package/src/tui/index.mjs +167 -53
- package/src/tui/key-handler.mjs +2 -2
- package/src/tui/layout.mjs +3 -3
- package/src/tui/render-frame.mjs +266 -171
- package/src/tui/slash-commands.mjs +6 -0
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 {
|
|
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
|
-
//
|
|
226
|
-
|
|
227
|
-
let
|
|
228
|
-
let
|
|
229
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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 },
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -114,8 +114,8 @@ export function createKeyHandler(ctx) {
|
|
|
114
114
|
setTimeout(() => process.exit(0), 100)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Ctrl+I: interrupt
|
|
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()
|
package/src/tui/layout.mjs
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* header → conversation → subagent → output →
|
|
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
|