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.
- package/README.md +11 -1
- package/package.json +3 -3
- package/src/agent/completion.mjs +22 -7
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +44 -13
- package/src/config.mjs +13 -1
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/system.md +1 -1
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +90 -74
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +13 -7
- package/src/tui/agent-turn.mjs +15 -0
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +2 -26
- package/src/tui/cmd-session.mjs +2 -2
- package/src/tui/index.mjs +13 -3
- package/src/tui/key-handler.mjs +54 -14
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +6 -1
- package/src/tui/render-conversation.mjs +5 -2
- package/src/tui/render-frame.mjs +2 -1
- package/src/tui/render.mjs +13 -7
package/src/tui/key-handler.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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+
|
|
251
|
+
{ type: "item", text: "Shift+Enter / Ctrl+J — Insert 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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
483
|
-
|
|
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
|
+
}
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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
|
}
|
package/src/tui/render.mjs
CHANGED
|
@@ -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 = () =>
|
|
147
|
+
const avail = () => width - 2 // every line reserves 2 cols for its prefix
|
|
143
148
|
const flush = () => {
|
|
144
|
-
lines.push((firstLine ? PROMPT :
|
|
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 =
|
|
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 =
|
|
168
|
+
cursorCol = 2 + col
|
|
164
169
|
}
|
|
165
170
|
if (ch === "\n") flush()
|
|
166
171
|
}
|
|
167
172
|
}
|
|
168
|
-
|
|
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
|
|