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.
@@ -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}`
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,20 +293,18 @@ 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 = []
@@ -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
- if (state.permission.name === "continue") {
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
- }