thincoder 0.8.9 → 0.8.11

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 CHANGED
@@ -205,6 +205,28 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
205
205
 
206
206
  ## Changelog
207
207
 
208
+ ### 0.8.11 (2026-07)
209
+ - **Feat**: `checklist` tool — persistent project task tracking in `.thincoder/checklist.md`. Add/mark/list items, auto-archive done items to `checklist-done.md`. Injected at session start (pending + in_progress only)
210
+ - **Feat**: updated prompts — four-step workflow (requirements→design→development→testing), three-step debugging strategy (logs→docs→binary search), working checklist discipline
211
+ - **Feat**: methodology docs — `docs/design/METHODOLOGY.md` rebuilt, `PHILOSOPHY.md` expanded with worldview #6 (official docs over guessing)
212
+
213
+ ### 0.8.10 (2026-07)
214
+ - **Bugfix**: pasted text now lands in the active TUI text target — the API key prompt when adding a provider via `/model` (and any free-text `askQuestion`) now accepts paste correctly. Previously, bracketed-paste injection in the terminal was always written to the main input box, so pasting into a question prompt appeared as "nothing happened" and orphaned the text into the input box after the question closed. Both bracketed-paste (Windows Terminal / most modern terminals) and Ctrl+V-as-key-event (legacy conhost) now route through a single `insertPastedText` helper that targets the question answer, options-list (ignored), or main input box as appropriate
215
+
216
+ ### 0.8.9 (2026-07)
217
+ - **Bugfix**: `wizardProviderItems` was defined inside `createWizard()` but not included in the return statement, causing key-handler to crash with `TypeError` during provider selection on first launch (/ new config)
218
+
219
+ ### 0.8.8 (2026-07)
220
+ - **Bugfix**: remove env whitelist from bash tool — child processes receive full parent environment
221
+ - **Bugfix**: reduce TUI flicker — single `write` with `home` + `clearToEnd` instead of separate cursor moves
222
+ - **Feat**: auto update check on startup + `/upgrade` command
223
+
224
+ ### 0.8.3 (2026-07)
225
+ - **Output panels**: tools with `outputPanel` flag stream to scrolled panel, auto-collapse to summary on completion (bash, long tool results)
226
+ - **Checkpoint enhancements**: `cat` for file preview from snapshots, per-file rewind, auto-recover on apply failure, escape-hatch hints on errors
227
+ - **Bash safety**: `checkpoint-before-destructive` discipline rule; bash guard guides checkpoint instead of just commit/stash
228
+ - **Code review fixes**: output friendliness, readability, English-only strings, TUI polish
229
+
208
230
  ### 0.8.0 (2026-07)
209
231
  - **TUI rendering overhaul**: layout engine (`layout.mjs`) — panels are positioned declaratively by priority instead of hand-pinned arithmetic; `renderFrame` is now a pure function (no state mutation); cursor position derived from layout coordinates. Status bar slash-command hints moved from if-else chain to lookup table. Three Chinese UI strings fixed to English
210
232
  - **Subagent panel redesigned**: per-instance tracking (`role#id/` prefix) — parallel subagents of the same role no longer overwrite each other. Shows current tool name + args, or streaming text last line (what it's writing right now), instead of truncated 200-char token fragments. `onToolCall` relayed from subagent to parent TUI. Only the earliest running subagent is marked done on completion (not all)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -2,7 +2,7 @@
2
2
  * agent/helpers.mjs — Agent utility functions and constants
3
3
  */
4
4
  import { configDir } from "../config.mjs"
5
- import { readFileSync, readdirSync } from "node:fs"
5
+ import { readFileSync, readdirSync, existsSync } from "node:fs"
6
6
  import { writeFile, mkdir } from "node:fs/promises"
7
7
  import { join } from "node:path"
8
8
  import { execSync } from "node:child_process"
@@ -92,6 +92,18 @@ export async function prepareRun(agent, input, callbacks, {
92
92
  content: `[System reminder: current time is ${new Date().toISOString()}.]`,
93
93
  transient: true,
94
94
  })
95
+ // Checklist injection: inject pending + in_progress items from .thincoder/checklist.md
96
+ try {
97
+ const { pendingItems } = await import("../tools/checklist.mjs")
98
+ const items = pendingItems(agent.cwd)
99
+ if (items.length > 0) {
100
+ agent.history.push({
101
+ role: "user",
102
+ content: `[System reminder: task checklist (pending/in-progress):\n${items.map(i => `- [${i.status === "in_progress" ? "~" : " "}] ${i.text}`).join("\n")}]`,
103
+ transient: true,
104
+ })
105
+ }
106
+ } catch { /* checklist not available — suppress error */ }
95
107
  }
