thincoder 0.8.12 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +20 -2
- package/src/agent/setup.mjs +0 -3
- package/src/agent-tools/goal.mjs +38 -1
- package/src/agent.mjs +6 -7
- package/src/auto-think.mjs +1 -1
- package/src/config.mjs +2 -0
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/hooks.mjs +93 -0
- 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/checklist.md +4 -1
- package/src/tools/checklist.mjs +134 -22
- 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 +5 -0
- package/src/tui/cmd-advisor.mjs +2 -2
- package/src/tui/cmd-fold.mjs +21 -0
- package/src/tui/cmd-mcp.mjs +42 -1
- package/src/tui/cmd-undo.mjs +91 -0
- package/src/tui/index.mjs +167 -53
- package/src/tui/key-handler.mjs +2 -2
- package/src/tui/layout.mjs +3 -3
- package/src/tui/render-frame.mjs +266 -171
- package/src/tui/slash-commands.mjs +6 -0
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}|${state.foldEnabled !== false ? "f" : "u"}`
|
|
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,30 +293,30 @@ 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 = []
|
|
214
311
|
for (const l of state.lines) {
|
|
215
312
|
for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
|
|
216
313
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
217
|
-
convLines.push({ text: wrapped, color: l.color })
|
|
314
|
+
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
218
315
|
}
|
|
219
316
|
}
|
|
220
317
|
}
|
|
318
|
+
// Messages after the conversation (streaming / thinking / tool output):
|
|
319
|
+
// appended after history lines so they appear at the bottom.
|
|
221
320
|
if (state.reasoning) {
|
|
222
321
|
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
223
322
|
convLines.push({ text: wrapped, color: C.reason })
|
|
@@ -237,27 +336,49 @@ function buildConvLines(state, cols) {
|
|
|
237
336
|
convLines.push({ text: wrapped, color: C.dim })
|
|
238
337
|
}
|
|
239
338
|
}
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
370
|
}
|
|
243
371
|
|
|
244
|
-
/** Determine input box border color and title. Pure. */
|
|
245
372
|
function inputBoxStyle(state) {
|
|
246
373
|
let borderColor = C.tool
|
|
247
374
|
let title
|
|
248
375
|
if (state.interruptPrompt) {
|
|
249
|
-
borderColor = C.warn
|
|
250
|
-
title = " Inject Message "
|
|
376
|
+
borderColor = C.warn; title = " Inject Message "
|
|
251
377
|
} else if (state.question) {
|
|
252
|
-
borderColor = C.tool
|
|
253
|
-
title = " Question "
|
|
378
|
+
borderColor = C.tool; title = " Question "
|
|
254
379
|
} else if (state.permission) {
|
|
255
380
|
borderColor = C.warn
|
|
256
|
-
|
|
257
|
-
title = " Continue? (y/n) "
|
|
258
|
-
} else {
|
|
259
|
-
title = ` Allow ${state.permission.name}? (y/n/a) `
|
|
260
|
-
}
|
|
381
|
+
title = state.permission.name === "continue" ? " Continue? (y/n) " : ` Allow ${state.permission.name}? (y/n/a) `
|
|
261
382
|
} else if (state.picker) {
|
|
262
383
|
title = " Select "
|
|
263
384
|
} else if (state.wizard) {
|
|
@@ -270,7 +391,6 @@ function inputBoxStyle(state) {
|
|
|
270
391
|
return { borderColor, title }
|
|
271
392
|
}
|
|
272
393
|
|
|
273
|
-
/** Build status bar line. Pure. */
|
|
274
394
|
function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
275
395
|
const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
|
|
276
396
|
const rawInput = state.input.join("")
|
|
@@ -286,9 +406,7 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
286
406
|
? " y: continue │ n: stop"
|
|
287
407
|
: " y: approve │ n: deny │ a: approve all (AUTO)"
|
|
288
408
|
}
|
|
289
|
-
if (state.picker)
|
|
290
|
-
return " ↑↓: select │ Enter: confirm │ Esc: cancel"
|
|
291
|
-
}
|
|
409
|
+
if (state.picker) return " ↑↓: select │ Enter: confirm │ Esc: cancel"
|
|
292
410
|
if (state.wizard) {
|
|
293
411
|
return state.wizard.step === "provider"
|
|
294
412
|
? " ↑↓: select │ Enter: confirm │ Esc: skip"
|
|
@@ -298,51 +416,28 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
298
416
|
const [cmd] = rawInput.split(/\s+/)
|
|
299
417
|
const cmds = slashCommands.filter((c) => c.name.startsWith(cmd))
|
|
300
418
|
const match = cmds.length === 1 ? cmds[0] : null
|
|
301
|
-
if (match && SLASH_HINTS[match.name]) {
|
|
302
|
-
return ` ${match.name} ${SLASH_HINTS[match.name]}`
|
|
303
|
-
}
|
|
419
|
+
if (match && SLASH_HINTS[match.name]) return ` ${match.name} ${SLASH_HINTS[match.name]}`
|
|
304
420
|
if (cmds.length > 0) {
|
|
305
|
-
if (cmds.length <= 4) {
|
|
306
|
-
return ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
307
|
-
}
|
|
421
|
+
if (cmds.length <= 4) return ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
308
422
|
return ` ${cmds.map((c) => c.name).join(" ")} │ Tab complete`
|
|
309
423
|
}
|
|
310
424
|
return ` unknown command (/help for available commands)`
|
|
311
425
|
}
|
|
312
426
|
|
|
313
427
|
const taskHint = state.tasks.length > 0
|
|
314
|
-
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
315
|
-
: ""
|
|
428
|
+
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}` : ""
|
|
316
429
|
const tk = state.tokens
|
|
317
430
|
const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
|
|
318
431
|
const cacheTotal = tk.cacheHit + tk.cacheMiss
|
|
319
432
|
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
|
-
: ""
|
|
433
|
+
? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${tk.reasoningTokens > 0 ? ` ✦${fmtK(tk.reasoningTokens)}` : ""}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}` : ""
|
|
322
434
|
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
323
435
|
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
324
436
|
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
325
437
|
const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
|
|
326
438
|
const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
|
|
327
439
|
const ctxHint = ctxPct > 0
|
|
328
|
-
? ctxPct >= 80
|
|
329
|
-
? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}`
|
|
330
|
-
: ` │ context ${ctxPct}%`
|
|
331
|
-
: ""
|
|
440
|
+
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%` : ""
|
|
332
441
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
333
442
|
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
334
443
|
}
|
|
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
|
-
}
|
|
@@ -28,6 +28,8 @@ import { handleConfigCommand } from "./cmd-config.mjs"
|
|
|
28
28
|
import { handleExtractCommand } from "./cmd-extract.mjs"
|
|
29
29
|
import { handleHelpCommand } from "./cmd-help.mjs"
|
|
30
30
|
import { handleUpgradeCommand } from "./cmd-upgrade.mjs"
|
|
31
|
+
import { handleFoldCommand } from "./cmd-fold.mjs"
|
|
32
|
+
import { handleUndoCommand } from "./cmd-undo.mjs"
|
|
31
33
|
|
|
32
34
|
export const SLASH_COMMANDS = [
|
|
33
35
|
{ name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
|
|
@@ -38,6 +40,7 @@ export const SLASH_COMMANDS = [
|
|
|
38
40
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
39
41
|
{ name: "/config", group: "Agent", desc: "config management (embedding / agent)" },
|
|
40
42
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
43
|
+
{ name: "/fold", group: "System", desc: "toggle result folding on/off" },
|
|
41
44
|
{ name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
|
|
42
45
|
{ name: "/session", group: "Session", desc: "list/switch archived sessions" },
|
|
43
46
|
{ name: "/clear", group: "Session", desc: "clear screen" },
|
|
@@ -47,6 +50,7 @@ export const SLASH_COMMANDS = [
|
|
|
47
50
|
{ name: "/mcp", group: "Project", desc: "manage MCP servers" },
|
|
48
51
|
{ name: "/reindex", group: "Project", desc: "rebuild memory index" },
|
|
49
52
|
{ name: "/restore", group: "Project", desc: "restore checkpoint" },
|
|
53
|
+
{ name: "/undo", group: "Project", desc: "undo recent file modifications" },
|
|
50
54
|
{ name: "/exit", group: "System", desc: "exit" },
|
|
51
55
|
{ name: "/help", group: "System", desc: "this list" },
|
|
52
56
|
]
|
|
@@ -70,6 +74,8 @@ const HANDLERS = {
|
|
|
70
74
|
"/model": handleModelCommand,
|
|
71
75
|
"/config": handleConfigCommand,
|
|
72
76
|
"/upgrade": handleUpgradeCommand,
|
|
77
|
+
"/fold": handleFoldCommand,
|
|
78
|
+
"/undo": handleUndoCommand,
|
|
73
79
|
"/extract": handleExtractCommand,
|
|
74
80
|
"/help": handleHelpCommand,
|
|
75
81
|
}
|