thincoder 0.12.13 → 0.12.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.13",
3
+ "version": "0.12.14",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -50,23 +50,14 @@ export function validateDesignToken(token) {
50
50
  return signature === expectedSig
51
51
  }
52
52
 
53
- /** Extract UUID from signed token for regex matching */
54
- function extractTokenUUID(token) {
55
- const parts = token.split(":")
56
- return parts.length >= 1 ? parts[0] : token
57
- }
58
-
59
- /** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net even though UUIDs contain only hex/hyphens.
60
- * Flexible matching: allows token to be on its own line, in a code block, or surrounded by whitespace.
61
- * Rejects partial matches by requiring word boundaries or brackets around the token. */
53
+ /** Build a [DESIGN-TOKEN:...] regex; escapes special chars as a safety net.
54
+ * Matches the FULL token (uuid:expiresAt:signature) — prompt tells advisor
55
+ * to echo the complete token verbatim, not just the UUID segment.
56
+ * Flexible matching: allows token to be on its own line, in a code block,
57
+ * or surrounded by whitespace. */
62
58
  const makeDesignTokenRegex = (token, flags = "") => {
63
- // Extract UUID from signed token (format: uuid:expiresAt:signature)
64
- const uuid = extractTokenUUID(token)
65
- const escaped = uuid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
66
- // Match [DESIGN-TOKEN: <uuid>] with flexible surrounding context:
67
- // - Allow leading/trailing whitespace and newlines
68
- // - Allow being inside code blocks (```...```)
69
- // - Require complete token (not truncated)
59
+ // Escape the entire token, not just UUID advisor echoes [DESIGN-TOKEN:uuid:expiresAt:signature]
60
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
70
61
  return new RegExp(
71
62
  `(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}\\s*\\](?:\\s|$|\`|\\*)`,
72
63
  flags + "ms"
@@ -165,7 +156,6 @@ export const advisorTool = {
165
156
  if (agent._role === "eng-coder") agent._engDesignReviewed = true
166
157
  // Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
167
158
  const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
168
- const tokenUUID = extractTokenUUID(designToken)
169
159
  return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
170
160
  }
171
161
  // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
@@ -47,6 +47,14 @@ export function translateShiftEnter(text) {
47
47
  return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
48
48
  }
49
49
 
50
+ /** Strip keyboard protocol CSI sequences that readline in raw mode does not recognize.
51
+ * Kitty CSI u: \x1b[key;modu — regular keys (e.g. Ctrl+C → \x1b[99;5u)
52
+ * modifyOtherKeys: \x1b[27;mod;key~ — function keys
53
+ * Call AFTER translateShiftEnter (which already handles Shift+Enter). */
54
+ export function stripKeyboardProtocol(text) {
55
+ return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
56
+ }
57
+
50
58
  /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
51
59
  * Extracted from index.mjs.
52
60
  * ctx: { agent, state, pushLine, render } */
package/src/tui/index.mjs CHANGED
@@ -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, translateShiftEnter } from "./clipboard.mjs"
30
+ import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
31
31
  import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
32
32
  import { runAgentTurn } from "./agent-turn.mjs"
33
33
  import { createKeyHandler } from "./key-handler.mjs"
@@ -201,6 +201,7 @@ export async function startTUI(agent, opts = {}) {
201
201
 
202
202
  // Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
203
203
  text = translateShiftEnter(text)
204
+ text = stripKeyboardProtocol(text)
204
205
 
205
206
  if (state.scroll !== lastRenderedScroll) {
206
207
  lastRenderedScroll = state.scroll
@@ -280,8 +281,10 @@ export async function startTUI(agent, opts = {}) {
280
281
  state.input = []
281
282
  state.cursor = 0
282
283
  state.history.push(text)
284
+ const wasInHistory = state.historyIndex !== -1
285
+ state.history.push(text)
283
286
  state.historyIndex = -1
284
- state._draft = null // submitted — the draft is now history
287
+ if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
285
288
  state.scroll = 0
286
289
 
287
290
  // Slash commands: handled locally, don't enter agent loop
@@ -8,12 +8,12 @@ export function createInteraction(ctx) {
8
8
 
9
9
  /** Key info for permission request (customized by tool), returns array of lines. name may have subagent prefix ("coder/bash"), use base name to match */
10
10
  function formatPermission(name, args) {
11
- const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
11
+ const cap = (s, n = 3000) => (s.length > n ? `${s.slice(0, n)}…(${s.length} chars total)` : s)
12
12
  const base = name.includes("/") ? name.split("/").pop() : name
13
13
  if (base === "bash") return cap(args.command ?? "").split("\n")
14
14
  if (base === "write") {
15
15
  // approving file writes must show what's being written: path + content preview
16
- return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
16
+ return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 3000).split("\n")]
17
17
  }
18
18
  if (base === "edit") {
19
19
  // simple diff: - old content / + new content
@@ -0,0 +1,113 @@
1
+ /** Perform search on state.lines, update state.search.matches and clamp index */
2
+ export function performSearch(state) {
3
+ if (!state.search) return
4
+ const query = state.search.query.toLowerCase()
5
+ state.search.matches = []
6
+ // Clear old highlights
7
+ state.lines.forEach(l => delete l._searchMatches)
8
+
9
+ if (!query) { state.search.index = 0; return }
10
+
11
+ state.lines.forEach((line, lineIndex) => {
12
+ const text = (line.text || "").toLowerCase()
13
+ let charIndex = text.indexOf(query)
14
+ if (charIndex !== -1) {
15
+ line._searchMatches = []
16
+ }
17
+ while (charIndex !== -1) {
18
+ state.search.matches.push({ lineIndex, charIndex })
19
+ line._searchMatches.push(charIndex)
20
+ charIndex = text.indexOf(query, charIndex + 1)
21
+ }
22
+ })
23
+
24
+ if (state.search.matches.length === 0) {
25
+ state.search.index = 0
26
+ } else {
27
+ state.search.index = Math.min(state.search.index, state.search.matches.length - 1)
28
+ }
29
+ }
30
+
31
+ /** Estimate scroll position to make a line visible (simplified) */
32
+ export function scrollToMatch(state, lineIndex) {
33
+ if (lineIndex == null) return
34
+ // Rough estimate: each state.line takes 1-2 rendered lines
35
+ let estimatedLine = 0
36
+ for (let i = 0; i < lineIndex && i < state.lines.length; i++) {
37
+ estimatedLine += Math.max(1, Math.ceil((state.lines[i].text?.length || 0) / 80))
38
+ }
39
+ const rows = process.stdout.rows || 24
40
+ const visibleH = Math.max(5, rows - 10) // reserve space for header, input, status, etc.
41
+ // scroll is number of lines hidden above viewport
42
+ // We want estimatedLine to be near the bottom of the viewport
43
+ state.scroll = Math.max(0, state.lines.length - estimatedLine - visibleH + 3)
44
+ }
45
+
46
+ /** Handle search-mode keyboard events.
47
+ * Returns true if the key was consumed (search mode handled it).
48
+ * Call before other handlers so search mode has priority. */
49
+ export function handleSearchKey(str, key, state, render) {
50
+ // Ctrl+F toggles search mode ON
51
+ if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
52
+ if (!state.search) {
53
+ state.search = { query: "", matches: [], index: 0 }
54
+ }
55
+ render()
56
+ return true
57
+ }
58
+
59
+ if (!state.search) return false
60
+
61
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
62
+ state.search = null
63
+ render()
64
+ return true
65
+ }
66
+ // Navigation requires Ctrl — bare n/p are query characters (regression fix: bare n/p
67
+ // used to hijack typing, making it impossible to type those letters into the query)
68
+ if ((key.ctrl && key.name === "n") || (key.ctrl && key.name === "g")) {
69
+ // Next match
70
+ if (state.search.matches.length > 0) {
71
+ state.search.index = (state.search.index + 1) % state.search.matches.length
72
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
73
+ }
74
+ render()
75
+ return true
76
+ }
77
+ if ((key.ctrl && key.name === "p") || (key.ctrl && key.name === "r")) {
78
+ // Previous match
79
+ if (state.search.matches.length > 0) {
80
+ state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
81
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
82
+ }
83
+ render()
84
+ return true
85
+ }
86
+ if (key.name === "return") {
87
+ // Exit search mode but keep highlighting until next Ctrl+F
88
+ state.search = null
89
+ render()
90
+ return true
91
+ }
92
+ if (key.name === "backspace") {
93
+ if (state.search.query.length > 0) {
94
+ state.search.query = state.search.query.slice(0, -1)
95
+ performSearch(state)
96
+ render()
97
+ }
98
+ return true
99
+ }
100
+ // Regular character input
101
+ if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
102
+ state.search.query += str
103
+ performSearch(state)
104
+ if (state.search.matches.length > 0) {
105
+ scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
106
+ }
107
+ render()
108
+ return true
109
+ }
110
+ // Swallow every other key (arrows/Tab/Delete/…) — without this they fall through
111
+ // to normal input handling and edit the HIDDEN state.input instead of the search box
112
+ return true
113
+ }
@@ -1,51 +1,7 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
3
  import { computeLayout } from "./layout.mjs"
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
- }
4
+ import { handleSearchKey } from "./key-handler-search.mjs"
49
5
 
50
6
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
51
7
  * Extracted from index.mjs.
@@ -150,68 +106,7 @@ export function createKeyHandler(ctx) {
150
106
  }
151
107
 
152
108
  // 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
- }
109
+ if (handleSearchKey(str, key, state, render)) return
215
110
 
