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