thincoder 0.12.3 → 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.
@@ -69,6 +69,8 @@ export function createKeyHandler(ctx) {
69
69
  state.status = "Processing..."
70
70
  if (answer === "a" && !isContinue) {
71
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.]")
72
74
  pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
73
75
  }
74
76
  const approved = answer === "y" || (answer === "a" && !isContinue)
@@ -147,7 +149,7 @@ export function createKeyHandler(ctx) {
147
149
  return
148
150
  }
149
151
 
150
- // Search mode: Ctrl+F to enter, Esc/n/N to navigate/exit
152
+ // Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
151
153
  if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
152
154
  if (!state.search) {
153
155
  state.search = { query: "", matches: [], index: 0 }
@@ -157,12 +159,14 @@ export function createKeyHandler(ctx) {
157
159
  }
158
160
 
159
161
  if (state.search) {
160
- if (key.name === "escape") {
162
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
161
163
  state.search = null
162
164
  render()
163
165
  return
164
166
  }
165
- if (key.name === "n" || (key.ctrl && key.name === "g")) {
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")) {
166
170
  // Next match
167
171
  if (state.search.matches.length > 0) {
168
172
  state.search.index = (state.search.index + 1) % state.search.matches.length
@@ -171,7 +175,7 @@ export function createKeyHandler(ctx) {
171
175
  render()
172
176
  return
173
177
  }
174
- if (key.name === "p" || (key.ctrl && key.name === "r")) {
178
+ if ((key.ctrl && key.name === "p") || (key.ctrl && key.name === "r")) {
175
179
  // Previous match
176
180
  if (state.search.matches.length > 0) {
177
181
  state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
@@ -204,6 +208,9 @@ export function createKeyHandler(ctx) {
204
208
  render()
205
209
  return
206
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
207
214
  }
208
215
 
209
216
  if (key.ctrl && key.name === "c") {
@@ -218,6 +225,17 @@ export function createKeyHandler(ctx) {
218
225
  render()
219
226
  return
220
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)
221
239
  cleanup()
222
240
  // 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
223
241
  ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
@@ -227,10 +245,11 @@ export function createKeyHandler(ctx) {
227
245
  // F1: 显示快捷键帮助
228
246
  if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
229
247
  showPicker("Keyboard Shortcuts", [
230
- { type: "item", text: "Ctrl+C — Cancel/Abort current operation" },
248
+ { type: "item", text: "Ctrl+C — Cancel/Abort; idle: press twice to exit" },
231
249
  { type: "item", text: "Ctrl+I — Interrupt and inject message" },
232
250
  { type: "item", text: "Ctrl+F — Search conversation history" },
233
- { type: "item", text: "Ctrl+LClear screen" },
251
+ { type: "item", text: "Shift+Enter / Ctrl+JInsert newline (multiline input)" },
252
+ { type: "item", text: "Alt+V — Paste clipboard image" },
234
253
  { type: "item", text: "Ctrl+U — Clear input line" },
235
254
  { type: "item", text: "Esc — Cancel current input/picker" },
236
255
  { type: "item", text: "↑/↓ — Navigate input history" },
@@ -395,6 +414,11 @@ export function createKeyHandler(ctx) {
395
414
  // input history
396
415
  if (key.name === "up") {
397
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
+ }
398
422
  state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
399
423
  state.input = [...state.history[state.historyIndex]]
400
424
  state.cursor = state.input.length
@@ -407,7 +431,9 @@ export function createKeyHandler(ctx) {
407
431
  state.historyIndex++
408
432
  if (state.historyIndex >= state.history.length) {
409
433
  state.historyIndex = -1
410
- state.input = []
434
+ // Restore the stashed draft instead of wiping back to blank
435
+ state.input = state._draft ? [...state._draft] : []
436
+ state._draft = null
411
437
  } else {
412
438
  state.input = [...state.history[state.historyIndex]]
413
439
  }
@@ -456,9 +482,16 @@ export function createKeyHandler(ctx) {
456
482
  return
457
483
  }
458
484
  if (key.name === "return" || key.name === "enter" || str === "\r") {
459
- if (key.shift) {
460
- // Shift+Enter: insert newline for multiline input
461
- state.input.splice(state.cursor, 0, '\n')
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")
462
495
  state.cursor++
463
496
  render()
464
497
  } else {
@@ -467,8 +500,10 @@ export function createKeyHandler(ctx) {
467
500
  return
468
501
  }
469
502
 
470
- // Ctrl+V: paste clipboard text into the active text target
471
- 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") {
472
507
  ;(async () => {
473
508
  const text = await readClipboardText()
474
509
  if (text) {
@@ -479,8 +514,13 @@ export function createKeyHandler(ctx) {
479
514
  return
480
515
  }
481
516
 
482
- // Ctrl+Alt+V (Windows) / Alt+V: paste clipboard image
483
- 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)
484
524
  if (isPasteImage) {
485
525
  pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
486
526
  return
@@ -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
+ }
@@ -335,7 +335,11 @@ export function createPickers(ctx) {
335
335
  if (!baseURL) return
336
336
  const model = await askQuestion("Enter model name:")
337
337
  if (!model) return
338
- agent.providers.push({ name, baseURL, model })
338
+ const format = (await askQuestion("API format [openai/anthropic/google] (default: openai):")).trim().toLowerCase()
339
+ const cfg = { name, baseURL, model }
340
+ if (format === "anthropic" || format === "google") cfg.format = format
341
+ else if (format && format !== "openai") return // unknown format → abort
342
+ agent.providers.push(cfg)
339
343
  await persistRaw((raw) => { raw.providers = agent.providers })
340
344
  const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
341
345
  if (key) await setProviderKey(name, key)
@@ -348,6 +352,7 @@ export function createPickers(ctx) {
348
352
  if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
349
353
  if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
350
354
  if (preset.chatPath) cfg.chatPath = preset.chatPath
355
+ if (preset.format) cfg.format = preset.format
351
356
  agent.providers.push(cfg)
352
357
  await persistRaw((raw) => { raw.providers = agent.providers })
353
358
  const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { ansi, C } from "./ansi.mjs"
6
6
  import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
7
+ import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
7
8
 
8
9
  let _convCache = { key: "", cols: 0, lines: [] }
9
10
 
@@ -51,7 +52,9 @@ function buildConvLines(state, cols) {
51
52
 
52
53
  for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
53
54
  for (const wrapped of wrapText(line, cols - 1)) {
54
- convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
55
+ // Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
56
+ // Runs AFTER wrapping so the ANSI it inserts never skews width math.
57
+ convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId })
55
58
  }
56
59
  }
57
60
  }
@@ -78,7 +81,7 @@ function buildConvLines(state, cols) {
78
81
  if (state.streaming) {
79
82
  for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
80
83
  for (const wrapped of wrapText(line, cols - 1)) {
81
- convLines.push({ text: wrapped, color: C.text })
84
+ convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: C.text })
82
85
  }
83
86
  }
84
87
  }
@@ -196,6 +196,7 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
196
196
  if (title === " Input " || title === " Processing... ") parts.push(" Ctrl+U clear ")
197
197
  if (title === " Question ") parts.push(" Enter submit ")
198
198
  if (title === " Inject Message ") parts.push(" Enter send, Esc cancel ")
199
+ parts.push(" Shift+Enter / Ctrl+J newline ")
199
200
  parts.push(" Ctrl+V paste ")
200
201
  parts.push(" Ctrl+I inject ")
201
202
  const hint = parts.join("")
@@ -409,5 +410,5 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
409
410
  const ctxHint = ctxPct > 0
410
411
  ? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
411
412
  const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
412
- return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
413
+ return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
413
414
  }
@@ -130,18 +130,23 @@ function renderTable(block, width) {
130
130
  return out
131
131
  }
132
132
 
133
- /** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width) */
133
+ /** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width).
134
+ * Every line carries a 2-column prefix so all content left-edges align: first line gets the
135
+ * `▸ ` prompt, continuation lines (wrap or explicit \n) get 2 spaces. Content width is
136
+ * `width - 2` on every line. A trailing \n flushes an empty line so the cursor's row exists —
137
+ * without it the box wouldn't grow for multiline input and the cursor row would be out of range. */
134
138
  export function layoutInput(chars, cursor, width) {
135
- const PROMPT = "\u25b8 "
139
+ const PROMPT = "\u25b8 " // first-line prefix (display width 2)
140
+ const CONT = " " // continuation prefix (width 2) — keeps left edge aligned
136
141
  const lines = []
137
142
  let cursorLine = 0
138
143
  let cursorCol = 0
139
144
  let cur = ""
140
145
  let col = 0
141
146
  let firstLine = true
142
- const avail = () => (firstLine ? width - 2 : width)
147
+ const avail = () => width - 2 // every line reserves 2 cols for its prefix
143
148
  const flush = () => {
144
- lines.push((firstLine ? PROMPT : "") + cur)
149
+ lines.push((firstLine ? PROMPT : CONT) + cur)
145
150
  firstLine = false
146
151
  cur = ""
147
152
  col = 0
@@ -153,19 +158,20 @@ export function layoutInput(chars, cursor, width) {
153
158
  if (col + w > avail()) flush()
154
159
  if (i === cursor) {
155
160
  cursorLine = lines.length
156
- cursorCol = (firstLine ? 2 : 0) + col
161
+ cursorCol = 2 + col
157
162
  }
158
163
  cur += ch
159
164
  col += w
160
165
  } else {
161
166
  if (i === cursor) {
162
167
  cursorLine = lines.length
163
- cursorCol = (firstLine ? 2 : 0) + col
168
+ cursorCol = 2 + col
164
169
  }
165
170
  if (ch === "\n") flush()
166
171
  }
167
172
  }
168
- if (cur || lines.length === 0) flush()
173
+ const endsWithNewline = chars.length > 0 && chars[chars.length - 1] === "\n"
174
+ if (cur || lines.length === 0 || endsWithNewline) flush()
169
175
  return { lines, cursorLine, cursorCol }
170
176
  }
171
177