216
111
  if (key.ctrl && key.name === "c") {
217
112
  // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
@@ -405,6 +300,7 @@ export function createKeyHandler(ctx) {
405
300
 
406
301
  // Ctrl+U: clear entire input box
407
302
  if ((key.name === "u" && key.ctrl) || str === "\x15") {
303
+ if (state.historyIndex !== -1) state._draft = [...state.input]
408
304
  state.input = []
409
305
  state.cursor = 0
410
306
  render()
@@ -414,9 +310,9 @@ export function createKeyHandler(ctx) {
414
310
  // input history
415
311
  if (key.name === "up") {
416
312
  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) {
313
+ // Save current input as draft when entering history mode (historyIndex === -1).
314
+ // Subsequent edits while in history mode are saved separately (see printable/editing handlers).
315
+ if (state.historyIndex === -1) {
420
316
  state._draft = [...state.input]
421
317
  }
422
318
  state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
@@ -470,6 +366,7 @@ export function createKeyHandler(ctx) {
470
366
  if (state.cursor > 0) {
471
367
  state.input.splice(state.cursor - 1, 1)
472
368
  state.cursor--
369
+ if (state.historyIndex !== -1) state._draft = [...state.input]
473
370
  render()
474
371
  }
475
372
  return
@@ -477,6 +374,7 @@ export function createKeyHandler(ctx) {
477
374
  if (key.name === "delete") {
478
375
  if (state.cursor < state.input.length) {
479
376
  state.input.splice(state.cursor, 1)
377
+ if (state.historyIndex !== -1) state._draft = [...state.input]
480
378
  render()
481
379
  }
482
380
  return
@@ -532,6 +430,7 @@ export function createKeyHandler(ctx) {
532
430
  const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
533
431
  state.input.splice(state.cursor, 0, ...chars)
534
432
  state.cursor += chars.length
433
+ if (state.historyIndex !== -1) state._draft = [...state.input]
535
434
  render()
536
435
  }
537
436
  }
@@ -78,7 +78,7 @@ export function computeLayout(state, { cols, rows }) {
78
78
  let permPreviewLines = []
79
79
  let permPreviewH = 0
80
80
  if (state.permission) {
81
- const maxLines = Math.max(1, rows - 8)
81
+ const maxLines = Math.min(8, Math.max(1, rows - 10))
82
82
  outer: for (const l of state.permissionPreview) {
83
83
  for (const wrapped of wrapText(` ${l}`, W - 1)) {
84
84
  if (permPreviewLines.length >= maxLines) break outer
@@ -95,14 +95,22 @@ export function computeLayout(state, { cols, rows }) {
95
95
  const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
96
96
  let convH = Math.max(1, rows - fixedH)
97
97
 
98
- // 小终端高度补偿(best-effort,不保证总行数 ≤ rows):先压 conversation 到最小 1 行,
99
- // 仍超出再压 picker 到最小 3 行。极端情况(permission preview + tasks 等同屏)补偿后仍可能
100
- // 溢出 —— 其余面板不强行裁剪,由渲染层自行截断
98
+ // 小终端高度补偿:先压 conversation 到最小 1 行,再压 picker 到最小 3 行,
99
+ // 仍溢出再压 permission preview 到最小 1 行(仅标题)。
101
100
  let pickerFinalH = pickerH
101
+ let permFinalH = permPreviewH
102
102
  const overflow = fixedH + convH - rows
103
- if (overflow > 0 && pickerH > 0) {
104
- pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
105
- convH = Math.max(1, rows - (fixedH - pickerH + pickerFinalH))
103
+ if (overflow > 0) {
104
+ if (pickerH > 0) {
105
+ pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
106
+ }
107
+ const afterPicker = fixedH - pickerH + pickerFinalH
108
+ convH = Math.max(1, rows - afterPicker)
109
+ const remaining = afterPicker + convH - rows
110
+ if (remaining > 0 && permPreviewH > 0) {
111
+ permFinalH = Math.max(1, permPreviewH - remaining)
112
+ convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
113
+ }
106
114
  }
107
115
 
108
116
  // --- Y coordinates (0-indexed, +1 when used with ANSI) ---
@@ -113,7 +121,7 @@ export function computeLayout(state, { cols, rows }) {
113
121
  const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
114
122
  const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
115
123
  const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
116
- const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
124
+ const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
117
125
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
118
126
  const inputBox = { y, h: inputBoxH }; y += inputBoxH
119
127
  const status = { y, h: statusH }