thincoder 0.12.12 → 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 +1 -1
- package/src/advisor/convergence.mjs +80 -0
- package/src/advisor/history.mjs +19 -73
- package/src/advisor/messages.mjs +142 -50
- package/src/advisor/repos.mjs +40 -0
- package/src/advisor/run.mjs +16 -4
- package/src/advisor.mjs +31 -60
- package/src/agent/completion.mjs +1 -8
- package/src/agent-tools/advisor.mjs +7 -17
- package/src/agent.mjs +20 -27
- package/src/config.mjs +13 -5
- package/src/context.mjs +32 -9
- package/src/prompts/advisor-round1.md +3 -3
- package/src/prompts/advisor-round2.md +6 -6
- package/src/prompts/advisor-round3.md +11 -13
- package/src/tui/clipboard.mjs +8 -0
- package/src/tui/index.mjs +5 -2
- package/src/tui/interaction.mjs +2 -2
- package/src/tui/key-handler-search.mjs +113 -0
- package/src/tui/key-handler.mjs +9 -110
- package/src/tui/layout.mjs +16 -8
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
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -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 =
|
|
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 ?? "",
|
|
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
|
+
}
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
418
|
-
//
|
|
419
|
-
if (state.historyIndex === -1
|
|
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
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -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 -
|
|
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
|
-
//
|
|
99
|
-
//
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
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 }
|