thincoder 0.12.2 → 0.12.4

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 (85) hide show
  1. package/README.md +29 -6
  2. package/package.json +3 -3
  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 +134 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +95 -6
  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 +152 -161
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +34 -4
  22. package/src/context.mjs +47 -13
  23. package/src/generate-title.mjs +44 -0
  24. package/src/prompts/advisor-design.md +43 -0
  25. package/src/prompts/advisor-round1.md +11 -4
  26. package/src/prompts/advisor-round2.md +12 -7
  27. package/src/prompts/advisor-round3.md +11 -6
  28. package/src/prompts/coder.md +9 -3
  29. package/src/prompts/discipline.md +12 -96
  30. package/src/prompts/eng-coder.md +34 -0
  31. package/src/prompts/engineering-sub.md +12 -0
  32. package/src/prompts/engineering.md +96 -0
  33. package/src/prompts/main.md +1 -1
  34. package/src/prompts/methodology-template.md +39 -0
  35. package/src/prompts/plan.md +2 -2
  36. package/src/prompts/system.md +43 -61
  37. package/src/provider/core.mjs +58 -2
  38. package/src/session.mjs +291 -94
  39. package/src/skills.mjs +48 -15
  40. package/src/tools/apply_patch.md +1 -1
  41. package/src/tools/checklist.mjs +4 -3
  42. package/src/tools/codemode.mjs +23 -11
  43. package/src/tools/delete.md +1 -0
  44. package/src/tools/edit.md +1 -1
  45. package/src/tools/execute.md +5 -0
  46. package/src/tools/file.mjs +4 -0
  47. package/src/tools/git.md +15 -0
  48. package/src/tools/git.mjs +1 -6
  49. package/src/tools/lint.md +8 -0
  50. package/src/tools/linter.mjs +1 -5
  51. package/src/tools/lsp.md +7 -0
  52. package/src/tools/lsp.mjs +8 -9
  53. package/src/tools/patch.mjs +1 -29
  54. package/src/tools/read_image.md +5 -1
  55. package/src/tools/system.mjs +1 -1
  56. package/src/tools/web.mjs +3 -3
  57. package/src/tui/agent-turn.mjs +184 -66
  58. package/src/tui/ansi.mjs +4 -0
  59. package/src/tui/clipboard.mjs +9 -0
  60. package/src/tui/cmd-config.mjs +14 -26
  61. package/src/tui/cmd-eng.mjs +44 -0
  62. package/src/tui/cmd-exit.mjs +1 -1
  63. package/src/tui/cmd-fold.mjs +3 -4
  64. package/src/tui/cmd-model.mjs +11 -6
  65. package/src/tui/cmd-new.mjs +5 -5
  66. package/src/tui/cmd-session.mjs +21 -11
  67. package/src/tui/cmd-think.mjs +1 -0
  68. package/src/tui/index.mjs +20 -9
  69. package/src/tui/key-handler.mjs +177 -9
  70. package/src/tui/layout.mjs +5 -5
  71. package/src/tui/markdown.mjs +52 -0
  72. package/src/tui/pickers.mjs +190 -45
  73. package/src/tui/render-conversation.mjs +54 -13
  74. package/src/tui/render-frame.mjs +39 -12
  75. package/src/tui/render-loop.mjs +2 -1
  76. package/src/tui/render.mjs +13 -7
  77. package/src/tui/slash-commands.mjs +11 -7
  78. package/src/tui/startup.mjs +4 -3
  79. package/src/tui/wizard.mjs +3 -0
  80. package/src/tools/checkpoint.md +0 -15
  81. package/src/tools/git_diff.md +0 -11
  82. package/src/tools/git_log.md +0 -10
  83. package/src/tools/git_status.md +0 -8
  84. package/src/tools/linter.md +0 -13
  85. package/src/tools/syntax_check.md +0 -10
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"
@@ -27,7 +27,7 @@ import { createWizard } from "./wizard.mjs"
27
27
  import { createPickers } from "./pickers.mjs"
