thincoder 0.10.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +47 -20
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +6 -2
- package/src/prompts/discipline.md +23 -6
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +13 -6
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +42 -130
- package/src/provider/google.mjs +199 -0
- package/src/provider/sse.mjs +112 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +125 -156
- package/src/tools/index.mjs +9 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +115 -89
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +86 -75
- package/src/tui/cmd-advisor.mjs +138 -49
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +61 -215
- package/src/tui/key-handler.mjs +56 -20
- package/src/tui/layout.mjs +13 -3
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +56 -114
- package/src/tui/render-loop.mjs +181 -0
- package/src/tui/slash-commands.mjs +26 -16
|
@@ -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
|
+
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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 {
|
|
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",
|
|
@@ -35,7 +36,7 @@ const SLASH_HINTS = {
|
|
|
35
36
|
export function renderHeader(agent, cols) {
|
|
36
37
|
const model = agent.provider.model
|
|
37
38
|
const spec = specForModel(model)
|
|
38
|
-
const thinkOnValue = spec.
|
|
39
|
+
const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
|
|
39
40
|
const t = agent.provider.thinking
|
|
40
41
|
const effort = agent.provider.reasoningEffort
|
|
41
42
|
const thinkBadge = t?.type === "disabled" ? "│ think: off"
|
|
@@ -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) => {
|
|
@@ -146,12 +123,29 @@ export function renderPicker(state, cols, panel, overlay) {
|
|
|
146
123
|
if (!panel || !overlay) return []
|
|
147
124
|
const out = []
|
|
148
125
|
const winH = panel.h - 1
|
|
149
|
-
const
|
|
126
|
+
const total = overlay.lines.length
|
|
127
|
+
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, total - winH)))
|
|
150
128
|
const shown = overlay.lines.slice(start, start + winH)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
129
|
+
// 标题行:左侧标题 + filter(截断防撑破帧),右侧位置指示(按键提示在状态栏,不重复)
|
|
130
|
+
const p = state.picker
|
|
131
|
+
const right = p && p.filteredItems?.length ? `${p.index + 1}/${p.filteredItems.length} ` : ""
|
|
132
|
+
const rawLeft = p ? ` ❯ ${p.title}${p.filter ? ` filter: ${p.filter}` : ""} ` : " ❯ Setup "
|
|
133
|
+
const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
|
|
134
|
+
const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
|
|
135
|
+
out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
|
|
136
|
+
const hasMoreAbove = start > 0
|
|
137
|
+
const hasMoreBelow = start + winH < total
|
|
138
|
+
for (let i = 0; i < shown.length; i++) {
|
|
139
|
+
const l = shown[i]
|
|
140
|
+
// 可视窗上方/下方有更多内容时,在首行/末行右侧给 dim 提示;单行窗口两个方向都有则合并指示
|
|
141
|
+
const moreAbove = i === 0 && hasMoreAbove
|
|
142
|
+
const moreBelow = i === shown.length - 1 && hasMoreBelow
|
|
143
|
+
const ind = moreAbove && moreBelow ? "↑↓ more" : moreAbove ? "↑ more" : moreBelow ? "↓ more" : ""
|
|
144
|
+
const maxW = cols - 1 - (ind ? stringWidth(ind) + 1 : 0)
|
|
145
|
+
// 超宽行截断并加省略号
|
|
146
|
+
const text = stringWidth(l.text) > maxW ? sliceByWidth(l.text, Math.max(0, maxW - 1)) + "…" : l.text
|
|
147
|
+
const pad = ind ? " ".repeat(Math.max(1, cols - 1 - stringWidth(text) - stringWidth(ind))) : ""
|
|
148
|
+
out.push(`${l.color}${text}${ansi.reset}${ind ? `${ansi.dim}${pad}${ind}${ansi.reset}` : ""}`)
|
|
155
149
|
}
|
|
156
150
|
for (let i = shown.length; i < winH; i++) out.push("")
|
|
157
151
|
return out
|
|
@@ -187,17 +181,36 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
187
181
|
|
|
188
182
|
for (let li = 0; li < boxLines.length; li++) {
|
|
189
183
|
const l = boxLines[li]
|
|
190
|
-
|
|
191
|
-
|
|
184
|
+
const original = sliceByWidth(l, W - 4)
|
|
185
|
+
let content = original
|
|
186
|
+
const contentWidth = stringWidth(original)
|
|
187
|
+
let fillLen = W - 4 - contentWidth
|
|
192
188
|
|
|
193
189
|
if (li === curLine && curCol >= 0) {
|
|
194
|
-
const beforeWidth = Math.min(curCol,
|
|
190
|
+
const beforeWidth = Math.min(curCol, contentWidth)
|
|
195
191
|
const before = sliceByWidth(content, beforeWidth)
|
|
196
|
-
const atIdx =
|
|
192
|
+
const atIdx = before.length // character index (not display width — CJK chars diverge)
|
|
197
193
|
const at = content[atIdx] ?? " "
|
|
198
194
|
const after = content.slice(atIdx + 1)
|
|
199
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
|
+
}
|
|
200
212
|
}
|
|
213
|
+
const fill = " ".repeat(Math.max(0, fillLen))
|
|
201
214
|
|
|
202
215
|
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}`)
|
|
203
216
|
}
|
|
@@ -297,78 +310,6 @@ export function renderFrame(state, agent, opts) {
|
|
|
297
310
|
// Internal helpers (unchanged from original)
|
|
298
311
|
// ====================================================================
|
|
299
312
|
|
|
300
|
-
export function countConvLines(state, cols) {
|
|
301
|
-
return buildConvLines(state, cols).length
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
let _convCache = { key: "", cols: 0, lines: [] }
|
|
305
|
-
function buildConvLines(state, cols) {
|
|
306
|
-
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
307
|
-
const key = convCacheKey(state)
|
|
308
|
-
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
309
|
-
|
|
310
|
-
const convLines = []
|
|
311
|
-
for (const l of state.lines) {
|
|
312
|
-
for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
|
|
313
|
-
for (const wrapped of wrapText(line, cols - 1)) {
|
|
314
|
-
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// Messages after the conversation (streaming / thinking / tool output):
|
|
319
|
-
// appended after history lines so they appear at the bottom.
|
|
320
|
-
if (state.reasoning) {
|
|
321
|
-
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
322
|
-
convLines.push({ text: wrapped, color: C.reason })
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
if (state.streaming) {
|
|
326
|
-
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
327
|
-
for (const wrapped of wrapText(line, cols - 1)) {
|
|
328
|
-
convLines.push({ text: wrapped, color: C.text })
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
const allStreams = Object.values(state.toolStreams).join("")
|
|
333
|
-
if (allStreams) {
|
|
334
|
-
const tail = sanitizeDisplay(allStreams.slice(-4000))
|
|
335
|
-
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
336
|
-
convLines.push({ text: wrapped, color: C.dim })
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ---- Fold long blocks (> 8 consecutive dim lines) ----
|
|
341
|
-
const FOLD_LINES = 8
|
|
342
|
-
let foldCounter = 0
|
|
343
|
-
const folded = []
|
|
344
|
-
let i = 0
|
|
345
|
-
while (i < convLines.length) {
|
|
346
|
-
const line = convLines[i]
|
|
347
|
-
// Only fold dim-colored lines (tool results, subagent previews)
|
|
348
|
-
if (line.color === C.dim) {
|
|
349
|
-
let j = i
|
|
350
|
-
while (j < convLines.length && convLines[j].color === C.dim) j++
|
|
351
|
-
const blockLen = j - i
|
|
352
|
-
if (blockLen > FOLD_LINES) {
|
|
353
|
-
const foldKey = `fold-${foldCounter++}`
|
|
354
|
-
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
355
|
-
// Show first 2 lines + fold hint
|
|
356
|
-
folded.push(convLines[i])
|
|
357
|
-
if (blockLen > 2) folded.push(convLines[i + 1])
|
|
358
|
-
folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
|
|
359
|
-
i = j
|
|
360
|
-
continue
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
folded.push(line)
|
|
365
|
-
i++
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
_convCache = { key, cols, lines: folded }
|
|
369
|
-
return folded
|
|
370
|
-
}
|
|
371
|
-
|
|
372
313
|
function inputBoxStyle(state) {
|
|
373
314
|
let borderColor = C.tool
|
|
374
315
|
let title
|
|
@@ -406,7 +347,7 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
406
347
|
? " y: continue │ n: stop"
|
|
407
348
|
: " y: approve │ n: deny │ a: approve all (AUTO)"
|
|
408
349
|
}
|
|
409
|
-
if (state.picker) return "
|
|
350
|
+
if (state.picker) return " type: filter │ ↑↓/PgUp/PgDn: select │ Enter: confirm │ Esc: cancel"
|
|
410
351
|
if (state.wizard) {
|
|
411
352
|
return state.wizard.step === "provider"
|
|
412
353
|
? " ↑↓: select │ Enter: confirm │ Esc: skip"
|
|
@@ -434,10 +375,11 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
434
375
|
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
435
376
|
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
436
377
|
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
437
|
-
const
|
|
438
|
-
const ctxPct = Math.round((state.ctxCache.tokens /
|
|
378
|
+
const modelContext = specForModel(agent.provider.model).context
|
|
379
|
+
const ctxPct = Math.round((state.ctxCache.tokens / modelContext) * 100)
|
|
380
|
+
const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
|
|
439
381
|
const ctxHint = ctxPct > 0
|
|
440
|
-
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}
|
|
382
|
+
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
|
|
441
383
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
442
384
|
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
443
385
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ctx object is injected by index.mjs and forwarded to each handler:
|
|
6
6
|
* { agent, state, distillOpts, pushLine, pushLabel, render,
|
|
7
|
-
*
|
|
7
|
+
* showPicker, closePicker, openModelPicker, setProviderKey, runDistill,
|
|
8
8
|
* persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { C } from "./ansi.mjs"
|
|
12
|
+
import { specForModel } from "../config.mjs"
|
|
12
13
|
import { handleClearCommand } from "./cmd-clear.mjs"
|
|
13
14
|
import { handleNewCommand } from "./cmd-new.mjs"
|
|
14
15
|
import { handleExitCommand } from "./cmd-exit.mjs"
|
|
@@ -55,8 +56,11 @@ export const SLASH_COMMANDS = [
|
|
|
55
56
|
{ name: "/help", group: "System", desc: "this list" },
|
|
56
57
|
]
|
|
57
58
|
|
|
58
|
-
/**
|
|
59
|
-
const
|
|
59
|
+
/** High-frequency command aliases (single source of truth — also used by index.mjs and cmd-help.mjs) */
|
|
60
|
+
export const SLASH_ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
|
|
61
|
+
|
|
62
|
+
/** Command → handler mapping table (exported for tests) */
|
|
63
|
+
export const HANDLERS = {
|
|
60
64
|
"/clear": handleClearCommand,
|
|
61
65
|
"/new": handleNewCommand,
|
|
62
66
|
"/exit": handleExitCommand,
|
|
@@ -90,27 +94,29 @@ export function createSlashCommands(ctx) {
|
|
|
90
94
|
const handlerCtx = { ...ctx, SLASH_COMMANDS }
|
|
91
95
|
|
|
92
96
|
async function handleSlash(text) {
|
|
93
|
-
const [
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
const resolved =
|
|
97
|
+
const [rawCmd, ...args] = text.split(/\s+/)
|
|
98
|
+
// case-insensitive matching + alias resolution
|
|
99
|
+
const cmd = rawCmd.toLowerCase()
|
|
100
|
+
const resolved = SLASH_ALIASES[cmd] ?? cmd
|
|
97
101
|
const handler = HANDLERS[resolved]
|
|
98
102
|
if (handler) {
|
|
99
|
-
await handler(handlerCtx)
|
|
103
|
+
await handler(handlerCtx, args)
|
|
100
104
|
return
|
|
101
105
|
}
|
|
102
|
-
ctx.pushLine(`Unknown command: ${
|
|
106
|
+
ctx.pushLine(`Unknown command: ${rawCmd} (/help for available commands)`, C.error)
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
|
|
106
110
|
function completions(input) {
|
|
107
111
|
if (!input.startsWith("/")) return []
|
|
108
112
|
const parts = input.split(/\s+/)
|
|
109
|
-
// still typing the first token: complete command names
|
|
113
|
+
// still typing the first token: complete command names (case-insensitive)
|
|
110
114
|
if (parts.length === 1) {
|
|
111
|
-
|
|
115
|
+
const prefix = parts[0].toLowerCase()
|
|
116
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)).map((c) => c.name)
|
|
112
117
|
}
|
|
113
|
-
|
|
118
|
+
// aliases resolve to their target command, so `/m <Tab>` completes /model args
|
|
119
|
+
const cmd = SLASH_ALIASES[parts[0].toLowerCase()] ?? parts[0].toLowerCase()
|
|
114
120
|
const last = parts.at(-1) // when trailing space, list all candidates
|
|
115
121
|
const head = parts.slice(0, -1).join(" ")
|
|
116
122
|
const argIndex = parts.length - 2 // which parameter is being typed (0-based)
|
|
@@ -118,13 +124,17 @@ export function createSlashCommands(ctx) {
|
|
|
118
124
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
119
125
|
if (cmd === "/think") {
|
|
120
126
|
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
121
|
-
if (argIndex === 1 && parts[1] === "effort")
|
|
127
|
+
if (argIndex === 1 && parts[1].toLowerCase() === "effort") {
|
|
128
|
+
// effort enum is model-specific — take it from the current model's spec
|
|
129
|
+
const levels = specForModel(agent.provider?.model).reasoningEffortEnum ?? ["high", "max"]
|
|
130
|
+
return match(levels)
|
|
131
|
+
}
|
|
122
132
|
}
|
|
123
|
-
if (cmd === "/config" && argIndex === 0) return match(["embedkey"
|
|
133
|
+
if (cmd === "/config" && argIndex === 0) return match(["embedkey"])
|
|
124
134
|
if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
|
|
125
135
|
if (cmd === "/mcp") {
|
|
126
|
-
if (argIndex === 0) return match(["add", "
|
|
127
|
-
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
136
|
+
if (argIndex === 0) return match(["add", "http", "ws", "stdio", "ai", "remove", "connect", "list"])
|
|
137
|
+
if (argIndex === 1 && (parts[1]?.toLowerCase() === "remove" || parts[1]?.toLowerCase() === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
128
138
|
}
|
|
129
139
|
return []
|
|
130
140
|
}
|