96
108
  agent.history.push({ role: "user", content: input })
97
109
  }
@@ -1,4 +1,10 @@
1
1
  Coding discipline (rigor over speed—tokens spent on verification are well spent):
2
+
3
+ **Workflow — never skip steps:**
4
+ - Before writing code: 1) Requirements — clarify what's needed, write user stories, confirm. 2) Design — plan architecture and approach, write a design doc. 3) Development — write code. 4) Testing — write test cases covering normal, boundary, and error cases. Documents are required for steps 1, 2, and 4. Requirements are not complete until checklist entries exist for every requirement point — use `checklist add` to create them. Skipping straight to step 3 is wrong ten times out of nine.
5
+ - Maintain a visible checklist for every task in `.thincoder/checklist.md` using the `checklist` tool. Checklist entries map to requirement/design points from the project's docs — project-level tracking. After requirements are confirmed, add one entry per requirement point. When you start working on an item, mark it in_progress. When it's verified complete, mark it done. Do not rely on context memory — context compresses, the checklist persists.
6
+
7
+ **Coding rules:**
2
8
  - **Prefer built-in tools over bash for file operations**: use `ls` (not `bash ls`), `glob` (not `bash find`), `grep` (not `bash grep`). The bash tool runs the system shell — on Windows this is cmd.exe without Unix commands; on Unix it may have them but built-in tools are more reliable and platform-consistent.
3
9
  - Spec before code: when the user describes a feature request without specifying the details (retry count? timeout? which error types? which files?), ask clarifying questions before writing code.
4
10
  - Design docs are the spec: when the project has design documents (check with `doc_search`), read them before implementing. Their decisions represent intentional architecture — don't override them with personal habit or guesswork.