28
28
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
29
29
  import { createInteraction } from "./interaction.mjs"
30
- import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText } from "./clipboard.mjs"
30
+ import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
31
31
  import { runAgentTurn } from "./agent-turn.mjs"
32
32
  import { createKeyHandler } from "./key-handler.mjs"
33
33
  import { showStartup, backgroundIndex } from "./startup.mjs"
@@ -59,10 +59,12 @@ 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: [],
65
66
  historyIndex: -1,
67
+ _draft: null, // stashed unsent input while navigating history (restored on down past newest)
66
68
  scroll: 0, // scroll lines from bottom upward
67
69
  processing: false,
68
70
  controller: null, // AbortController for current agent run
@@ -78,16 +80,16 @@ export async function startTUI(agent, opts = {}) {
78
80
  ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
79
81
  reasoning: "", // thinking stream buffer (dimmed display)
80
82
  completion: null, // Tab completion state { candidates, index }
81
- toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
82
83
  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
84
  currentTool: null, // currently executing tool name (shown in status bar)
85
85
  processingStarted: 0, // current turn start time (status bar timer)
86
86
  status: "Ready",
87
87
  queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
88
88
  interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
89
+ search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
89
90
  expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
90
91
  foldEnabled: true, // global fold toggle — /fold on|off
92
+ exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
91
93
  }
92
94
 
93
95
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
@@ -108,7 +110,11 @@ export async function startTUI(agent, opts = {}) {
108
110
  const startupRows = process.stdout.rows || 24
109
111
  emitKeypressEvents(keyStream)
110
112
  process.stdin.setRawMode(true)
111
- process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
113
+ // Keyboard enhancement enable BOTH protocols (unsupported terminals ignore them):
114
+ // kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
115
+ // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
116
+ // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
117
+ process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn)
112
118
 
113
119
  const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
114
120
 
@@ -181,6 +187,9 @@ export async function startTUI(agent, opts = {}) {
181
187
  text = text.slice(0, -tail[0].length)
182
188
  }
183
189
 
