thincoder 0.8.11 → 0.8.13
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/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -10
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -21
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +11 -4
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +25 -28
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +142 -15
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +9 -3
- package/src/tools/file.mjs +114 -5
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/patch.mjs +7 -3
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +19 -10
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +17 -2
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +167 -54
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +6 -4
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +240 -167
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
package/src/tui/render-frame.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* render-frame.mjs — terminal frame renderer (pure computation, no side effects)
|
|
3
3
|
* Produces an ANSI frame string from state + agent + layout, returns cursor position.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Panel render functions are exported individually for incremental rendering
|
|
6
|
+
* (only changed panels are written to the terminal, eliminating flicker on Windows).
|
|
7
|
+
* The legacy renderFrame() wrapper is kept for compatibility.
|
|
7
8
|
*/
|
|
8
9
|
import { ansi, C, ESC } from "./ansi.mjs"
|
|
9
10
|
import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import { specForModel } from "../config.mjs"
|
|
14
15
|
import { basename } from "node:path"
|
|
15
16
|
|
|
16
|
-
// ---------- status bar slash-command hints
|
|
17
|
+
// ---------- status bar slash-command hints ----------
|
|
17
18
|
const SLASH_HINTS = {
|
|
18
19
|
"/config": "open config menu",
|
|
19
20
|
"/model": "select model & manage providers",
|
|
@@ -24,9 +25,205 @@ const SLASH_HINTS = {
|
|
|
24
25
|
"/restore": "select checkpoint to restore",
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// ====================================================================
|
|
29
|
+
// Panel render functions (exported for incremental rendering)
|
|
30
|
+
// Each returns string[] — one element per screen row, ANSI-colored,
|
|
31
|
+
// WITHOUT \x1b[K (clear-line) or cursor positioning (added by caller).
|
|
32
|
+
// ====================================================================
|
|
33
|
+
|
|
34
|
+
/** Header panel (always 1 line). */
|
|
35
|
+
export function renderHeader(agent, cols) {
|
|
36
|
+
const model = agent.provider.model
|
|
37
|
+
const spec = specForModel(model)
|
|
38
|
+
const thinkOnValue = spec.thinkOnValue ?? "enabled"
|
|
39
|
+
const t = agent.provider.thinking
|
|
40
|
+
const effort = agent.provider.reasoningEffort
|
|
41
|
+
const thinkBadge = t?.type === "disabled" ? "│ think: off"
|
|
42
|
+
: effort ? `│ think: ${effort}`
|
|
43
|
+
: t?.type === thinkOnValue ? "│ think: on" : ""
|
|
44
|
+
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
|
+
|
|
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}`
|
|
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
|
+
/** Todo/task panel. Returns empty array when no tasks visible. */
|
|
72
|
+
export function renderTodo(visibleTasks, cols) {
|
|
73
|
+
return visibleTasks.map((t) => {
|
|
74
|
+
const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
|
|
75
|
+
const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
|
|
76
|
+
return `${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}`
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Subagent panel. Returns empty when no subagents. */
|
|
81
|
+
export function renderSubagent(allSubs, W) {
|
|
82
|
+
const subs = allSubs
|
|
83
|
+
if (subs.length === 0) return []
|
|
84
|
+
const out = []
|
|
85
|
+
for (const s of subs.slice(0, MAX_SUB_LINES)) {
|
|
86
|
+
const icon = s.done ? "✓" : "…"
|
|
87
|
+
const color = s.done ? C.dim : C.tool
|
|
88
|
+
const label = `[${s.role}]`.padEnd(10)
|
|
89
|
+
let content
|
|
90
|
+
if (s.done) {
|
|
91
|
+
const elapsed = Math.floor((Date.now() - s.started) / 1000)
|
|
92
|
+
content = `done ${elapsed}s`
|
|
93
|
+
} else if (s.tool) {
|
|
94
|
+
content = s.tool
|
|
95
|
+
} else if (s.text) {
|
|
96
|
+
const textLines = s.text.split("\n").filter((l) => l.trim())
|
|
97
|
+
content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
|
|
98
|
+
} else {
|
|
99
|
+
content = "thinking..."
|
|
100
|
+
}
|
|
101
|
+
out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, W - 14))}${ansi.reset}`)
|
|
102
|
+
}
|
|
103
|
+
if (subs.length > MAX_SUB_LINES) {
|
|
104
|
+
out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}`)
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Tool output panels (streaming output like tail -f). Returns empty when no active output. */
|
|
110
|
+
export function renderOutput(state, W, panelH) {
|
|
111
|
+
const active = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
112
|
+
if (active.length === 0) return []
|
|
113
|
+
const out = []
|
|
114
|
+
const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
|
|
115
|
+
for (const p of active) {
|
|
116
|
+
const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
|
|
117
|
+
const tail = textLines.slice(-linesPerPanel)
|
|
118
|
+
for (const line of tail) {
|
|
119
|
+
out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Fill remaining rows to match panelH exactly
|
|
123
|
+
const used = active.reduce((s, p) => {
|
|
124
|
+
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
125
|
+
return s + tl.length
|
|
126
|
+
}, 0)
|
|
127
|
+
for (let i = used; i < panelH; i++) out.push("")
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Permission preview panel. Returns empty when no permission request. */
|
|
132
|
+
export function renderPermission(permPreviewLines) {
|
|
133
|
+
if (permPreviewLines.length === 0) return []
|
|
134
|
+
return [`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}`, ...permPreviewLines.map((w) => `${C.warn}${w}${ansi.reset}`)]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Queue preview (1 line when queue has items during processing). */
|
|
138
|
+
export function renderQueue(state, W) {
|
|
139
|
+
if (state.queue.length === 0 || !state.processing) return ""
|
|
140
|
+
const preview = sliceByWidth(state.queue[0].text, W - 20)
|
|
141
|
+
return `${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D delete │ Ctrl+I inject${ansi.reset}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Picker/wizard overlay panel. Returns empty when no overlay. */
|
|
145
|
+
export function renderPicker(state, cols, panel, overlay) {
|
|
146
|
+
if (!panel || !overlay) return []
|
|
147
|
+
const out = []
|
|
148
|
+
const winH = panel.h - 1
|
|
149
|
+
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
|
|
150
|
+
const shown = overlay.lines.slice(start, start + winH)
|
|
151
|
+
const title = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ Setup "
|
|
152
|
+
out.push(`${ansi.bold}${C.tool}${title}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ navigate, Enter confirm, Esc cancel)" : ""}${ansi.reset}`)
|
|
153
|
+
for (const l of shown) {
|
|
154
|
+
out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}`)
|
|
155
|
+
}
|
|
156
|
+
for (let i = shown.length; i < winH; i++) out.push("")
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Input box (border-bounded text entry area). Always visible. */
|
|
161
|
+
/**
|
|
162
|
+
* Render the input box. When inputLayout is provided, renders a visual cursor
|
|
163
|
+
* (SGR reverse video) so the hardware cursor can stay hidden at all times,
|
|
164
|
+
* matching pi-tui's approach.
|
|
165
|
+
*/
|
|
166
|
+
export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset) {
|
|
167
|
+
const { borderColor, title } = inputBoxStyle(state)
|
|
168
|
+
let topBorder
|
|
169
|
+
if (title === " Input " || title === " Question " || title === " Inject Message " || title === " Processing... ") {
|
|
170
|
+
const parts = []
|
|
171
|
+
if (title === " Input " || title === " Processing... ") parts.push(" Ctrl+U clear ")
|
|
172
|
+
if (title === " Question ") parts.push(" Enter submit ")
|
|
173
|
+
if (title === " Inject Message ") parts.push(" Enter send, Esc cancel ")
|
|
174
|
+
parts.push(" Ctrl+V paste ")
|
|
175
|
+
parts.push(" Ctrl+I inject ")
|
|
176
|
+
const hint = parts.join("")
|
|
177
|
+
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
178
|
+
} else {
|
|
179
|
+
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
180
|
+
}
|
|
181
|
+
const out = [`${borderColor}${topBorder}${ansi.reset}`]
|
|
182
|
+
|
|
183
|
+
// Visual cursor position in the input box (hardware cursor stays hidden)
|
|
184
|
+
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
185
|
+
const curLine = (!hasOverlay && inputLayout) ? inputLayout.cursorLine - (inputOffset ?? 0) : -1
|
|
186
|
+
const curCol = (!hasOverlay && inputLayout) ? inputLayout.cursorCol : -1
|
|
187
|
+
|
|
188
|
+
for (let li = 0; li < boxLines.length; li++) {
|
|
189
|
+
const l = boxLines[li]
|
|
190
|
+
let content = sliceByWidth(l, W - 4)
|
|
191
|
+
const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
|
|
192
|
+
|
|
193
|
+
if (li === curLine && curCol >= 0) {
|
|
194
|
+
const beforeWidth = Math.min(curCol, stringWidth(content))
|
|
195
|
+
const before = sliceByWidth(content, beforeWidth)
|
|
196
|
+
const atIdx = beforeWidth
|
|
197
|
+
const at = content[atIdx] ?? " "
|
|
198
|
+
const after = content.slice(atIdx + 1)
|
|
199
|
+
content = before + `${ansi.reset}\x1b[7m${at}\x1b[27m${ansi.reset}` + after
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}`)
|
|
203
|
+
}
|
|
204
|
+
out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}`)
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Status bar (always 1 line). */
|
|
209
|
+
export function renderStatus(state, agent, cols, slashCommands) {
|
|
210
|
+
const statusLine = buildStatusLine(state, agent, { cols, slashCommands })
|
|
211
|
+
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
212
|
+
const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
|
|
213
|
+
const advisorBanner = agent.config?.advisor?.enabled ? `${C.advisor} ADVISOR${ansi.reset}${ansi.dim}│` : ""
|
|
214
|
+
const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.enabled ? " ADVISOR│ " : "")
|
|
215
|
+
const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
|
|
216
|
+
return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ====================================================================
|
|
220
|
+
// Legacy: full-frame renderer (wraps individual panel functions)
|
|
221
|
+
// ====================================================================
|
|
222
|
+
|
|
27
223
|
/**
|
|
28
224
|
* Render one frame, returns { frame, cursorRow, cursorCol }.
|
|
29
225
|
* Pure function: does not modify state/agent.
|
|
226
|
+
* @deprecated Prefer individual panel functions for incremental rendering.
|
|
30
227
|
*/
|
|
31
228
|
export function renderFrame(state, agent, opts) {
|
|
32
229
|
const cols = opts.cols || 80
|
|
@@ -36,150 +233,58 @@ export function renderFrame(state, agent, opts) {
|
|
|
36
233
|
|
|
37
234
|
const layout = computeLayout(state, { cols, rows })
|
|
38
235
|
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
39
|
-
const model = agent.provider.model
|
|
40
|
-
const thinking = agent.provider.thinking
|
|
41
|
-
const effort = agent.provider.reasoningEffort
|
|
42
|
-
const isMultimodal = specForModel(model).multimodal
|
|
43
|
-
const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
|
|
44
|
-
: effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
|
|
45
236
|
|
|
46
237
|
const out = [ansi.home]
|
|
47
238
|
let cursorRow = 0, cursorCol = 0
|
|
48
239
|
|
|
49
|
-
//
|
|
50
|
-
out.push(
|
|
51
|
-
`${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}${ansi.clearLine}`,
|
|
52
|
-
)
|
|
240
|
+
// header
|
|
241
|
+
out.push(`${renderHeader(agent, cols)}\x1b[K`)
|
|
53
242
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const scroll = Math.min(state.scroll, maxScroll)
|
|
58
|
-
const end = convLines.length - scroll
|
|
59
|
-
const visible = convLines.slice(Math.max(0, end - panels.conversation.h), end)
|
|
60
|
-
const pad = panels.conversation.h - visible.length
|
|
61
|
-
for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
|
|
62
|
-
for (const l of visible) {
|
|
63
|
-
out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
|
|
243
|
+
// conversation
|
|
244
|
+
for (const l of renderConversation(state, cols, panels.conversation.h, state.scroll)) {
|
|
245
|
+
out.push(`${l}\x1b[K`)
|
|
64
246
|
}
|
|
65
247
|
|
|
66
|
-
//
|
|
248
|
+
// picker
|
|
67
249
|
if (panels.picker) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const shown = overlay.lines.slice(start, start + winH)
|
|
71
|
-
const overlayTitle = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ Setup "
|
|
72
|
-
out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ navigate, Enter confirm, Esc cancel)" : ""}${ansi.reset}${ansi.clearLine}`)
|
|
73
|
-
for (const l of shown) {
|
|
74
|
-
out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
|
|
250
|
+
for (const l of renderPicker(state, cols, panels.picker, overlay)) {
|
|
251
|
+
out.push(`${l}\x1b[K`)
|
|
75
252
|
}
|
|
76
|
-
for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
|
|
77
253
|
}
|
|
78
254
|
|
|
79
|
-
//
|
|
80
|
-
for (const
|
|
81
|
-
const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
|
|
82
|
-
const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
|
|
83
|
-
out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
|
|
84
|
-
}
|
|
255
|
+
// todo
|
|
256
|
+
for (const l of renderTodo(visibleTasks, cols)) out.push(`${l}\x1b[K`)
|
|
85
257
|
|
|
86
|
-
//
|
|
258
|
+
// subagent
|
|
87
259
|
if (panels.subagent) {
|
|
88
|
-
const
|
|
89
|
-
for (const s of subs.slice(0, MAX_SUB_LINES)) {
|
|
90
|
-
const icon = s.done ? "✓" : "…"
|
|
91
|
-
const color = s.done ? C.dim : C.tool
|
|
92
|
-
const label = `[${s.role}]`.padEnd(10)
|
|
93
|
-
let content
|
|
94
|
-
if (s.done) {
|
|
95
|
-
const elapsed = Math.floor((Date.now() - s.started) / 1000)
|
|
96
|
-
content = `done ${elapsed}s`
|
|
97
|
-
} else if (s.tool) {
|
|
98
|
-
const argSummary = s.toolArgs ? summarizeToolArg(s.tool, s.toolArgs) : ""
|
|
99
|
-
content = `${s.tool}${argSummary ? ` ${argSummary}` : ""}`
|
|
100
|
-
} else if (s.text) {
|
|
101
|
-
const textLines = s.text.split("\n").filter((l) => l.trim())
|
|
102
|
-
content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
|
|
103
|
-
} else {
|
|
104
|
-
content = "thinking..."
|
|
105
|
-
}
|
|
106
|
-
const availWidth = W - 14
|
|
107
|
-
out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, availWidth))}${ansi.reset}${ansi.clearLine}`)
|
|
108
|
-
}
|
|
109
|
-
if (subs.length > MAX_SUB_LINES) {
|
|
110
|
-
out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}${ansi.clearLine}`)
|
|
111
|
-
}
|
|
260
|
+
for (const l of renderSubagent(allSubs, W)) out.push(`${l}\x1b[K`)
|
|
112
261
|
}
|
|
113
262
|
|
|
114
|
-
//
|
|
263
|
+
// output panels
|
|
115
264
|
if (panels.output) {
|
|
116
|
-
const
|
|
117
|
-
if (active.length > 0) {
|
|
118
|
-
const linesPerPanel = Math.max(1, Math.floor(panels.output.h / active.length))
|
|
119
|
-
for (const p of active) {
|
|
120
|
-
const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
|
|
121
|
-
const tail = textLines.slice(-linesPerPanel)
|
|
122
|
-
for (const line of tail) {
|
|
123
|
-
out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}${ansi.clearLine}`)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// fill remaining rows
|
|
127
|
-
const used = active.reduce((s, p) => {
|
|
128
|
-
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
129
|
-
return s + tl.length
|
|
130
|
-
}, 0)
|
|
131
|
-
for (let i = used; i < panels.output.h; i++) {
|
|
132
|
-
out.push(ansi.clearLine)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
265
|
+
for (const l of renderOutput(state, W, panels.output.h)) out.push(`${l}\x1b[K`)
|
|
135
266
|
}
|
|
136
267
|
|
|
137
|
-
//
|
|
268
|
+
// permission preview
|
|
138
269
|
if (panels.permission) {
|
|
139
|
-
out.push(`${
|
|
140
|
-
for (const wrapped of permPreviewLines) {
|
|
141
|
-
out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
|
|
142
|
-
}
|
|
270
|
+
for (const l of renderPermission(permPreviewLines)) out.push(`${l}\x1b[K`)
|
|
143
271
|
}
|
|
144
272
|
|
|
145
|
-
//
|
|
273
|
+
// queue preview
|
|
146
274
|
if (panels.queue) {
|
|
147
|
-
const
|
|
148
|
-
out.push(`${
|
|
275
|
+
const qLine = renderQueue(state, W)
|
|
276
|
+
if (qLine) out.push(`${qLine}\x1b[K`)
|
|
149
277
|
}
|
|
150
278
|
|
|
151
|
-
//
|
|
152
|
-
const
|
|
153
|
-
let topBorder
|
|
154
|
-
if (title === " Input " || title === " Question ") {
|
|
155
|
-
const parts = []
|
|
156
|
-
if (title === " Input ") parts.push(" Ctrl+U clear ")
|
|
157
|
-
if (title === " Question ") parts.push(" Enter submit ")
|
|
158
|
-
parts.push(" Ctrl+V paste ")
|
|
159
|
-
const hint = parts.join("")
|
|
160
|
-
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
161
|
-
} else {
|
|
162
|
-
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
163
|
-
}
|
|
164
|
-
out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
|
|
165
|
-
for (const l of boxLines) {
|
|
166
|
-
const content = sliceByWidth(l, W - 4)
|
|
167
|
-
const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
|
|
168
|
-
out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
|
|
169
|
-
}
|
|
170
|
-
out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}${ansi.clearLine}`)
|
|
279
|
+
// input box
|
|
280
|
+
for (const l of renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset)) out.push(`${l}\x1b[K`)
|
|
171
281
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
175
|
-
const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
|
|
176
|
-
const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "")
|
|
177
|
-
const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
|
|
178
|
-
out.push(`${ansi.dim}${planBanner}${autoBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}${ansi.clearLine}`)
|
|
282
|
+
// status bar
|
|
283
|
+
out.push(`${renderStatus(state, agent, cols, slashCommands)}\x1b[K`)
|
|
179
284
|
|
|
180
285
|
const frame = out.join("\r\n")
|
|
181
286
|
|
|
182
|
-
//
|
|
287
|
+
// cursor position
|
|
183
288
|
if (!state.permission && !state.question && !state.picker && state.wizard?.step !== "provider") {
|
|
184
289
|
cursorRow = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
185
290
|
cursorCol = 3 + inputLayout.cursorCol
|
|
@@ -188,20 +293,18 @@ export function renderFrame(state, agent, opts) {
|
|
|
188
293
|
return { frame, cursorRow, cursorCol }
|
|
189
294
|
}
|
|
190
295
|
|
|
191
|
-
//
|
|
296
|
+
// ====================================================================
|
|
297
|
+
// Internal helpers (unchanged from original)
|
|
298
|
+
// ====================================================================
|
|
192
299
|
|
|
193
|
-
/** Count conversation lines after sanitize + wrap (for scroll clamping). Pure. */
|
|
194
300
|
export function countConvLines(state, cols) {
|
|
195
301
|
return buildConvLines(state, cols).length
|
|
196
302
|
}
|
|
197
303
|
|
|
198
|
-
/** Build conversation lines from state (sanitized + wrapped). Pure.
|
|
199
|
-
* Cached: avoids O(n) rebuild on cursor moves — only recomputes when conversation grows/changes. */
|
|
200
304
|
let _convCache = { key: "", cols: 0, lines: [] }
|
|
201
305
|
function buildConvLines(state, cols) {
|
|
202
|
-
// Cheap cache key: structural hints that change whenever the conversation changes
|
|
203
306
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
204
|
-
const key =
|
|
307
|
+
const key = convCacheKey(state)
|
|
205
308
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
206
309
|
|
|
207
310
|
const convLines = []
|
|
@@ -235,20 +338,16 @@ function buildConvLines(state, cols) {
|
|
|
235
338
|
return convLines
|
|
236
339
|
}
|
|
237
340
|
|
|
238
|
-
/** Determine input box border color and title. Pure. */
|
|
239
341
|
function inputBoxStyle(state) {
|
|
240
342
|
let borderColor = C.tool
|
|
241
343
|
let title
|
|
242
|
-
if (state.
|
|
243
|
-
borderColor = C.
|
|
244
|
-
|
|
344
|
+
if (state.interruptPrompt) {
|
|
345
|
+
borderColor = C.warn; title = " Inject Message "
|
|
346
|
+
} else if (state.question) {
|
|
347
|
+
borderColor = C.tool; title = " Question "
|
|
245
348
|
} else if (state.permission) {
|
|
246
349
|
borderColor = C.warn
|
|
247
|
-
|
|
248
|
-
title = " Continue? (y/n) "
|
|
249
|
-
} else {
|
|
250
|
-
title = ` Allow ${state.permission.name}? (y/n/a) `
|
|
251
|
-
}
|
|
350
|
+
title = state.permission.name === "continue" ? " Continue? (y/n) " : ` Allow ${state.permission.name}? (y/n/a) `
|
|
252
351
|
} else if (state.picker) {
|
|
253
352
|
title = " Select "
|
|
254
353
|
} else if (state.wizard) {
|
|
@@ -261,7 +360,6 @@ function inputBoxStyle(state) {
|
|
|
261
360
|
return { borderColor, title }
|
|
262
361
|
}
|
|
263
362
|
|
|
264
|
-
/** Build status bar line. Pure. */
|
|
265
363
|
function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
266
364
|
const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
|
|
267
365
|
const rawInput = state.input.join("")
|
|
@@ -277,9 +375,7 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
277
375
|
? " y: continue │ n: stop"
|
|
278
376
|
: " y: approve │ n: deny │ a: approve all (AUTO)"
|
|
279
377
|
}
|
|
280
|
-
if (state.picker)
|
|
281
|
-
return " ↑↓: select │ Enter: confirm │ Esc: cancel"
|
|
282
|
-
}
|
|
378
|
+
if (state.picker) return " ↑↓: select │ Enter: confirm │ Esc: cancel"
|
|
283
379
|
if (state.wizard) {
|
|
284
380
|
return state.wizard.step === "provider"
|
|
285
381
|
? " ↑↓: select │ Enter: confirm │ Esc: skip"
|
|
@@ -289,51 +385,28 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
289
385
|
const [cmd] = rawInput.split(/\s+/)
|
|
290
386
|
const cmds = slashCommands.filter((c) => c.name.startsWith(cmd))
|
|
291
387
|
const match = cmds.length === 1 ? cmds[0] : null
|
|
292
|
-
if (match && SLASH_HINTS[match.name]) {
|
|
293
|
-
return ` ${match.name} ${SLASH_HINTS[match.name]}`
|
|
294
|
-
}
|
|
388
|
+
if (match && SLASH_HINTS[match.name]) return ` ${match.name} ${SLASH_HINTS[match.name]}`
|
|
295
389
|
if (cmds.length > 0) {
|
|
296
|
-
if (cmds.length <= 4) {
|
|
297
|
-
return ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
298
|
-
}
|
|
390
|
+
if (cmds.length <= 4) return ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
299
391
|
return ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
|
|
300
392
|
}
|
|
301
393
|
return ` unknown command (/help for available commands)`
|
|
302
394
|
}
|
|
303
395
|
|
|
304
396
|
const taskHint = state.tasks.length > 0
|
|
305
|
-
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
306
|
-
: ""
|
|
397
|
+
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}` : ""
|
|
307
398
|
const tk = state.tokens
|
|
308
399
|
const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
|
|
309
400
|
const cacheTotal = tk.cacheHit + tk.cacheMiss
|
|
310
401
|
const tokenHint = tk.prompt > 0
|
|
311
|
-
? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}`
|
|
312
|
-
: ""
|
|
402
|
+
? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${tk.reasoningTokens > 0 ? ` ✦${fmtK(tk.reasoningTokens)}` : ""}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}` : ""
|
|
313
403
|
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
314
404
|
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
315
405
|
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
316
406
|
const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
|
|
317
407
|
const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
|
|
318
408
|
const ctxHint = ctxPct > 0
|
|
319
|
-
? ctxPct >= 80
|
|
320
|
-
? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}`
|
|
321
|
-
: ` │ context ${ctxPct}%`
|
|
322
|
-
: ""
|
|
409
|
+
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%` : ""
|
|
323
410
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
324
|
-
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** Summarize tool args for subagent panel display (one line, short). Pure. */
|
|
328
|
-
function summarizeToolArg(toolName, args) {
|
|
329
|
-
if (!args || typeof args !== "object") return ""
|
|
330
|
-
if (toolName === "bash" && args.command) {
|
|
331
|
-
const cmd = args.command.split("\n")[0]
|
|
332
|
-
return `"${sliceByWidth(cmd, 50)}"`
|
|
333
|
-
}
|
|
334
|
-
if (args.path) return sliceByWidth(args.path, 60)
|
|
335
|
-
if (args.pattern) return `"${sliceByWidth(args.pattern, 50)}"`
|
|
336
|
-
if (args.query) return `"${sliceByWidth(args.query, 50)}"`
|
|
337
|
-
if (args.task) return `"${sliceByWidth(args.task, 50)}"`
|
|
338
|
-
return ""
|
|
411
|
+
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
339
412
|
}
|
|
@@ -21,6 +21,7 @@ import { handleGoalCommand } from "./cmd-goal.mjs"
|
|
|
21
21
|
import { handleSkillsCommand } from "./cmd-skills.mjs"
|
|
22
22
|
import { handleMcpCommand } from "./cmd-mcp.mjs"
|
|
23
23
|
import { handleAutoCommand } from "./cmd-auto.mjs"
|
|
24
|
+
import { handleAdvisorCommand } from "./cmd-advisor.mjs"
|
|
24
25
|
import { handleThinkCommand } from "./cmd-think.mjs"
|
|
25
26
|
import { handleModelCommand } from "./cmd-model.mjs"
|
|
26
27
|
import { handleConfigCommand } from "./cmd-config.mjs"
|
|
@@ -31,6 +32,7 @@ import { handleUpgradeCommand } from "./cmd-upgrade.mjs"
|
|
|
31
32
|
export const SLASH_COMMANDS = [
|
|
32
33
|
{ name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
|
|
33
34
|
{ name: "/auto", group: "Agent", desc: "toggle auto-approve" },
|
|
35
|
+
{ name: "/advisor", group: "Agent", desc: "toggle advisor review & select model" },
|
|
34
36
|
{ name: "/model", group: "Agent", desc: "select model & manage providers" },
|
|
35
37
|
{ name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
|
|
36
38
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
@@ -63,6 +65,7 @@ const HANDLERS = {
|
|
|
63
65
|
"/skills": handleSkillsCommand,
|
|
64
66
|
"/mcp": handleMcpCommand,
|
|
65
67
|
"/auto": handleAutoCommand,
|
|
68
|
+
"/advisor": handleAdvisorCommand,
|
|
66
69
|
"/think": handleThinkCommand,
|
|
67
70
|
"/model": handleModelCommand,
|
|
68
71
|
"/config": handleConfigCommand,
|