@@ -32,9 +38,10 @@ Testing discipline (right check at the right time — don't run the full suite f
32
38
  - If verify reports syntax errors or test failures, fix them before claiming completion — never mark work done with known failures
33
39
  - When you change behavior or add code, add at least one test that covers the change. If the project has no test suite yet, note that in your report. Never skip this step — untested code is incomplete code.
34
40
 
35
- Debugging strategy (when something goes wrong, diagnose before treating):
36
- - Read the FULL error output the root cause is often at the end, not the first line
37
- - Don't change multiple things at once hoping one works that destroys the signal
38
- - Narrow down systematically: reproduce the failure in isolation, read the file you just wrote to confirm it matches your intent, trace the control flow with grep or code_search, then fix ONE thing and re-run
39
- - If the error message is unclear, search the web for it before guessing at a fix
41
+ Debugging strategy (when something goes wrong, three steps before anything else):
42
+ - Step 1 — **Read logs**: read the FULL error output. The root cause is often at the end, not the first line. Don't skip, don't guess.
43
+ - Step 2 **Check docs**: if the error message is unclear, search official docs (websearch/fetch) before guessing at a fix. Don't build theories in isolation.
44
+ - Step 3 **Binary search**: cut the problem space in half, test which half contains the fault, repeat. Don't try to find the answer in one jump.
45
+ - After the three steps: reproduce the failure in isolation, fix ONE thing, re-run. Don't change multiple things at once — that destroys the signal.
46
+ - Don't get stuck reading code for long stretches. What you can't understand by reading, understand by running: write a test, add a log, use binary search. Acting beats staring.
40
47
  - Distinguish root causes from proximate causes: if your own behavior was wrong, ask what caused it — did the prompt mislead you? is there a contradiction in the rules? was a tool description ambiguous? Fix the system, not just the symptom.
@@ -155,7 +155,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
155
155
 
156
156
  const text = await response.text().catch(() => "")
157
157
  const message = `LLM API error ${response.status}: ${text}`
158
- if (isQuotaError(text)) throw new Error(message)
158
+ if (isNonRetryableError(response.status, text)) throw new Error(message)
159
159
  if (response.status === 429) {
160
160
  const retryAfter = Number(response.headers.get("retry-after"))
161
161
  const waitMs =
@@ -179,19 +179,39 @@ async function requestWithRetry(provider, body, signal, onWait) {
179
179
  throw lastError
180
180
  }
181
181
 
182
- function isQuotaError(text) {
183
- try {
184
- const type = JSON.parse(text)?.error?.type
185
- return typeof type === "string" && type.includes("quota")
186
- } catch {
187
- return false
182
+ /**
183
+ * Detect errors that should NOT be retried — quota, billing, auth, invalid params.
184
+ * Different providers use wildly different error formats. Check body text for known patterns.
185
+ */
186
+ function isNonRetryableError(status, text) {
187
+ // Auth errors: never retry
188
+ if (status === 401 || status === 403) return true
189
+ // 400-level non-429: usually invalid params
190
+ if (status >= 400 && status < 500 && status !== 429) return true
191
+ // For 429, check if it's actually a billing/quota error (not rate limit)
192
+ if (status === 429) {
193
+ const lower = text.toLowerCase()
194
+ // Chinese providers often return 429 for billing issues
195
+ if (lower.includes("余额不足") || lower.includes("余额") || lower.includes("充值")) return true
196
+ if (lower.includes("insufficient") && (lower.includes("balance") || lower.includes("quota") || lower.includes("credit"))) return true
197
+ if (lower.includes("quota") && (lower.includes("exceeded") || lower.includes("insufficient"))) return true
198
+ // Standard OpenAI billing error (error.type === "insufficient_quota" or similar)
199
+ try {
200
+ const j = JSON.parse(text)
201
+ const errType = j?.error?.type || ""
202
+ if (typeof errType === "string" && (errType.includes("quota") || errType.includes("billing") || errType.includes("insufficient") || errType.includes("balance"))) return true
203
+ const errCode = j?.error?.code || ""
204
+ if (typeof errCode === "string" && (errCode === "1113" || errCode === "1114")) return true // GLM billing codes
205
+ } catch {}
188
206
  }
207
+ return false
189
208
  }
190
209
 
191
210
  async function readSSE(response, { onToken, onReasoning }) {
192
211
  const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
193
212
  const decoder = new TextDecoder()
194
213
  let buffer = ""
214
+ let hasChoices = false
195
215
 
196
216
  const processLines = (lines) => {
197
217
  for (const line of lines) {
@@ -205,6 +225,7 @@ async function readSSE(response, { onToken, onReasoning }) {
205
225
  if (json.usage) result.usage = json.usage
206
226
  const choice = json.choices?.[0]
207
227
  if (!choice) continue
228
+ hasChoices = true
208
229
  if (choice.finish_reason) result.finishReason = choice.finish_reason
209
230
 
210
231
  const delta = choice.delta ?? {}
@@ -234,6 +255,31 @@ async function readSSE(response, { onToken, onReasoning }) {
234
255
  }
235
256
  buffer += decoder.decode()
236
257
  processLines(buffer.split("\n"))
258
+
259
+ // If no SSE choices were found, the response is likely a JSON error
260
+ if (!hasChoices) {
261
+ const contentType = response.headers.get("content-type") || ""
262
+ let errorMsg = ""
263
+ try {
264
+ const raw = buffer.trim() || ""
265
+ if (raw) {
266
+ const parsed = JSON.parse(raw)
267
+ errorMsg = parsed?.error?.message
268
+ || parsed?.base_resp?.status_msg
269
+ || parsed?.detail
270
+ || parsed?.message
271
+ || parsed?.msg
272
+ || (typeof parsed.error === "string" ? parsed.error : "")
273
+ }
274
+ } catch { /* not JSON */ }
275
+ if (!errorMsg && !contentType.includes("event-stream")) {
276
+ errorMsg = `Response is not SSE (Content-Type: ${contentType || "unknown"})`
277
+ }
278
+ if (errorMsg) {
279
+ throw new Error(`API error: ${errorMsg}`)
280
+ }
281
+ }
282
+
237
283
  return result
238
284
  }
239
285
 
@@ -0,0 +1,7 @@
1
+ Manage the task checklist in .thincoder/checklist.md. Use at these points: after requirements are confirmed — add one entry per requirement point; when starting work — mark in_progress; when verified complete — mark done. Checklist entries map to requirement/design points — project-level tracking across sessions. For in-session subtask breakdown of a single checklist item, use the `task` tool instead. Completed items are auto-archived to .thincoder/checklist-done.md.
2
+
3
+ Parameters:
4
+ - action: "add" | "mark" | "list"
5
+ - item: text for new item (with "add")
6
+ - index: 1-based index (with "mark")
7
+ - status: "pending" | "in_progress" | "done" (with "mark")
@@ -0,0 +1,114 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+ import { DESC } from "./shared.mjs"
4
+
5
+ const CHECKLIST = "checklist.md"
6
+ const DONE = "checklist-done.md"
7
+
8
+ function checklistPath(cwd) { return join(cwd, ".thincoder", CHECKLIST) }
9
+ function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
10
+
11
+ /** Parse checklist file into array of { index, status, text } */
12
+ function parse(filePath) {
13
+ if (!existsSync(filePath)) return []
14
+ const lines = readFileSync(filePath, "utf-8").split("\n")
15
+ const items = []
16
+ let idx = 0
17
+ for (const line of lines) {
18
+ const m = line.match(/^- \[(.)\] (.+)$/)
19
+ if (m) {
20
+ idx++
21
+ const raw = m[1]
22
+ const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
23
+ items.push({ index: idx, status, text: m[2].trim() })
24
+ }
25
+ }
26
+ return items
27
+ }
28
+
29
+ /** Write items back to file */
30
+ function write(filePath, items) {
31
+ mkdirSync(dirname(filePath), { recursive: true })
32
+ const lines = []
33
+ for (const item of items) {
34
+ const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
35
+ lines.push(`- [${mark}] ${item.text}`)
36
+ }
37
+ writeFileSync(filePath, lines.join("\n") + "\n")
38
+ }
39
+
40
+ /** Parse pending items only (for context injection) */
41
+ export function pendingItems(cwd) {
42
+ return parse(checklistPath(cwd)).filter(i => i.status !== "done")
43
+ }
44
+
45
+ export const checklistTool = {
46
+ name: "checklist",
47
+ description: DESC("checklist"),
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ action: {
52
+ type: "string",
53
+ enum: ["add", "mark", "list"],
54
+ description: "add a new item / mark item status / list all items"
55
+ },
56
+ item: {
57
+ type: "string",
58
+ description: "Item text (required for add)"
59
+ },
60
+ index: {
61
+ type: "number",
62
+ description: "1-based item index (required for mark)"
63
+ },
64
+ status: {
65
+ type: "string",
66
+ enum: ["pending", "in_progress", "done"],
67
+ description: "New status (required for mark)"
68
+ },
69
+ },
70
+ required: ["action"],
71
+ },
72
+ readonly: false,
73
+ execute(args, ctx) {
74
+ switch (args.action) {
75
+ case "add": {
76
+ if (!args.item || typeof args.item !== "string") return "Error: 'item' is required for add"
77
+ const items = parse(checklistPath(ctx.cwd))
78
+ items.push({ index: items.length + 1, status: "pending", text: args.item })
79
+ write(checklistPath(ctx.cwd), items)
80
+ return `Added: [ ] ${args.item}`
81
+ }
82
+ case "mark": {
83
+ if (args.index == null) return "Error: 'index' is required for mark"
84
+ const status = args.status
85
+ if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
86
+ const cp = checklistPath(ctx.cwd)
87
+ const items = parse(cp)
88
+ if (args.index < 1 || args.index > items.length) return `Error: index ${args.index} out of range (1-${items.length})`
89
+ const item = items[args.index - 1]
90
+ const old = item.status
91
+ if (old === status) return `Already ${status}: ${item.text}`
92
+ item.status = status
93
+ if (status === "done") {
94
+ // Move to done file
95
+ const dp = donePath(ctx.cwd)
96
+ const doneItems = parse(dp)
97
+ doneItems.push(item)
98
+ write(dp, doneItems)
99
+ items.splice(args.index - 1, 1)
100
+ }
101
+ write(cp, items)
102
+ return `Marked #${args.index} ${old} → ${status}: ${item.text}`
103
+ }
104
+ case "list": {
105
+ const items = parse(checklistPath(ctx.cwd))
106
+ if (items.length === 0) return "(checklist is empty)"
107
+ const marks = { pending: " ", in_progress: "~", done: "x" }
108
+ return items.map(i => `- [${marks[i.status]}] ${i.text}`).join("\n")
109
+ }
110
+ default:
111
+ return `Error: unknown action '${args.action}'`
112
+ }
113
+ },
114
+ }
@@ -6,12 +6,14 @@ import { applyPatchTool, syntaxCheckTool, deleteTool } from "./patch.mjs";
6
6
  import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
7
7
  import { websearchTool, fetchTool } from "./web.mjs";
8
8
  import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
9
+ import { checklistTool } from "./checklist.mjs";
9
10
 
10
11
  export const builtinTools = [
11
12
  readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
12
13
  syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
13
14
  websearchTool, lsTool, fetchTool, deleteTool,
14
15
  gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
16
+ checklistTool,
15
17
  ];
16
18
 
17
19
  export {
@@ -1,5 +1,43 @@
1
1
  import { C } from "./ansi.mjs"
2
2
 
3
+ /** Read text from system clipboard. Returns empty string on failure. */
4
+ export async function readClipboardText() {
5
+ try {
6
+ const { execFile } = await import("node:child_process")
7
+ const isWin = process.platform === "win32"
8
+ const isMac = process.platform === "darwin"
9
+ if (isWin) {
10
+ return await new Promise((resolve) => execFile("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
11
+ } else if (isMac) {
12
+ return await new Promise((resolve) => execFile("pbpaste", [], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
13
+ } else {
14
+ return await new Promise((resolve) => execFile("xclip", ["-selection", "clipboard", "-o"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
15
+ }
16
+ } catch {
17
+ return ""
18
+ }
19
+ }
20
+
21
+ /** Insert pasted text into the active text target.
22
+ * Free-text question active → append to its answer (single-line field: newlines stripped).
23
+ * Options question active → ignore (no text field; must not leak into the input box).
24
+ * Otherwise → splice into the main input box at cursor (newlines kept, tabs → 2 spaces).
25
+ * Shared by bracketed paste (stdin data handler) and Ctrl+V clipboard read,
26
+ * so pasted content lands in the same place regardless of how the terminal delivered it. */
27
+ export function insertPastedText(state, rawText) {
28
+ if (!rawText) return
29
+ const q = state.question
30
+ if (q) {
31
+ if (q.options.length > 0) return
32
+ q.answer = (q.answer ?? "") + rawText.replace(/[\r\n]+/g, "")
33
+ return
34
+ }
35
+ const text = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ")
36
+ const chars = [...text]
37
+ state.input.splice(state.cursor, 0, ...chars)
38
+ state.cursor += chars.length
39
+ }
40
+
3
41
  /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
4
42
  * Extracted from index.mjs.
5
43
  * ctx: { agent, state, pushLine, render } */
package/src/tui/index.mjs CHANGED
@@ -29,7 +29,7 @@ import { createWizard } from "./wizard.mjs"
29
29
  import { createPickers } from "./pickers.mjs"
30
30
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
31
31
  import { createInteraction } from "./interaction.mjs"
32
- import { pasteClipboardImage as pasteClipboardImageImpl } from "./clipboard.mjs"
32
+ import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText } from "./clipboard.mjs"
33
33
  import { runAgentTurn } from "./agent-turn.mjs"
34
34
  import { createKeyHandler } from "./key-handler.mjs"
35
35
  import { showStartup, backgroundIndex } from "./startup.mjs"
@@ -102,18 +102,17 @@ export async function startTUI(agent, opts = {}) {
102
102
  mousePending = ""
103
103
 
104
104
  // Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
105
- // Insert pasted content directly into state.input to avoid slow char-by-char keypress render
105
+ // Route pasted content to the active text target (question answer / input box) in one shot,
106
+ // avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
106
107
  if (pasteMode) {
107
108
  const endIdx = text.indexOf("\x1b[201~")
108
109
  if (endIdx >= 0) {
109
110
  pasteAccum += text.slice(0, endIdx)
110
111
  pasteMode = false
111
- const pasted = pasteAccum.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ")
112
+ const pasted = pasteAccum
112
113
  pasteAccum = ""
113
114
  if (pasted) {
114
- const chars = [...pasted]
115
- state.input.splice(state.cursor, 0, ...chars)
116
- state.cursor += chars.length
115
+ insertPastedText(state, pasted)
117
116
  render()
118
117
  }
119
118
  text = text.slice(endIdx + 6)
@@ -131,11 +130,9 @@ export async function startTUI(agent, opts = {}) {
131
130
  const endIdx = after.indexOf("\x1b[201~")
132
131
  if (endIdx >= 0) {
133
132
  // Paste begin and end in the same chunk: insert pasted content directly
134
- const pasted = after.slice(0, endIdx).replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ")
133
+ const pasted = after.slice(0, endIdx)
135
134
  if (pasted) {
136
- const chars = [...pasted]
137
- state.input.splice(state.cursor, 0, ...chars)
138
- state.cursor += chars.length
135
+ insertPastedText(state, pasted)
139
136
  render()
140
137
  }
141
138
  text = before + after.slice(endIdx + 6)
@@ -1,4 +1,5 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
+ import { readClipboardText, insertPastedText } from "./clipboard.mjs"
2
3
 
3
4
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
4
5
  * Extracted from index.mjs.
@@ -69,6 +70,7 @@ export function createKeyHandler(ctx) {
69
70
  state.status = "Processing..."
70
71
  render()
71
72
  } else if (key.name === "return") {
73
+ if (q._pasting) return // block Enter while paste is in flight
72
74
  const answer = (q.answer ?? "").trim()
73
75
  q.resolve(answer || "")
74
76
  state.question = null
@@ -78,6 +80,18 @@ export function createKeyHandler(ctx) {
78
80
  } else if (key.name === "backspace") {
79
81
  q.answer = (q.answer ?? "").slice(0, -1)
80
82
  render()
83
+ } else if (key.ctrl && !key.alt && key.name === "v") {
84
+ // Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
85
+ // as a key event; bracketed-paste terminals are handled upstream in the stdin handler)
86
+ if (q._pasting) return
87
+ q._pasting = true
88
+ readClipboardText().then((text) => {
89
+ q._pasting = false
90
+ if (text) {
91
+ insertPastedText(state, text)
92
+ render()
93
+ }
94
+ }).catch(() => { q._pasting = false })
81
95
  } else if (str && !key.ctrl && !key.meta) {
82
96
  q.answer = (q.answer ?? "") + str
83
97
  render()
@@ -256,8 +270,20 @@ export function createKeyHandler(ctx) {
256
270
  return
257
271
  }
258
272
 
259
- // Ctrl+V (Unix) / Alt+V (Windows): paste clipboard image save temp file → insert read_image into input box
260
- const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
273
+ // Ctrl+V: paste clipboard text into the active text target
274
+ if (key.ctrl && !key.alt && key.name === "v") {
275
+ ;(async () => {
276
+ const text = await readClipboardText()
277
+ if (text) {
278
+ insertPastedText(state, text)
279
+ render()
280
+ }
281
+ })()
282
+ return
283
+ }
284
+
285
+ // Ctrl+Alt+V (Windows) / Alt+V: paste clipboard image
286
+ const isPasteImage = key.name === "v" && key.alt
261
287
  if (isPasteImage) {
262
288
  pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
263
289
  return
@@ -151,11 +151,12 @@ export function renderFrame(state, agent, opts) {
151
151
  // ---- input box ----
152
152
  const { borderColor, title } = inputBoxStyle(state)
153
153
  let topBorder
154
- if (title === " Input " && isMultimodal) {
155
- const hint = platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
156
- topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
157
- } else if (title === " Input ") {
158
- const hint = " Ctrl+U clear "
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("")
159
160
  topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
160
161
  } else {
161
162
  topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`