thincoder 0.11.1 → 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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +206 -31
- package/src/agent/helpers.mjs +11 -2
- package/src/agent.mjs +14 -7
- package/src/cli/make-agent.mjs +20 -0
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +5 -5
- package/src/prompts/advisor-round2.md +4 -4
- package/src/prompts/advisor-round3.md +3 -3
- package/src/prompts/discipline.md +1 -1
- package/src/skills.mjs +67 -31
- package/src/tools/shared.mjs +27 -2
- package/src/tui/agent-turn.mjs +57 -12
- package/src/tui/cmd-advisor.mjs +190 -95
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +2 -1
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +0 -2
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-frame.mjs +67 -65
- package/src/tui/render-loop.mjs +43 -114
package/src/tui/render-loop.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* render-loop.mjs — frame scheduler +
|
|
3
|
-
*
|
|
2
|
+
* render-loop.mjs — frame scheduler + row-diff rendering
|
|
3
|
+
*
|
|
4
|
+
* Single render path: every frame recomputes the whole screen via renderRows
|
|
5
|
+
* (pure function of state), diffs against the previously written rows, and
|
|
6
|
+
* repaints only changed rows with absolute cursor positioning.
|
|
7
|
+
*
|
|
8
|
+
* This replaces the old dual-path design (full redraw vs per-panel incremental
|
|
9
|
+
* caches) which required fragile manual cache invalidation — the source of
|
|
10
|
+
* repeated "panel renders blank / frozen" bugs. Row content IS the cache key:
|
|
11
|
+
* if a row should look different, it is rewritten; otherwise untouched.
|
|
12
|
+
*
|
|
13
|
+
* Conversation wrapping stays memoized inside render-conversation (convCacheKey),
|
|
14
|
+
* so recomputing the frame each paint is cheap.
|
|
4
15
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
renderFrame, countConvLines, convCacheKey,
|
|
8
|
-
renderHeader, renderConversation, renderTodo, renderSubagent,
|
|
9
|
-
renderOutput, renderPermission, renderQueue, renderPicker,
|
|
10
|
-
renderInputBox, renderStatus,
|
|
11
|
-
} from "./render-frame.mjs"
|
|
16
|
+
import { countConvLines, renderRows } from "./render-frame.mjs"
|
|
12
17
|
import { estimateTokens } from "../context.mjs"
|
|
13
18
|
import { ansi, C } from "./ansi.mjs"
|
|
14
19
|
|
|
@@ -16,18 +21,17 @@ const MIN_RENDER_INTERVAL_MS = 16
|
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* Create the render loop closure. Returns { render, scheduleRender }.
|
|
19
|
-
*
|
|
24
|
+
*
|
|
20
25
|
* @param {object} state — TUI state
|
|
21
26
|
* @param {object} agent — agent instance
|
|
22
27
|
* @param {object} ctx — mutable context: { startupDims, SLASH_COMMANDS, showUpdateNotice }
|
|
23
28
|
* @param {Function} pushLine — for error logging
|
|
29
|
+
* @param {Function} [write] — frame output (default process.stdout.write); injectable for tests
|
|
24
30
|
*/
|
|
25
|
-
export function createRenderLoop(state, agent, ctx, pushLine) {
|
|
31
|
+
export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => process.stdout.write(s)) {
|
|
26
32
|
const { startupDims, SLASH_COMMANDS } = ctx
|
|
27
|
-
|
|
33
|
+
let prevRows = []
|
|
28
34
|
let lastCols = 0, lastRows = 0
|
|
29
|
-
let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
|
|
30
|
-
const convLineCache = []
|
|
31
35
|
let renderRequested = false, renderTimer = null, lastRenderAt = 0
|
|
32
36
|
|
|
33
37
|
function scheduleRender() {
|
|
@@ -50,46 +54,22 @@ export function createRenderLoop(state, agent, ctx, pushLine) {
|
|
|
50
54
|
process.nextTick(() => scheduleRender())
|
|
51
55
|
}
|
|
52
56
|
|
|
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
57
|
function doRender() {
|
|
80
58
|
try {
|
|
81
|
-
const
|
|
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
|
|
59
|
+
const dims = { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
|
|
85
60
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
91
|
-
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
61
|
+
// Expired output panels: prune once their close grace elapsed (done + closeAt in the past)
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
for (const [name, p] of Object.entries(state.outputPanels)) {
|
|
64
|
+
if (p.done && (p.closeAt ?? 0) <= now) delete state.outputPanels[name]
|
|
92
65
|
}
|
|
66
|
+
|
|
67
|
+
const { rows, cursorRow, cursorCol, layout } = renderRows(state, agent,
|
|
68
|
+
{ cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
|
|
69
|
+
|
|
70
|
+
// Clamp scroll (bookkeeping for the status-bar hint; renderConversation clamps internally too)
|
|
71
|
+
state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols) - layout.panels.conversation.h))
|
|
72
|
+
|
|
93
73
|
if (state.ctxCache.len !== agent.history.length) {
|
|
94
74
|
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
95
75
|
}
|
|
@@ -101,77 +81,26 @@ export function createRenderLoop(state, agent, ctx, pushLine) {
|
|
|
101
81
|
ctx.showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
102
82
|
}
|
|
103
83
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
}
|
|
84
|
+
// Full repaint on terminal resize (row model no longer matches the physical screen)
|
|
85
|
+
const fullRepaint = dims.cols !== lastCols || dims.rows !== lastRows || rows.length !== prevRows.length
|
|
86
|
+
lastCols = dims.cols; lastRows = dims.rows
|
|
124
87
|
|
|
125
|
-
// ---- Incremental rendering (layout stable) ----
|
|
126
88
|
const out = []
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
}
|
|
89
|
+
if (fullRepaint) {
|
|
90
|
+
// No trailing newline after the last row — it sits on the terminal's bottom
|
|
91
|
+
// row and a trailing \r\n would scroll the whole screen up by one line.
|
|
92
|
+
out.push(ansi.home, rows.join("\r\n"), ansi.clearToEnd)
|
|
93
|
+
} else {
|
|
94
|
+
for (let i = 0; i < rows.length; i++) {
|
|
95
|
+
if (rows[i] !== prevRows[i]) out.push(`\x1b[${i + 1};1H${rows[i]}`)
|
|
144
96
|
}
|
|
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
97
|
}
|
|
98
|
+
prevRows = rows
|
|
168
99
|
|
|
169
|
-
const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
170
|
-
const cc = 3 + inputLayout.cursorCol
|
|
171
100
|
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
172
|
-
const cursorSuffix = hasOverlay ? "" : `\x1b[${
|
|
101
|
+
const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
|
|
173
102
|
|
|
174
|
-
if (out.length || cursorSuffix)
|
|
103
|
+
if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
175
104
|
} catch (e) {
|
|
176
105
|
// Don't let a render error crash the TUI
|
|
177
106
|
}
|