thincoder 0.11.0 → 0.11.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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/advisor.mjs +360 -72
  3. package/src/agent/helpers.mjs +7 -3
  4. package/src/agent/setup.mjs +2 -2
  5. package/src/agent-tools/advisor.mjs +36 -0
  6. package/src/agent-tools/plan.mjs +53 -2
  7. package/src/agent-tools/subagent.mjs +7 -1
  8. package/src/agent-tools/timer.mjs +1 -1
  9. package/src/agent-tools/verify.mjs +1 -0
  10. package/src/agent-tools.mjs +1 -0
  11. package/src/agent.mjs +73 -21
  12. package/src/auto-think.mjs +23 -5
  13. package/src/config.mjs +1 -1
  14. package/src/prompts/advisor-round1.md +23 -0
  15. package/src/prompts/advisor-round2.md +26 -0
  16. package/src/prompts/advisor-round3.md +24 -0
  17. package/src/prompts/coder.md +1 -0
  18. package/src/prompts/discipline.md +15 -1
  19. package/src/prompts/explore.md +2 -0
  20. package/src/prompts/plan.md +2 -0
  21. package/src/prompts/system.md +5 -1
  22. package/src/provider/anthropic.mjs +4 -4
  23. package/src/provider/core.mjs +6 -126
  24. package/src/provider/google.mjs +4 -2
  25. package/src/provider/sse.mjs +112 -0
  26. package/src/tools/bash.md +8 -0
  27. package/src/tools/codemode.mjs +5 -16
  28. package/src/tools/edit.md +8 -0
  29. package/src/tools/git.mjs +9 -6
  30. package/src/tools/read.md +7 -0
  31. package/src/tools/shared.mjs +16 -0
  32. package/src/tools/system.mjs +14 -10
  33. package/src/tools/web.mjs +21 -16
  34. package/src/tui/agent-turn.mjs +76 -64
  35. package/src/tui/cmd-advisor.mjs +119 -18
  36. package/src/tui/index.mjs +7 -187
  37. package/src/tui/key-handler.mjs +8 -2
  38. package/src/tui/layout.mjs +1 -1
  39. package/src/tui/render-conversation.mjs +92 -0
  40. package/src/tui/render-frame.mjs +27 -103
  41. package/src/tui/render-loop.mjs +181 -0
@@ -7,13 +7,14 @@
7
7
  * The legacy renderFrame() wrapper is kept for compatibility.
8
8
  */
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
- import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
11
- import {
12
- sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay,
13
- } from "./render.mjs"
10
+ import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
+ import { sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay } from "./render.mjs"
14
12
  import { specForModel } from "../config.mjs"
13
+ import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
15
14
  import { basename } from "node:path"
16
15
 
16
+ export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
17
+
17
18
  // ---------- status bar slash-command hints ----------