190
+ // Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
191
+ text = translateShiftEnter(text)
192
+
184
193
  if (state.scroll !== lastRenderedScroll) {
185
194
  lastRenderedScroll = state.scroll
186
195
  render()
@@ -195,9 +204,10 @@ export async function startTUI(agent, opts = {}) {
195
204
  const cleanup = () => {
196
205
  if (cleanedUp) return
197
206
  cleanedUp = true
198
- // Save session before exit (synchronous write); archive current to a slot first, then save new — never lose data
207
+ // Save session before exit (synchronous write).
208
+ // Archiving to a slot is handled by /new and /session switch — not on every exit,
209
+ // otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
199
210
  try {
200
- archiveCurrent(agent.cwd)
201
211
  saveSession(agent, state.lines)
202
212
  } catch {
203
213
  // Save failure shouldn't block exit
@@ -209,7 +219,7 @@ export async function startTUI(agent, opts = {}) {
209
219
  // Can't close? fine, process is exiting anyway
210
220
  }
211
221
  process.stdin.setRawMode(false)
212
- process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
222
+ process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
213
223
  }
214
224
  process.on("exit", cleanup)
215
225
 
@@ -259,6 +269,7 @@ export async function startTUI(agent, opts = {}) {
259
269
  state.cursor = 0
260
270
  state.history.push(text)
261
271
  state.historyIndex = -1
272
+ state._draft = null // submitted — the draft is now history
262
273
  state.scroll = 0
263
274
 
264
275
  // Slash commands: handled locally, don't enter agent loop
@@ -353,7 +364,7 @@ export async function startTUI(agent, opts = {}) {
353
364
  agent, state, render, popPicker, renderPickerLines,
354
365
  handleSlash, handleTab, submit, pasteClipboardImage,
355
366
  wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
356
- renderWizard, pushLine, cleanup,
367
+ renderWizard, pushLine, cleanup, showPicker,
357
368
  })
358
369
  keyStream.on("keypress", (str, key) => {
359
370
  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)
@@ -24,6 +69,8 @@ export function createKeyHandler(ctx) {
24
69
  state.status = "Processing..."
25
70
  if (answer === "a" && !isContinue) {
26
71
  agent.autoApprove = true
72
+ agent._pendingReminders = agent._pendingReminders ?? []
73
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
27
74
  pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
28
75
  }
29
76
  const approved = answer === "y" || (answer === "a" && !isContinue)
@@ -102,7 +149,71 @@ export function createKeyHandler(ctx) {
102
149
  return
103
150
  }
104
151
 
105
- if (key.ctrl && key.name === "c") {
152
+ // Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
153
+ if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
154
+ if (!state.search) {
155
+ state.search = { query: "", matches: [], index: 0 }
156
+ }
157
+ render()
158
+ return
159
+ }
160
+
161
+ if (state.search) {
162
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
163
+ state.search = null
164
+ render()
165
+ return
166
+ }
167
+ // Navigation requires Ctrl — bare n/p are query characters (regression fix: bare n/p
168
+ // used to hijack typing, making it impossible to type those letters into the query)
169
+ if ((key.ctrl && key.name === "n") || (key.ctrl && key.name === "g")) {
170
+ // Next match
171
+ if (state.search.matches.length > 0) {
172
+ state.search.index = (state.search.index + 1) % state.search.matches.length
173
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
174
+ }
175
+ render()
176
+ return
177
+ }
178
+ if ((key.ctrl && key.name === "p") || (key.ctrl && key.name === "r")) {
179
+ // Previous match
180
+ if (state.search.matches.length > 0) {
181
+ state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
182
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
183
+ }
184
+ render()
185
+ return
186
+ }
187
+ if (key.name === "return") {
188
+ // Exit search mode but keep highlighting until next Ctrl+F
189
+ state.search = null
190
+ render()
191
+ return
192
+ }
193
+ if (key.name === "backspace") {
194
+ if (state.search.query.length > 0) {
195
+ state.search.query = state.search.query.slice(0, -1)
196
+ performSearch(state)
197
+ render()
198
+ }
199
+ return
200
+ }
201
+ // Regular character input
202
+ if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
203
+ state.search.query += str
204
+ performSearch(state)
205
+ if (state.search.matches.length > 0) {
206
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
207
+ }
208
+ render()
209
+ return
210
+ }
211
+ // Swallow every other key (arrows/Tab/Delete/…) — without this they fall through
212
+ // to normal input handling and edit the HIDDEN state.input instead of the search box
213
+ return
214
+ }
215
+
216
+ if (key.ctrl && key.name === "c") {
106
217
  // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
107
218
  if (state.picker) {
108
219
  popPicker(null)
@@ -114,12 +225,41 @@ export function createKeyHandler(ctx) {
114
225
  render()
115
226
  return
116
227
  }
228
+ // 防误触:空闲态第一次 Ctrl+C 仅提示并武装,窗口内再按才真正退出
229
+ if (!state.exitArmed) {
230
+ state.exitArmed = true
231
+ if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
232
+ ctx.exitArmTimer = setTimeout(() => { state.exitArmed = false }, ctx.exitArmDelay ?? 3000)
233
+ ctx.exitArmTimer.unref?.()
234
+ pushLine("[exit] Press Ctrl+C again within 3s to exit", C.warn)
235
+ render()
236
+ return
237
+ }
238
+ if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
117
239
  cleanup()
118
240
  // 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
119
241
  ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
120
242
  ctx.exitTimer.unref?.()
121
243
  }
122
244
 
245
+ // F1: 显示快捷键帮助
246
+ if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
247
+ showPicker("Keyboard Shortcuts", [
248
+ { type: "item", text: "Ctrl+C — Cancel/Abort; idle: press twice to exit" },
249
+ { type: "item", text: "Ctrl+I — Interrupt and inject message" },
250
+ { type: "item", text: "Ctrl+F — Search conversation history" },
251
+ { type: "item", text: "Shift+Enter / Ctrl+J — Insert newline (multiline input)" },
252
+ { type: "item", text: "Alt+V — Paste clipboard image" },
253
+ { type: "item", text: "Ctrl+U — Clear input line" },
254
+ { type: "item", text: "Esc — Cancel current input/picker" },
255
+ { type: "item", text: "↑/↓ — Navigate input history" },
256
+ { type: "item", text: "PgUp/PgDn — Scroll conversation" },
257
+ { type: "item", text: "Enter — Send message or confirm selection" },
258
+ { type: "item", text: "F1 — Show this help" },
259
+ ])
260
+ return
261
+ }
262
+
123
263
  // Ctrl+I (or Tab during processing): interrupt and inject a message
124
264
  if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
125
265
  if (state.processing && state.controller && !state.interruptPrompt) {
@@ -274,6 +414,11 @@ export function createKeyHandler(ctx) {
274
414
  // input history
275
415
  if (key.name === "up") {
276
416
  if (state.history.length) {
417
+ // Draft protection: entering history navigation with unsent input stashes it,
418
+ // so navigating back down past the newest entry restores what was being typed.
419
+ if (state.historyIndex === -1 && state.input.length > 0) {
420
+ state._draft = [...state.input]
421
+ }
277
422
  state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
278
423
  state.input = [...state.history[state.historyIndex]]
279
424
  state.cursor = state.input.length
@@ -286,7 +431,9 @@ export function createKeyHandler(ctx) {
286
431
  state.historyIndex++
287
432
  if (state.historyIndex >= state.history.length) {
288
433
  state.historyIndex = -1
289
- state.input = []
434
+ // Restore the stashed draft instead of wiping back to blank
435
+ state.input = state._draft ? [...state._draft] : []
436
+ state._draft = null
290
437
  } else {
291
438
  state.input = [...state.history[state.historyIndex]]
292
439
  }
@@ -335,12 +482,28 @@ export function createKeyHandler(ctx) {
335
482
  return
336
483
  }
337
484
  if (key.name === "return" || key.name === "enter" || str === "\r") {
338
- submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
485
+ // Multiline newline — three entry points (docs/design/TUI-INPUT-BOX.md §1.5):
486
+ // 1. Alt+Enter: readline parses \x1b\r as meta+return (all terminals)
487
+ // 2. Shift+Enter: keyboard-enhanced terminals send \x1b[13;2u / \x1b[27;2;13~,
488
+ // translateShiftEnter maps them to \x1b\r → also meta+return
489
+ // 3. Ctrl+J: sends \n (0x0A), readline parses as name:"enter" — the universal
490
+ // fallback: \n and \r are distinct bytes in EVERY terminal, no protocol needed
491
+ // (legacy conhost users: Shift+Enter is a bare \r there, physically
492
+ // indistinguishable from Enter — Ctrl+J is their newline key)
493
+ if ((key.name === "return" && key.meta) || key.name === "enter") {
494
+ state.input.splice(state.cursor, 0, "\n")
495
+ state.cursor++
496
+ render()
497
+ } else {
498
+ submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
499
+ }
339
500
  return
340
501
  }
341
502
 
342
- // Ctrl+V: paste clipboard text into the active text target
343
- if (key.ctrl && !key.alt && key.name === "v") {
503
+ // Ctrl+V: paste clipboard text into the active text target.
504
+ // Exclude meta (ESC-prefix) so Ctrl+Alt+V which readline reports as ctrl+meta —
505
+ // falls through to the image-paste branch below instead of being eaten here.
506
+ if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
344
507
  ;(async () => {
345
508
  const text = await readClipboardText()
346
509
  if (text) {
@@ -351,8 +514,13 @@ export function createKeyHandler(ctx) {
351
514
  return
352
515
  }
353
516
 
354
- // Ctrl+Alt+V (Windows) / Alt+V: paste clipboard image
355
- const isPasteImage = key.name === "v" && key.alt
517
+ // Alt+V / Ctrl+Alt+V: paste clipboard image.
518
+ // NOTE: readline reports ESC-prefixed combos as key.meta, NOT key.alt (probe-verified:
519
+ // \x1b + char → { meta: true, alt: false }). Checking key.alt alone is a dead branch —
520
+ // the key fell through to the printable handler which ignores meta keys, so image paste
521
+ // silently did nothing. Accept meta (and alt, for terminals that set it); the text-paste
522
+ // branch above excludes meta so Ctrl+Alt+V reaches here.
523
+ const isPasteImage = key.name === "v" && (key.alt || key.meta)
356
524
  if (isPasteImage) {
357
525
  pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
358
526
  return
@@ -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 = []
@@ -0,0 +1,52 @@
1
+ /**
2
+ * markdown.mjs — lightweight inline markdown rendering for the TUI display layer.
3
+ *
4
+ * Zero dependencies, display-only: turns raw markdown markers into ANSI styling so
5
+ * model replies stop showing literal `**`, `##`, backtick markers (IK5VW3).
6
+ *
7
+ * Design constraints:
8
+ * - Operates on ALREADY-WRAPPED single lines (call after wrapText): inserting ANSI
9
+ * here cannot break width math, because wrapping already happened.
10
+ * - Uses narrow-scope SGR resets (22 = bold off, 27 = reverse off, 29 = strikethrough off)
11
+ * instead of reset(0), so the line's base color (C.text etc.) survives.
12
+ * - Code spans are extracted FIRST: anything inside backticks is styled as code and
13
+ * its `**`/`__` markers are NOT interpreted (markdown semantics).
14
+ * - Unclosed markers are left as-is (streaming safety: mid-token `**bo` renders literally).
15
+ */
16
+
17
+ const BOLD = "\x1b[1m"
18
+ const BOLD_OFF = "\x1b[22m"
19
+ const REVERSE = "\x1b[7m"
20
+ const REVERSE_OFF = "\x1b[27m"
21
+ const STRIKE = "\x1b[9m"
22
+ const STRIKE_OFF = "\x1b[29m"
23
+
24
+ /** Render inline markers on a single text line: `code` spans, **bold**, __bold__, ~~strike~~. */
25
+ export function renderMarkdownInline(line) {
26
+ if (!line || line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("_") === -1 && line.indexOf("~") === -1) {
27
+ return line
28
+ }
29
+
30
+ // Split on backticks: even indexes are plain text (bold/strike processed),
31
+ // odd indexes are code spans (styled as-is, markers inside untouched).
32
+ const parts = line.split("`")
33
+ let out = ""
34
+ for (let i = 0; i < parts.length; i++) {
35
+ if (i % 2 === 1) {
36
+ out += REVERSE + parts[i] + REVERSE_OFF
37
+ } else {
38
+ out += parts[i]
39
+ .replace(/\*\*([^*\n]+)\*\*/g, `${BOLD}$1${BOLD_OFF}`)
40
+ .replace(/__([^_\n]+)__/g, `${BOLD}$1${BOLD_OFF}`)
41
+ .replace(/~~([^~\n]+)~~/g, `${STRIKE}$1${STRIKE_OFF}`)
42
+ }
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** Render a heading line: strip leading `#` markers and bold the whole line. Returns original when not a heading. */
48
+ export function renderMarkdownHeading(line) {
49
+ const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line)
50
+ if (!m || !m[2]) return line
51
+ return `${BOLD}${m[2]}${BOLD_OFF}`
52
+ }