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.
@@ -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
- * Layout calculation lives in layout.mjs (computeLayout pure function).
6
- * Side effects (scroll clamping, ctxCache update) are performed by the caller before rendering.
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 (lookup table instead of if-else chain) ----------
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
- * Render one frame, returns { frame, cursorRow, cursorCol }.
29
- * Pure function: does not modify state/agent.
30
- */
31
- export function renderFrame(state, agent, opts) {
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
- const layout = computeLayout(state, { cols, rows })
38
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
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 thinkBadge = thinking?.type === "disabled" ? "│ think: off"
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
- : thinking?.type === thinkOnValue ? "│ think: on" : ""
48
-
49
- const out = [ansi.home]
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
- // ---- header ----
53
- out.push(
54
- `${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}`,
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
- // ---- conversation ----
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 - panels.conversation.h)
60
- const scroll = Math.min(state.scroll, maxScroll)
61
- const end = convLines.length - scroll
62
- const visible = convLines.slice(Math.max(0, end - panels.conversation.h), end)
63
- const pad = panels.conversation.h - visible.length
64
- for (let i = 0; i < pad; i++) out.push(ansi.clearLine)
65
- for (const l of visible) {
66
- out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
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
- // ---- todo panel ----
83
- for (const t of visibleTasks) {
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
- out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
87
- }
76
+ return `${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}`
77
+ })
78
+ }
88
79
 
89
- // ---- subagent panel ----
90
- if (panels.subagent) {
91
- const subs = allSubs
92
- for (const s of subs.slice(0, MAX_SUB_LINES)) {
93
- const icon = s.done ? "✓" : "…"
94
- const color = s.done ? C.dim : C.tool
95
- const label = `[${s.role}]`.padEnd(10)
96
- let content
97
- if (s.done) {
98
- const elapsed = Math.floor((Date.now() - s.started) / 1000)
99
- content = `done ${elapsed}s`
100
- } else if (s.tool) {
101
- const argSummary = s.toolArgs ? summarizeToolArg(s.tool, s.toolArgs) : ""
102
- content = `${s.tool}${argSummary ? ` ${argSummary}` : ""}`
103
- } else if (s.text) {
104
- const textLines = s.text.split("\n").filter((l) => l.trim())
105
- content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
106
- } else {
107
- content = "thinking..."
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
- // ---- tool output panels (streaming output like tail -f, auto-clears when done) ----
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
- // ---- permission preview ----
141
- if (panels.permission) {
142
- out.push(`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}${ansi.clearLine}`)
143
- for (const wrapped of permPreviewLines) {
144
- out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
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
- // ---- queue preview ----
149
- if (panels.queue) {
150
- const preview = sliceByWidth(state.queue[0].text, W - 20)
151
- out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D delete │ Ctrl+I inject${ansi.reset}${ansi.clearLine}`)
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
- // ---- input box ----
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.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
170
- for (const l of boxLines) {
171
- const content = sliceByWidth(l, W - 4)
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
- out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}${ansi.clearLine}`)
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}${ansi.clearLine}`)
204
+ out.push(`${borderColor}╰${"─".repeat(Math.max(0, W - 2))}╯${ansi.reset}`)
205
+ return out
206
+ }
176
207
 
177
- // ---- status bar ----
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
- out.push(`${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}${ansi.clearLine}`)
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
- // ---- cursor ----
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
- // ---------------------------------------------------------- internal helpers
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 = `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}`
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
- _convCache = { key, cols, lines: convLines }
241
- return convLines
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
- if (state.permission.name === "continue") {
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
  }