thincoder 0.8.11 → 0.8.13

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