18
19
  const SLASH_HINTS = {
19
20
  "/config": "open config menu",
@@ -44,30 +45,6 @@ export function renderHeader(agent, cols) {
44
45
  return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
45
46
  }
46
47
 
47
- /**
48
- * Compute a cheap cache key for the conversation panel.
49
- * Structural hints that change ONLY when the conversation actually changes.
50
- * Used by the incremental renderer to skip rebuilding convLines when nothing changed.
51
- */
52
- export function convCacheKey(state) {
53
- const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
54
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
55
- }
56
-
57
- /** Conversation panel (scrollable, variable height). Returns exactly `visibleH` lines. */
58
- export function renderConversation(state, cols, visibleH, scroll) {
59
- const convLines = buildConvLines(state, cols)
60
- const maxScroll = Math.max(0, convLines.length - visibleH)
61
- const clamped = Math.min(scroll, maxScroll)
62
- const end = convLines.length - clamped
63
- const visible = convLines.slice(Math.max(0, end - visibleH), end)
64
- const pad = visibleH - visible.length
65
- const out = []
66
- for (let i = 0; i < pad; i++) out.push("")
67
- for (const l of visible) out.push(`${l.color}${l.text}${ansi.reset}`)
68
- return out
69
- }
70
-
71
48
  /** Todo/task panel. Returns empty array when no tasks visible. */
72
49
  export function renderTodo(visibleTasks, cols) {
73
50
  return visibleTasks.map((t) => {
@@ -204,17 +181,36 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
204
181
 
205
182
  for (let li = 0; li < boxLines.length; li++) {
206
183
  const l = boxLines[li]
207
- let content = sliceByWidth(l, W - 4)
208
- const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
184
+ const original = sliceByWidth(l, W - 4)
185
+ let content = original
186
+ const contentWidth = stringWidth(original)
187
+ let fillLen = W - 4 - contentWidth
209
188
 
210
189
  if (li === curLine && curCol >= 0) {
211
- const beforeWidth = Math.min(curCol, stringWidth(content))
190
+ const beforeWidth = Math.min(curCol, contentWidth)
212
191
  const before = sliceByWidth(content, beforeWidth)
213
192
  const atIdx = before.length // character index (not display width — CJK chars diverge)
214
193
  const at = content[atIdx] ?? " "
215
194
  const after = content.slice(atIdx + 1)
216
195
  content = before + `${ansi.reset}\x1b[7m${at}\x1b[27m${ansi.reset}` + after
196
+ // Cursor at end of input: at=[\x20] adds one display column — compensate fill.
197
+ // When fill is already 0 (content fills the line), don't add the extra space at all
198
+ // — instead, move the cursor to the last real character.
199
+ if (atIdx >= original.length) {
200
+ if (fillLen > 0) {
201
+ fillLen = Math.max(0, fillLen - 1)
202
+ } else {
203
+ // No room for extra space: place cursor on last character instead
204
+ const lastIdx = original.length - 1
205
+ if (lastIdx >= 0) {
206
+ const before2 = sliceByWidth(original, stringWidth(original.slice(0, lastIdx)))
207
+ const at2 = original[lastIdx] ?? " "
208
+ content = before2 + `${ansi.reset}\x1b[7m${at2}\x1b[27m${ansi.reset}`
209
+ }
210
+ }
211
+ }
217
212
  }
213
+ const fill = " ".repeat(Math.max(0, fillLen))
218
214
 
219
215
  out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}`)
220
216
  }
@@ -314,78 +310,6 @@ export function renderFrame(state, agent, opts) {
314
310
  // Internal helpers (unchanged from original)
315
311
  // ====================================================================
316
312
 
317
- export function countConvLines(state, cols) {
318
- return buildConvLines(state, cols).length
319
- }
320
-
321
- let _convCache = { key: "", cols: 0, lines: [] }
322
- function buildConvLines(state, cols) {
323
- const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
324
- const key = convCacheKey(state)
325
- if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
326
-
327
- const convLines = []
328
- for (const l of state.lines) {
329
- for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
330
- for (const wrapped of wrapText(line, cols - 1)) {
331
- convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
332
- }
333
- }
334
- }
335
- // Messages after the conversation (streaming / thinking / tool output):
336
- // appended after history lines so they appear at the bottom.
337
- if (state.reasoning) {
338
- for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
339
- convLines.push({ text: wrapped, color: C.reason })
340
- }
341
- }
342
- if (state.streaming) {
343
- for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
344
- for (const wrapped of wrapText(line, cols - 1)) {
345
- convLines.push({ text: wrapped, color: C.text })
346
- }
347
- }
348
- }
349
- const allStreams = Object.values(state.toolStreams).join("")
350
- if (allStreams) {
351
- const tail = sanitizeDisplay(allStreams.slice(-4000))
352
- for (const wrapped of wrapText(tail, cols - 1)) {
353
- convLines.push({ text: wrapped, color: C.dim })
354
- }
355
- }
356
-
357
- // ---- Fold long blocks (> 8 consecutive dim lines) ----
358
- const FOLD_LINES = 8
359
- let foldCounter = 0
360
- const folded = []
361
- let i = 0
362
- while (i < convLines.length) {
363
- const line = convLines[i]
364
- // Only fold dim-colored lines (tool results, subagent previews)
365
- if (line.color === C.dim) {
366
- let j = i
367
- while (j < convLines.length && convLines[j].color === C.dim) j++
368
- const blockLen = j - i
369
- if (blockLen > FOLD_LINES) {
370
- const foldKey = `fold-${foldCounter++}`
371
- if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
372
- // Show first 2 lines + fold hint
373
- folded.push(convLines[i])
374
- if (blockLen > 2) folded.push(convLines[i + 1])
375
- folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
376
- i = j
377
- continue
378
- }
379
- }
380
- }
381
- folded.push(line)
382
- i++
383
- }
384
-
385
- _convCache = { key, cols, lines: folded }
386
- return folded
387
- }
388
-
389
313
  function inputBoxStyle(state) {
390
314
  let borderColor = C.tool
391
315
  let title
@@ -0,0 +1,181 @@
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.
4
+ */
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"
12
+ import { estimateTokens } from "../context.mjs"
13
+ import { ansi, C } from "./ansi.mjs"
14
+
15
+ const MIN_RENDER_INTERVAL_MS = 16
16
+
17
+ /**
18
+ * Create the render loop closure. Returns { render, scheduleRender }.
19
+ *
20
+ * @param {object} state — TUI state
21
+ * @param {object} agent — agent instance
22
+ * @param {object} ctx — mutable context: { startupDims, SLASH_COMMANDS, showUpdateNotice }
23
+ * @param {Function} pushLine — for error logging
24
+ */
25
+ export function createRenderLoop(state, agent, ctx, pushLine) {
26
+ const { startupDims, SLASH_COMMANDS } = ctx
27
+ const panelCache = new Map()
28
+ let lastCols = 0, lastRows = 0
29
+ let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
30
+ const convLineCache = []
31
+ let renderRequested = false, renderTimer = null, lastRenderAt = 0
32
+
33
+ function scheduleRender() {
34
+ if (renderTimer) return
35
+ const elapsed = performance.now() - lastRenderAt
36
+ const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
37
+ renderTimer = setTimeout(() => {
38
+ renderTimer = null
39
+ if (!renderRequested) return
40
+ renderRequested = false
41
+ lastRenderAt = performance.now()
42
+ doRender()
43
+ if (renderRequested) scheduleRender()
44
+ }, delay)
45
+ }
46
+
47
+ function render() {
48
+ if (renderRequested) return
49
+ renderRequested = true
50
+ process.nextTick(() => scheduleRender())
51
+ }
52
+
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
+ function doRender() {
80
+ 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
85
+
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
92
+ }
93
+ if (state.ctxCache.len !== agent.history.length) {
94
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
95
+ }
96
+
97
+ // Deferred upgrade notice — pop when no overlay is active
98
+ if (ctx.pendingNoticeReady(state)) {
99
+ const notice = state.pendingNotice
100
+ state.pendingNotice = null
101
+ ctx.showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
102
+ }
103
+
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
+ }
124
+
125
+ // ---- Incremental rendering (layout stable) ----
126
+ 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
+ }
144
+ }
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
+ }
168
+
169
+ const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
170
+ const cc = 3 + inputLayout.cursorCol
171
+ const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
172
+ const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
173
+
174
+ if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
175
+ } catch (e) {
176
+ // Don't let a render error crash the TUI
177
+ }
178
+ }
179
+
180
+ return { render, scheduleRender }
181
+ }