thincoder 0.12.2 → 0.12.3

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 (77) hide show
  1. package/README.md +18 -5
  2. package/package.json +1 -1
  3. package/src/advisor/history.mjs +112 -0
  4. package/src/advisor/messages.mjs +182 -0
  5. package/src/advisor/repos.mjs +133 -0
  6. package/src/advisor/run.mjs +346 -0
  7. package/src/advisor.mjs +109 -509
  8. package/src/agent/completion.mjs +119 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +93 -5
  12. package/src/agent-tools/advisor.mjs +159 -12
  13. package/src/agent-tools/eng.mjs +64 -0
  14. package/src/agent-tools/subagent.mjs +73 -3
  15. package/src/agent-tools/task.mjs +45 -6
  16. package/src/agent-tools/verify.mjs +18 -0
  17. package/src/agent-tools.mjs +1 -0
  18. package/src/agent.mjs +110 -150
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +22 -4
  22. package/src/prompts/advisor-design.md +43 -0
  23. package/src/prompts/advisor-round1.md +11 -4
  24. package/src/prompts/advisor-round2.md +12 -7
  25. package/src/prompts/advisor-round3.md +11 -6
  26. package/src/prompts/coder.md +9 -3
  27. package/src/prompts/discipline.md +12 -96
  28. package/src/prompts/eng-coder.md +34 -0
  29. package/src/prompts/engineering-sub.md +12 -0
  30. package/src/prompts/engineering.md +96 -0
  31. package/src/prompts/main.md +1 -1
  32. package/src/prompts/methodology-template.md +39 -0
  33. package/src/prompts/plan.md +2 -2
  34. package/src/prompts/system.md +43 -61
  35. package/src/session.mjs +270 -89
  36. package/src/skills.mjs +48 -15
  37. package/src/tools/apply_patch.md +1 -1
  38. package/src/tools/codemode.mjs +10 -4
  39. package/src/tools/delete.md +1 -0
  40. package/src/tools/edit.md +1 -1
  41. package/src/tools/execute.md +5 -0
  42. package/src/tools/file.mjs +4 -0
  43. package/src/tools/git.md +15 -0
  44. package/src/tools/git.mjs +1 -6
  45. package/src/tools/lint.md +8 -0
  46. package/src/tools/linter.mjs +1 -5
  47. package/src/tools/lsp.md +7 -0
  48. package/src/tools/lsp.mjs +8 -9
  49. package/src/tools/patch.mjs +1 -29
  50. package/src/tools/read_image.md +5 -1
  51. package/src/tools/system.mjs +1 -1
  52. package/src/tools/web.mjs +3 -3
  53. package/src/tui/agent-turn.mjs +169 -66
  54. package/src/tui/cmd-config.mjs +12 -0
  55. package/src/tui/cmd-eng.mjs +44 -0
  56. package/src/tui/cmd-exit.mjs +1 -1
  57. package/src/tui/cmd-fold.mjs +3 -4
  58. package/src/tui/cmd-model.mjs +11 -6
  59. package/src/tui/cmd-new.mjs +5 -5
  60. package/src/tui/cmd-session.mjs +21 -11
  61. package/src/tui/cmd-think.mjs +1 -0
  62. package/src/tui/index.mjs +7 -6
  63. package/src/tui/key-handler.mjs +132 -4
  64. package/src/tui/layout.mjs +5 -5
  65. package/src/tui/pickers.mjs +184 -44
  66. package/src/tui/render-conversation.mjs +49 -11
  67. package/src/tui/render-frame.mjs +38 -12
  68. package/src/tui/render-loop.mjs +2 -1
  69. package/src/tui/slash-commands.mjs +11 -7
  70. package/src/tui/startup.mjs +4 -3
  71. package/src/tui/wizard.mjs +3 -0
  72. package/src/tools/checkpoint.md +0 -15
  73. package/src/tools/git_diff.md +0 -11
  74. package/src/tools/git_log.md +0 -10
  75. package/src/tools/git_status.md +0 -8
  76. package/src/tools/linter.md +0 -13
  77. package/src/tools/syntax_check.md +0 -10
@@ -1,7 +1,7 @@
1
1
  /** /exit command: exit TUI (same path as Ctrl+C).
2
2
  * Direct synchronous process.exit — prevents the post-handler render()
3
3
  * from redrawing the TUI over the cleaned terminal. The actual cleanup
4
- * (archiveCurrent + saveSession + closeMcp + terminal reset) runs once
4
+ * (saveSession + closeMcp + terminal reset) runs once
5
5
  * via the process.on("exit") handler registered in index.mjs.
6
6
  */
