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
package/src/tui/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Large logic blocks extracted to independent modules:
7
7
  * agent-turn.mjs — agent loop + callback construction
8
- * key-handler.mjs keyboard event dispatch
8
+ * render-loop.mjs frame scheduler + incremental panel rendering
9
9
  * startup.mjs — startup screen + session restore + background indexing
10
10
  * interaction.mjs — permission approval + Q&A
11
11
  * pickers.mjs — generic list picker + model picker
@@ -20,15 +20,8 @@ import { emitKeypressEvents } from "node:readline"
20
20
  import { PassThrough } from "node:stream"
21
21
  import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
22
22
  import { closeAllMcp } from "../mcp.mjs"
23
- import { estimateTokens } from "../context.mjs"
24
23
  import { ansi, C } from "./ansi.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"
31
- import { computeLayout } from "./layout.mjs"
24
+ import { createRenderLoop } from "./render-loop.mjs"
32
25
  import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
33
26
  import { createWizard } from "./wizard.mjs"
34
27
  import { createPickers } from "./pickers.mjs"
@@ -87,7 +80,7 @@ export async function startTUI(agent, opts = {}) {
87
80
  completion: null, // Tab completion state { candidates, index }
88
81
  toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
89
82
  subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
90
- outputPanels: {}, // generic tool output panel: { toolName: { text, done } } — streamed live during execution, collapsed to summary on completion
83
+ outputPanels: {}, // tool output panels: { toolName: { parts: [{kind, text}], len, done, closeAt } } — streamed live during execution, kept visible for a grace period after completion
91
84
  currentTool: null, // currently executing tool name (shown in status bar)
92
85
  processingStarted: 0, // current turn start time (status bar timer)
93
86
  status: "Ready",
@@ -247,184 +240,11 @@ export async function startTUI(agent, opts = {}) {
247
240
 
248
241
  // ---------------------------------------------------------- Render
249
242
 
250
- // Panel cache for incremental rendering: panelName → { y, h, content }
251
- const panelCache = new Map()
252
- let lastCols = 0, lastRows = 0
253
- let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
254
- const convLineCache = [] // line-level cache for conversation panel (per-line diff)
255
- let renderRequested = false, renderTimer = null, lastRenderAt = 0
256
- const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
257
-
258
- function scheduleRender() {
259
- if (renderTimer) return
260
- const elapsed = performance.now() - lastRenderAt
261
- const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
262
- renderTimer = setTimeout(() => {
263
- renderTimer = null
264
- if (!renderRequested) return
265
- renderRequested = false
266
- lastRenderAt = performance.now()
267
- doRender()
268
- if (renderRequested) scheduleRender() // more requests arrived during render
269
- }, delay)
270
- }
271
-
272
- /** Rate-limited render entry point. All call sites use this. */
273
- function render() {
274
- if (renderRequested) return
275
- renderRequested = true
276
- // process.nextTick merges multiple synchronous render() calls
277
- // within the same tick into a single scheduleRender call.
278
- process.nextTick(() => scheduleRender())
279
- }
280
-
281
- /** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
282
- function buildPanel(name, panelLayout, lines, cacheKey) {
283
- if (!panelLayout) {
284
- if (panelCache.has(name)) panelCache.delete(name)
285
- return null
286
- }
287
- const content = lines.join("\r\n")
288
- const cached = panelCache.get(name)
289
- const effectiveKey = cacheKey ?? content
290
- if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
291
- const rows = []
292
- for (let i = 0; i < panelLayout.h; i++) {
293
- rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
294
- }
295
- panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
296
- return rows.join("")
297
- }
298
-
299
- /** Detect if panel layout structure changed (appeared/disappeared/shifted).
300
- * Only checks panels that are ALREADY cached — new panels (not yet written)
301
- * are not a structural change; the incremental path will write them naturally. */
302
- function layoutStructureChanged(layout) {
303
- for (const [name, cached] of panelCache) {
304
- const p = layout.panels[name] ?? null
305
- if (p == null) return true // cached panel disappeared → layout changed
306
- if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
307
- }
308
- return false
309
- }
310
-
311
- function doRender() {
312
- try {
313
- const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
314
- const layout = computeLayout(state, dims)
315
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
316
-
317
- // Side effects: clamp scroll + overlay + update ctxCache
318
- const convLines = countConvLines(state, dims.cols)
319
- state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
320
- if (overlay && panels.picker) {
321
- const winH = panels.picker.h - 1
322
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
323
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
324
- }
325
- if (state.ctxCache.len !== agent.history.length) {
326
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
327
- }
328
-
329
- // 后台更新提示:等 picker/权限确认/提问弹层全部关闭后再弹,不硬抢
330
- // (key-handler 分支顺序 permission → question → picker,任一激活时弹了也摸不到)
331
- if (pendingNoticeReady(state)) {
332
- const notice = state.pendingNotice
333
- state.pendingNotice = null
334
- showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
335
- }
336
-
337
- // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
338
- // Don't clear panelCache — update positions so the next incremental check
339
- // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
340
- // per panel on the next frame (much cheaper than another full redraw).
341
- if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
342
- lastCols = dims.cols; lastRows = dims.rows
343
- // Update cached panel positions (content stays stale → next frame rewrites)
344
- for (const [name, panelLayout] of Object.entries(panels)) {
345
- if (!panelLayout) { panelCache.delete(name); continue }
346
- const cached = panelCache.get(name)
347
- if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
348
- }
349
- const isStreaming = state.processing && !state.permission && !state.question && !state.picker
350
- const isWizard = state.wizard?.step === "provider"
351
- // Content + cursor in a single write. Hardware cursor stays hidden —
352
- // the visual cursor is drawn in the input box as SGR reverse video.
353
- // Position for IME, hide for visual (matching pi-tui).
354
- const { frame, cursorRow, cursorCol } = renderFrame(state, agent, { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
355
- if (isStreaming) {
356
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
357
- } else if (isWizard) {
358
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
359
- } else {
360
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
361
- }
362
- return
363
- }
364
-
365
- // ---- Incremental rendering (layout stable) ----
366
- // pi-tui pattern: content inside sync-update block; cursor outside.
367
- // DECSET 2026 buffers all panel writes and renders them atomically.
368
- // Cursor hide/show/position MUST be outside — otherwise the terminal's
369
- // internal cursor state machine and the sync render buffer can disagree.
370
- const out = []
371
- const push = (s) => { if (s != null) out.push(s) }
372
-
373
- // Always-visible panels
374
- push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
375
- push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
376
- push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
377
-
378
- // Conversation: line-level cache — only push changed lines
379
- const convKey = convCacheKey(state)
380
- const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
381
- if (convChanged) {
382
- lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
383
- const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
384
- const y = panels.conversation.y + 1
385
- for (let i = 0; i < lines.length; i++) {
386
- if (lines[i] !== convLineCache[i]) {
387
- out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
388
- convLineCache[i] = lines[i]
389
- }
390
- }
391
- if (convLineCache.length > lines.length) {
392
- for (let i = lines.length; i < convLineCache.length; i++) {
393
- out.push(`\x1b[${y + i};1H\x1b[K`)
394
- }
395
- }
396
- convLineCache.length = lines.length
397
- }
398
-
399
- // Conditional panels
400
- push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
401
- push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
402
- push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
403
- push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
404
- if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
405
- else panelCache.delete("queue")
406
- if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
407
- else panelCache.delete("picker")
408
-
409
- // Determine cursor suffix — appended to the same write() as the sync block.
410
- // MUST position the cursor at the input box even when hidden: the terminal's
411
- // cursor position determines where the IME candidate window appears.
412
- // pi-tui's positionHardwareCursor does the same — positions first, then
413
- // decides show/hide based on showHardwareCursor.
414
- // Hardware cursor stays hidden — the visual cursor is drawn in the input
415
- // box text as SGR reverse video (matching pi-tui's approach).
416
- // We still position the hardware cursor for IME candidate window placement.
417
- const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
418
- const cc = 3 + inputLayout.cursorCol
419
- const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
420
- const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
421
-
422
- // Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
423
- if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
424
- } catch (e) {
425
- // Don't let a render error crash the TUI
426
- }
427
- }
243
+ const renderLoop = createRenderLoop(state, agent,
244
+ { startupDims: { cols: startupCols, rows: startupRows }, SLASH_COMMANDS,
245
+ pendingNoticeReady, get showUpdateNotice() { return showUpdateNotice } },
246
+ pushLine)
247
+ const { render, scheduleRender } = renderLoop
428
248
 
429
249
  process.stdout.on("resize", () => {
430
250
  try { render() } catch { /* resize error — ignore */ }
@@ -596,5 +416,6 @@ export async function startTUI(agent, opts = {}) {
596
416
 
597
417
  function summarize(obj) {
598
418
  const s = JSON.stringify(obj)
419
+ if (s === "{}") return "" // no-arg tools (advisor/verify/…) — don't render empty braces
599
420
  return s.length > 80 ? s.slice(0, 80) + "…" : s
600
421
  }
@@ -37,7 +37,8 @@ export function createInteraction(ctx) {
37
37
  function askPermission(name, args) {
38
38
  // auto mode: fully authorized, no more prompts
39
39
  if (agent.autoApprove) {
40
- pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
40
+ const argSummary = summarize(args)
41
+ pushLine(` [auto] ${name}${argSummary ? ` ${argSummary}` : ""}`, C.warn)
41
42
  return Promise.resolve(true)
42
43
  }
43
44
  // store preview content in permissionPreview, rendered above input box next to "Allow?" prompt
@@ -24,8 +24,6 @@ export function createKeyHandler(ctx) {
24
24
  state.status = "Processing..."
25
25
  if (answer === "a" && !isContinue) {
26
26
  agent.autoApprove = true
27
- agent._pendingReminders = agent._pendingReminders ?? []
28
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
29
27
  pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
30
28
  }
31
29
  const approved = answer === "y" || (answer === "a" && !isContinue)
@@ -140,8 +138,14 @@ export function createKeyHandler(ctx) {
140
138
  const msg = (state.interruptPrompt.text ?? "").trim()
141
139
  state.interruptPrompt = null
142
140
  if (msg) {
143
- pushLine(` [inject] ${msg}`, C.warn)
144
- state.controller.abort({ interrupt: true, message: msg })
141
+ // Guard: if the turn already finished while the user was typing, the controller
142
+ // may have been replaced or already aborted — don't abort a live turn by mistake.
143
+ if (state.processing && state.controller && !state.controller.signal.aborted) {
144
+ pushLine(` [inject] ${msg}`, C.warn)
145
+ state.controller.abort({ interrupt: true, message: msg })
146
+ } else {
147
+ pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
148
+ }
145
149
  render()
146
150
  }
147
151
  } else if (key.name === "backspace") {
@@ -69,8 +69,9 @@ export function computeLayout(state, { cols, rows }) {
69
69
  ? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
70
70
  : 0
71
71
 
72
- // Tool output panels: max 8 lines per panel, capped at reasonable total
73
- const panels = Object.values(state.outputPanels).filter((p) => !p.done)
72
+ // Tool output panels: max 8 lines per panel, capped at reasonable total.
73
+ // Done panels stay visible until their closeAt grace elapses (render loop prunes them).
74
+ const panels = Object.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
74
75
  const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
75
76
 
76
77
  // Permission preview (height depends on wrapped content)
@@ -0,0 +1,92 @@
1
+ /**
2
+ * render-conversation.mjs — conversation panel line builder
3
+ * Extracted from render-frame.mjs.
4
+ */
5
+ import { ansi, C } from "./ansi.mjs"
6
+ import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
7
+
8
+ let _convCache = { key: "", cols: 0, lines: [] }
9
+
10
+ export function convCacheKey(state) {
11
+ const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
12
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
13
+ }
14
+
15
+ function buildConvLines(state, cols) {
16
+ const key = convCacheKey(state)
17
+ if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
18
+
19
+ const convLines = []
20
+ for (const l of state.lines) {
21
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
22
+ for (const wrapped of wrapText(line, cols - 1)) {
23
+ convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
24
+ }
25
+ }
26
+ }
27
+ if (state.reasoning) {
28
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
29
+ convLines.push({ text: wrapped, color: C.reason })
30
+ }
31
+ }
32
+ if (state.streaming) {
33
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
34
+ for (const wrapped of wrapText(line, cols - 1)) {
35
+ convLines.push({ text: wrapped, color: C.text })
36
+ }
37
+ }
38
+ }
39
+ const allStreams = Object.values(state.toolStreams).join("")
40
+ if (allStreams) {
41
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
42
+ for (const wrapped of wrapText(tail, cols - 1)) {
43
+ convLines.push({ text: wrapped, color: C.dim })
44
+ }
45
+ }
46
+
47
+ // Fold long blocks (> 8 consecutive dim lines)
48
+ const FOLD_LINES = 8
49
+ let foldCounter = 0
50
+ const folded = []
51
+ let i = 0
52
+ while (i < convLines.length) {
53
+ const line = convLines[i]
54
+ if (line.color === C.dim) {
55
+ let j = i
56
+ while (j < convLines.length && convLines[j].color === C.dim) j++
57
+ const blockLen = j - i
58
+ if (blockLen > FOLD_LINES) {
59
+ const foldKey = `fold-${foldCounter++}`
60
+ if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
61
+ folded.push(convLines[i])
62
+ if (blockLen > 2) folded.push(convLines[i + 1])
63
+ folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
64
+ i = j
65
+ continue
66
+ }
67
+ }
68
+ }
69
+ folded.push(line)
70
+ i++
71
+ }
72
+
73
+ _convCache = { key, cols, lines: folded }
74
+ return folded
75
+ }
76
+
77
+ export function countConvLines(state, cols) {
78
+ return buildConvLines(state, cols).length
79
+ }
80
+
81
+ export function renderConversation(state, cols, visibleH, scroll) {
82
+ const convLines = buildConvLines(state, cols)
83
+ const maxScroll = Math.max(0, convLines.length - visibleH)
84
+ const clamped = Math.min(scroll, maxScroll)
85
+ const end = convLines.length - clamped
86
+ const visible = convLines.slice(Math.max(0, end - visibleH), end)
87
+ const pad = visibleH - visible.length
88
+ const out = []
89
+ for (let p = 0; p < pad; p++) out.push("")
90
+ for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
91
+ return out
92
+ }