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 +22 -0
- package/package.json +1 -1
- package/src/agent/helpers.mjs +1 -1
- package/src/agent/setup.mjs +12 -0
- package/src/prompts/discipline.md +12 -5
- package/src/provider/core.mjs +53 -7
- package/src/tools/checklist.md +7 -0
- package/src/tools/checklist.mjs +114 -0
- package/src/tools/index.mjs +2 -0
- package/src/tui/clipboard.mjs +38 -0
- package/src/tui/index.mjs +7 -10
- package/src/tui/key-handler.mjs +28 -2
- package/src/tui/render-frame.mjs +6 -5
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
package/src/agent/helpers.mjs
CHANGED
|
@@ -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"
|
package/src/agent/setup.mjs
CHANGED
|
@@ -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,
|
|
36
|
-
- Read the FULL error output
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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.
|
package/src/provider/core.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
package/src/tools/index.mjs
CHANGED
|
@@ -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 {
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
112
|
+
const pasted = pasteAccum
|
|
112
113
|
pasteAccum = ""
|
|
113
114
|
if (pasted) {
|
|
114
|
-
|
|
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)
|
|
133
|
+
const pasted = after.slice(0, endIdx)
|
|
135
134
|
if (pasted) {
|
|
136
|
-
|
|
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)
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -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
|
|
260
|
-
|
|
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
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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 "
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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)))}╮`
|