7
7
  export async function handleExitCommand(_ctx) {
@@ -4,17 +4,16 @@
4
4
  import { C } from "./ansi.mjs"
5
5
 
6
6
  export async function handleFoldCommand(ctx, args = []) {
7
- const { state } = ctx
7
+ const { state, pushLabel } = ctx
8
8
  const arg = args[0]?.toLowerCase()
9
9
  if (arg === "on") {
10
10
  state.foldEnabled = true
11
- ctx.pushLine("Folding: on (long tool results are collapsed)", C.dim)
12
11
  } else if (arg === "off") {
13
12
  state.foldEnabled = false
14
- ctx.pushLine("Folding: off (all results shown in full)", C.dim)
15
13
  } else {
16
14
  state.foldEnabled = !state.foldEnabled
17
- ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.dim)
18
15
  }
16
+ pushLabel("❯ Fold", C.bold + C.tool)
17
+ ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.tool)
19
18
  ctx.render()
20
19
  }
@@ -1,18 +1,23 @@
1
1
  import { C } from "./ansi.mjs"
2
2
 
3
- /** /model command: open model picker, or switch provider directly via `/model <provider>`.
3
+ /** /model command: open model picker, or switch provider directly via `/model <provider>[:model]`.
4
4
  * ctx: { agent, openModelPicker, selectModel, pushLine } */
5
5
  export async function handleModelCommand(ctx, args = []) {
6
- const name = args[0]?.toLowerCase()
7
- if (!name) {
6
+ const raw = args[0]?.toLowerCase()
7
+ if (!raw) {
8
8
  ctx.openModelPicker().catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
9
9
  return
10
10
  }
11
- const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === name)
11
+ // Parse "provider:model" syntax
12
+ const colonIdx = raw.indexOf(":")
13
+ const providerName = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw
14
+ const modelName = colonIdx >= 0 ? raw.slice(colonIdx + 1) : null
15
+
16
+ const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === providerName)
12
17
  if (!target) {
13
18
  const available = ctx.agent.providers.map((p) => p.name).join(", ")
14
- ctx.pushLine(`Unknown provider: ${args[0]} (available: ${available})`, C.error)
19
+ ctx.pushLine(`Unknown provider: ${providerName} (available: ${available})`, C.error)
15
20
  return
16
21
  }
17
- await ctx.selectModel({ provider: target.name, model: target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
22
+ await ctx.selectModel({ provider: target.name, model: modelName || target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
18
23
  }
@@ -1,12 +1,13 @@
1
- import { clearSession } from "../session.mjs"
1
+ import { newSession } from "../session.mjs"
2
2
  import { C } from "./ansi.mjs"
3
3
 
4
- /** /new command: start new session (old session archived to slot).
4
+ /** /new command: start a new session in a fresh slot.
5
5
  * ctx: { agent, state, pushLine, showPicker, render } */
6
6
  export async function handleNewCommand(ctx) {
7
7
  const { agent, state, pushLine, showPicker, render } = ctx
8
8
 
9
9
  const doNewSession = () => {
10
+ const slot = newSession(agent.cwd)
10
11
  agent.history = []
11
12
  agent.tasks = []
12
13
  agent.planMode = false
@@ -15,14 +16,13 @@ export async function handleNewCommand(ctx) {
15
16
  state.tasks = []
16
17
  state.lines = []
17
18
  state.streaming = ""
18
- clearSession(agent.cwd)
19
19
  render()
20
- pushLine("New session started (old session archived to slot; /session to view)", C.dim)
20
+ pushLine(`New session started (slot ${slot}; /session to switch back)`, C.dim)
21
21
  }
22
22
 
23
23
  if (agent.history.length > 0) {
24
24
  const e = await showPicker("Start new session?", [
25
- { type: "item", text: "Yes, archive current and start new", action: "yes" },
25
+ { type: "item", text: "Yes, start new session in a new slot", action: "yes" },
26
26
  { type: "item", text: "Cancel", action: "no" },
27
27
  ], { defaultIndex: 1 })
28
28
  if (e?.action === "yes") doNewSession()
@@ -1,22 +1,34 @@
1
1
  import { listSlots, switchToSlot, applySession } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
3
 
4
- /** /session command: list/switch archived session slots.
4
+ /** /session command: list/switch session slots.
5
5
  * ctx: { agent, state, showPicker, pushLine, pushLabel, render } */
6
6
  export async function handleSessionCommand(ctx) {
7
7
  const { agent, state, showPicker, pushLine, pushLabel, render } = ctx
8
8
  const slots = listSlots(agent.cwd)
9
9
  if (slots.length === 0) {
10
- pushLine("No archived sessions (use /new and old sessions auto-archive to slots)", C.dim)
10
+ pushLine("No sessions (use /new to start a new session)", C.dim)
11
11
  return
12
12
  }
13
+ const shortDate = (d) => {
14
+ const dt = new Date(d)
15
+ return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
16
+ }
17
+ const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + "…"
13
18
  const entries = [
14
- { type: "header", text: `Archived sessions (↑↓ select, Enter switch, Esc cancel)` },
15
- ...slots.map((s) => ({
16
- type: "item",
17
- text: `Slot ${s.slot} ${s.date}`,
18
- slot: s.slot,
19
- })),
19
+ { type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel)` },
20
+ ...slots.map((s) => {
21
+ const preview = s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)"
22
+ const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
23
+ const when = shortDate(s.updatedAt)
24
+ const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
25
+ const marker = s.isActive ? " ●" : ""
26
+ return {
27
+ type: "item",
28
+ text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${preview}${model}${marker}`,
29
+ slot: s.slot,
30
+ }
31
+ }),
20
32
  ]
21
33
  const e = await showPicker("Sessions", entries)
22
34
  if (!e) return
@@ -26,9 +38,7 @@ export async function handleSessionCommand(ctx) {
26
38
  return
27
39
  }
28
40
  applySession(agent, data)
29
- state.lines = data.display.length
30
- ? data.display.map((l) => ({ text: l.text, color: l.color }))
31
- : []
41
+ state.lines = data.display.length ? [...data.display] : []
32
42
  state.tasks = agent.tasks ?? []
33
43
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
34
44
  state.tasks = []
@@ -74,6 +74,7 @@ export async function handleThinkCommand(ctx, args = []) {
74
74
  if (e.action === "auto") {
75
75
  const newAuto = agent.config?.agent?.autoThink === true
76
76
  pushLine(`Auto-think: ${newAuto ? "ON" : "OFF"}`, C.tool)
77
+ return // exit loop — no useful actions remain when auto mode just changed
77
78
  } else if (e.action === "effort") {
78
79
  pushLine(`Reasoning effort: ${e.level}`, C.tool)
79
80
  } else {
package/src/tui/index.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { emitKeypressEvents } from "node:readline"
20
20
  import { PassThrough } from "node:stream"
21
- import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
21
+ import { saveSession } from "../session.mjs"
22
22
  import { closeAllMcp } from "../mcp.mjs"
23
23
  import { ansi, C } from "./ansi.mjs"
24
24
  import { createRenderLoop } from "./render-loop.mjs"
@@ -59,6 +59,7 @@ export async function startTUI(agent, opts = {}) {
59
59
  const state = {
60
60
  lines: [], // conversation lines: { text, color }
61
61
  streaming: "", // current streaming buffer
62
+ advisorStreaming: "", // advisor streaming buffer (formatted like main response)
62
63
  input: [], // input buffer (codepoint array)
63
64
  cursor: 0,
64
65
  history: [],
@@ -78,14 +79,13 @@ export async function startTUI(agent, opts = {}) {
78
79
  ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
79
80
  reasoning: "", // thinking stream buffer (dimmed display)
80
81
  completion: null, // Tab completion state { candidates, index }
81
- toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
82
82
  subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
83
- outputPanels: {}, // tool output panels: { toolName: { parts: [{kind, text}], len, done, closeAt } } — streamed live during execution, kept visible for a grace period after completion
84
83
  currentTool: null, // currently executing tool name (shown in status bar)
85
84
  processingStarted: 0, // current turn start time (status bar timer)
86
85
  status: "Ready",
87
86
  queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
88
87
  interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
88
+ search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
89
89
  expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
90
90
  foldEnabled: true, // global fold toggle — /fold on|off
91
91
  }
@@ -195,9 +195,10 @@ export async function startTUI(agent, opts = {}) {
195
195
  const cleanup = () => {
196
196
  if (cleanedUp) return
197
197
  cleanedUp = true
198
- // Save session before exit (synchronous write); archive current to a slot first, then save new — never lose data
198
+ // Save session before exit (synchronous write).
199
+ // Archiving to a slot is handled by /new and /session switch — not on every exit,
200
+ // otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
199
201
  try {
200
- archiveCurrent(agent.cwd)
201
202
  saveSession(agent, state.lines)
202
203
  } catch {
203
204
  // Save failure shouldn't block exit
@@ -353,7 +354,7 @@ export async function startTUI(agent, opts = {}) {
353
354
  agent, state, render, popPicker, renderPickerLines,
354
355
  handleSlash, handleTab, submit, pasteClipboardImage,
355
356
  wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
356
- renderWizard, pushLine, cleanup,
357
+ renderWizard, pushLine, cleanup, showPicker,
357
358
  })
358
359
  keyStream.on("keypress", (str, key) => {
359
360
  try {
@@ -2,14 +2,59 @@ import { ansi, C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
3
  import { computeLayout } from "./layout.mjs"
4
4
 
5
+ /** Perform search on state.lines, update state.search.matches and clamp index */
6
+ function performSearch(state) {
7
+ if (!state.search) return
8
+ const query = state.search.query.toLowerCase()
9
+ state.search.matches = []
10
+ // Clear old highlights
11
+ state.lines.forEach(l => delete l._searchMatches)
12
+
13
+ if (!query) { state.search.index = 0; return }
14
+
15
+ state.lines.forEach((line, lineIndex) => {
16
+ const text = (line.text || "").toLowerCase()
17
+ let charIndex = text.indexOf(query)
18
+ if (charIndex !== -1) {
19
+ line._searchMatches = []
20
+ }
21
+ while (charIndex !== -1) {
22
+ state.search.matches.push({ lineIndex, charIndex })
23
+ line._searchMatches.push(charIndex)
24
+ charIndex = text.indexOf(query, charIndex + 1)
25
+ }
26
+ })
27
+
28
+ if (state.search.matches.length === 0) {
29
+ state.search.index = 0
30
+ } else {
31
+ state.search.index = Math.min(state.search.index, state.search.matches.length - 1)
32
+ }
33
+ }
34
+
35
+ /** Estimate scroll position to make a line visible (simplified) */
36
+ function scrollToMatch(state, lineIndex) {
37
+ if (lineIndex == null) return
38
+ // Rough estimate: each state.line takes 1-2 rendered lines
39
+ let estimatedLine = 0
40
+ for (let i = 0; i < lineIndex && i < state.lines.length; i++) {
41
+ estimatedLine += Math.max(1, Math.ceil((state.lines[i].text?.length || 0) / 80))
42
+ }
43
+ const rows = process.stdout.rows || 24
44
+ const visibleH = Math.max(5, rows - 10) // reserve space for header, input, status, etc.
45
+ // scroll is number of lines hidden above viewport
46
+ // We want estimatedLine to be near the bottom of the viewport
47
+ state.scroll = Math.max(0, state.lines.length - estimatedLine - visibleH + 3)
48
+ }
49
+
5
50
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
6
51
  * Extracted from index.mjs.
7
52
  * ctx: { agent, state, render, renderPickerLines, popPicker,
8
53
  * handleSlash, handleTab, submit, pasteClipboardImage,
9
54
  * wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
10
- * renderWizard, pushLine, cleanup } */
55
+ * renderWizard, pushLine, cleanup, showPicker } */
11
56
  export function createKeyHandler(ctx) {
12
- const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
57
+ const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker } = ctx
13
58
 
14
59
  return function onKeypress(str, key = {}) {
15
60
  // permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
@@ -102,7 +147,66 @@ export function createKeyHandler(ctx) {
102
147
  return
103
148
  }
104
149
 
105
- if (key.ctrl && key.name === "c") {
150
+ // Search mode: Ctrl+F to enter, Esc/n/N to navigate/exit
151
+ if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
152
+ if (!state.search) {
153
+ state.search = { query: "", matches: [], index: 0 }
154
+ }
155
+ render()
156
+ return
157
+ }
158
+
159
+ if (state.search) {
160
+ if (key.name === "escape") {
161
+ state.search = null
162
+ render()
163
+ return
164
+ }
165
+ if (key.name === "n" || (key.ctrl && key.name === "g")) {
166
+ // Next match
167
+ if (state.search.matches.length > 0) {
168
+ state.search.index = (state.search.index + 1) % state.search.matches.length
169
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
170
+ }
171
+ render()
172
+ return
173
+ }
174
+ if (key.name === "p" || (key.ctrl && key.name === "r")) {
175
+ // Previous match
176
+ if (state.search.matches.length > 0) {
177
+ state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
178
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
179
+ }
180
+ render()
181
+ return
182
+ }
183
+ if (key.name === "return") {
184
+ // Exit search mode but keep highlighting until next Ctrl+F
185
+ state.search = null
186
+ render()
187
+ return
188
+ }
189
+ if (key.name === "backspace") {
190
+ if (state.search.query.length > 0) {
191
+ state.search.query = state.search.query.slice(0, -1)
192
+ performSearch(state)
193
+ render()
194
+ }
195
+ return
196
+ }
197
+ // Regular character input
198
+ if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
199
+ state.search.query += str
200
+ performSearch(state)
201
+ if (state.search.matches.length > 0) {
202
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
203
+ }
204
+ render()
205
+ return
206
+ }
207
+ }
208
+
209
+ if (key.ctrl && key.name === "c") {
106
210
  // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
107
211
  if (state.picker) {
108
212
  popPicker(null)
@@ -120,6 +224,23 @@ export function createKeyHandler(ctx) {
120
224
  ctx.exitTimer.unref?.()
121
225
  }
122
226
 
227
+ // F1: 显示快捷键帮助
228
+ if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
229
+ showPicker("Keyboard Shortcuts", [
230
+ { type: "item", text: "Ctrl+C — Cancel/Abort current operation" },
231
+ { type: "item", text: "Ctrl+I — Interrupt and inject message" },
232
+ { type: "item", text: "Ctrl+F — Search conversation history" },
233
+ { type: "item", text: "Ctrl+L — Clear screen" },
234
+ { type: "item", text: "Ctrl+U — Clear input line" },
235
+ { type: "item", text: "Esc — Cancel current input/picker" },
236
+ { type: "item", text: "↑/↓ — Navigate input history" },
237
+ { type: "item", text: "PgUp/PgDn — Scroll conversation" },
238
+ { type: "item", text: "Enter — Send message or confirm selection" },
239
+ { type: "item", text: "F1 — Show this help" },
240
+ ])
241
+ return
242
+ }
243
+
123
244
  // Ctrl+I (or Tab during processing): interrupt and inject a message
124
245
  if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
125
246
  if (state.processing && state.controller && !state.interruptPrompt) {
@@ -335,7 +456,14 @@ export function createKeyHandler(ctx) {
335
456
  return
336
457
  }
337
458
  if (key.name === "return" || key.name === "enter" || str === "\r") {
338
- submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
459
+ if (key.shift) {
460
+ // Shift+Enter: insert newline for multiline input
461
+ state.input.splice(state.cursor, 0, '\n')
462
+ state.cursor++
463
+ render()
464
+ } else {
465
+ submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
466
+ }
339
467
  return
340
468
  }
341
469
 
@@ -22,8 +22,8 @@ export function computeLayout(state, { cols, rows }) {
22
22
  const W = Math.max(20, cols - 1)
23
23
 
24
24
  // --- input box ---
25
- const inputBuf = state.interruptPrompt ? [...state.interruptPrompt.text] : state.input
26
- const inputCursor = state.interruptPrompt ? inputBuf.length : state.cursor
25
+ const inputBuf = state.search ? [...state.search.query] : (state.interruptPrompt ? [...state.interruptPrompt.text] : state.input)
26
+ const inputCursor = state.search ? inputBuf.length : (state.interruptPrompt ? inputBuf.length : state.cursor)
27
27
  const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
28
28
  let inputOffset = 0
29
29
  if (inputLayout.lines.length > MAX_INPUT_LINES) {
@@ -69,10 +69,10 @@ export function computeLayout(state, { cols, rows }) {
69
69
  ? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
70
70
  : 0
71
71
 
72
- // Tool output panels: max 8 lines per panel, capped at reasonable total.
72
+ // Tool output panels: max 9 lines per panel (1 title + 8 content), capped at reasonable total.
73
73
  // Done panels stay visible until their closeAt grace elapses (render loop prunes them).
74
- const panels = Object.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
75
- const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
74
+ const panels = Object.values(state.outputPanels ?? {}).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
75
+ const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 9, rows - 10) : 0
76
76
 
77
77
  // Permission preview (height depends on wrapped content)
78
78
  let permPreviewLines = []