thincoder 0.8.1 → 0.8.3
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 +2 -1
- package/bin/thincoder.cjs +4 -0
- package/bin/thincoder.mjs +25 -25
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tui/index.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* tui.mjs — bare ANSI terminal UI
|
|
3
|
+
* Zero dependencies: raw mode keyboard input, ANSI escape rendering, custom wide-char wrapping.
|
|
4
|
+
* Layout: header / conversation (scrollable) / todo panel (when tasks exist) / input box / status bar.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* agent-turn.mjs — agent
|
|
8
|
-
* key-handler.mjs —
|
|
9
|
-
* startup.mjs —
|
|
10
|
-
* interaction.mjs —
|
|
11
|
-
* pickers.mjs —
|
|
12
|
-
* wizard.mjs —
|
|
13
|
-
* slash-commands.mjs —
|
|
6
|
+
* Large logic blocks extracted to independent modules:
|
|
7
|
+
* agent-turn.mjs — agent loop + callback construction
|
|
8
|
+
* key-handler.mjs — keyboard event dispatch
|
|
9
|
+
* startup.mjs — startup screen + session restore + background indexing
|
|
10
|
+
* interaction.mjs — permission approval + Q&A
|
|
11
|
+
* pickers.mjs — generic list picker + model picker
|
|
12
|
+
* wizard.mjs — first-launch config wizard
|
|
13
|
+
* slash-commands.mjs — slash command dispatch
|
|
14
14
|
* config-helpers.mjs — persistRaw / syncProviderField / maskKey
|
|
15
|
-
* clipboard.mjs —
|
|
16
|
-
* distill-cmd.mjs — /distill
|
|
15
|
+
* clipboard.mjs — clipboard image paste
|
|
16
|
+
* distill-cmd.mjs — /distill command
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { emitKeypressEvents } from "node:readline"
|
|
@@ -36,9 +36,9 @@ import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
|
36
36
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
* agent: createAgent
|
|
41
|
-
* opts: { projectDir?, team?, author? }
|
|
39
|
+
* Start the TUI, taking over the terminal until exit.
|
|
40
|
+
* agent: return value of createAgent
|
|
41
|
+
* opts: { projectDir?, team?, author? } — used by /distill when writing to project/team layers
|
|
42
42
|
*/
|
|
43
43
|
export async function startTUI(agent, opts = {}) {
|
|
44
44
|
if (!process.stdin.isTTY) {
|
|
@@ -48,52 +48,106 @@ export async function startTUI(agent, opts = {}) {
|
|
|
48
48
|
const distillOpts = opts
|
|
49
49
|
|
|
50
50
|
const state = {
|
|
51
|
-
lines: [], //
|
|
52
|
-
streaming: "", // current
|
|
53
|
-
input: [], //
|
|
51
|
+
lines: [], // conversation lines: { text, color }
|
|
52
|
+
streaming: "", // current streaming buffer
|
|
53
|
+
input: [], // input buffer (codepoint array)
|
|
54
54
|
cursor: 0,
|
|
55
55
|
history: [],
|
|
56
56
|
historyIndex: -1,
|
|
57
|
-
scroll: 0, //
|
|
57
|
+
scroll: 0, // scroll lines from bottom upward
|
|
58
58
|
processing: false,
|
|
59
59
|
controller: null, // AbortController for current agent run
|
|
60
60
|
permission: null, // { name, args, resolve }
|
|
61
|
-
permissionPreview: [], //
|
|
62
|
-
question: null, // { text, options, resolve } — agent
|
|
63
|
-
picker: null, //
|
|
64
|
-
wizard: null, //
|
|
65
|
-
tasks: agent.tasks ?? [], // task
|
|
66
|
-
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, //
|
|
67
|
-
ctxCache: { len: -1, tokens: 0 }, //
|
|
68
|
-
reasoning: "", //
|
|
69
|
-
completion: null, // Tab
|
|
70
|
-
toolStreams: {}, //
|
|
71
|
-
subTasks: {}, //
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
|
|
62
|
+
question: null, // { text, options, resolve } — agent question tool callback
|
|
63
|
+
picker: null, // model picker { entries, lines, index, scroll, selectedLine }
|
|
64
|
+
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
65
|
+
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
66
|
+
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // cumulative token usage (shown in status bar)
|
|
67
|
+
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
68
|
+
reasoning: "", // thinking stream buffer (dimmed display)
|
|
69
|
+
completion: null, // Tab completion state { candidates, index }
|
|
70
|
+
toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
|
|
71
|
+
subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
|
|
72
|
+
outputPanels: {}, // generic tool output panel: { toolName: { text, done } } — streamed live during execution, collapsed to summary on completion
|
|
73
|
+
currentTool: null, // currently executing tool name (shown in status bar)
|
|
74
|
+
processingStarted: 0, // current turn start time (status bar timer)
|
|
74
75
|
status: "Ready",
|
|
75
|
-
queue: [], //
|
|
76
|
+
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
//
|
|
79
|
+
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
79
80
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
80
81
|
state.tasks = []
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
84
|
+
// Input stream goes through a filter: mouse sequences (scroll wheel) are intercepted and handled here,
|
|
85
|
+
// stripped clean before passing to keypress parsing, preventing sequence fragments (e.g. "64;72;42M")
|
|
86
|
+
// from leaking into the input box
|
|
85
87
|
const keyStream = new PassThrough()
|
|
86
|
-
let mousePending = "" //
|
|
88
|
+
let mousePending = "" // incomplete mouse sequence tail spanning chunks
|
|
87
89
|
let lastRenderedScroll = 0
|
|
88
90
|
emitKeypressEvents(keyStream)
|
|
89
91
|
process.stdin.setRawMode(true)
|
|
90
|
-
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn)
|
|
92
|
+
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
|
|
93
|
+
|
|
94
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
95
|
+
|
|
96
|
+
let pasteMode = false
|
|
97
|
+
let pasteAccum = ""
|
|
91
98
|
|
|
92
99
|
process.stdin.on("data", (chunk) => {
|
|
93
|
-
let text = mousePending +
|
|
100
|
+
let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
|
|
94
101
|
mousePending = ""
|
|
95
102
|
|
|
96
|
-
//
|
|
103
|
+
// Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
|
|
104
|
+
// Insert pasted content directly into state.input to avoid slow char-by-char keypress render
|
|
105
|
+
if (pasteMode) {
|
|
106
|
+
const endIdx = text.indexOf("\x1b[201~")
|
|
107
|
+
if (endIdx >= 0) {
|
|
108
|
+
pasteAccum += text.slice(0, endIdx)
|
|
109
|
+
pasteMode = false
|
|
110
|
+
const pasted = pasteAccum.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ")
|
|
111
|
+
pasteAccum = ""
|
|
112
|
+
if (pasted) {
|
|
113
|
+
const chars = [...pasted]
|
|
114
|
+
state.input.splice(state.cursor, 0, ...chars)
|
|
115
|
+
state.cursor += chars.length
|
|
116
|
+
render()
|
|
117
|
+
}
|
|
118
|
+
text = text.slice(endIdx + 6)
|
|
119
|
+
} else {
|
|
120
|
+
pasteAccum += text
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for paste start (may appear mid-chunk alongside other input)
|
|
126
|
+
const pasteStartIdx = text.indexOf("\x1b[200~")
|
|
127
|
+
if (pasteStartIdx >= 0) {
|
|
128
|
+
const before = text.slice(0, pasteStartIdx)
|
|
129
|
+
const after = text.slice(pasteStartIdx + 6)
|
|
130
|
+
const endIdx = after.indexOf("\x1b[201~")
|
|
131
|
+
if (endIdx >= 0) {
|
|
132
|
+
// Paste begin and end in the same chunk: insert pasted content directly
|
|
133
|
+
const pasted = after.slice(0, endIdx).replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ")
|
|
134
|
+
if (pasted) {
|
|
135
|
+
const chars = [...pasted]
|
|
136
|
+
state.input.splice(state.cursor, 0, ...chars)
|
|
137
|
+
state.cursor += chars.length
|
|
138
|
+
render()
|
|
139
|
+
}
|
|
140
|
+
text = before + after.slice(endIdx + 6)
|
|
141
|
+
} else {
|
|
142
|
+
// Paste spans multiple chunks: write prefix, enter paste mode
|
|
143
|
+
if (before) keyStream.write(before)
|
|
144
|
+
pasteMode = true
|
|
145
|
+
pasteAccum = after
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Scroll wheel: \x1b[<64;…M = scroll up, \x1b[<65;…M = scroll down (3 lines each)
|
|
97
151
|
for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
|
|
98
152
|
if (Number(m[1]) === 64) {
|
|
99
153
|
state.scroll += 3
|
|
@@ -102,7 +156,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
102
156
|
}
|
|
103
157
|
}
|
|
104
158
|
|
|
105
|
-
//
|
|
159
|
+
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
106
160
|
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
107
161
|
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
108
162
|
if (tail) {
|
|
@@ -121,38 +175,41 @@ export async function startTUI(agent, opts = {}) {
|
|
|
121
175
|
const cleanup = () => {
|
|
122
176
|
if (cleanedUp) return
|
|
123
177
|
cleanedUp = true
|
|
124
|
-
//
|
|
178
|
+
// Save session before exit (synchronous write); archive current to a slot first, then save new — never lose data
|
|
125
179
|
try {
|
|
126
180
|
archiveCurrent(agent.cwd)
|
|
127
181
|
saveSession(agent, state.lines)
|
|
128
182
|
} catch {
|
|
129
|
-
//
|
|
183
|
+
// Save failure shouldn't block exit
|
|
130
184
|
}
|
|
131
|
-
//
|
|
185
|
+
// Kill MCP stdio subprocesses, don't leave orphans
|
|
132
186
|
try {
|
|
133
187
|
closeAllMcp(agent)
|
|
134
188
|
} catch {
|
|
135
|
-
//
|
|
189
|
+
// Can't close? fine, process is exiting anyway
|
|
136
190
|
}
|
|
137
191
|
process.stdin.setRawMode(false)
|
|
138
|
-
process.stdout.write(ansi.mouseOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
192
|
+
process.stdout.write(ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
139
193
|
}
|
|
140
194
|
process.on("exit", cleanup)
|
|
141
195
|
|
|
142
196
|
const pushLine = (text, color) => {
|
|
143
197
|
state.lines.push({ text, color })
|
|
144
|
-
if (state.lines.length > 5000)
|
|
198
|
+
if (state.lines.length > 5000) {
|
|
199
|
+
state.lines.splice(0, 1000)
|
|
200
|
+
state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
|
|
201
|
+
}
|
|
145
202
|
render()
|
|
146
203
|
}
|
|
147
204
|
|
|
148
|
-
/**
|
|
205
|
+
/** Message block label: blank line + label line. Breathing space between user/assistant messages */
|
|
149
206
|
const pushLabel = (text, color) => {
|
|
150
207
|
if (state.lines.length > 0) state.lines.push({ text: "", color: C.dim })
|
|
151
208
|
state.lines.push({ text, color })
|
|
152
209
|
render()
|
|
153
210
|
}
|
|
154
211
|
|
|
155
|
-
//
|
|
212
|
+
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
156
213
|
let assistantLabeled = false
|
|
157
214
|
const ensureAssistantLabel = () => {
|
|
158
215
|
if (!assistantLabeled) {
|
|
@@ -161,9 +218,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
161
218
|
}
|
|
162
219
|
}
|
|
163
220
|
|
|
164
|
-
// ----------------------------------------------------------
|
|
221
|
+
// ---------------------------------------------------------- Render
|
|
165
222
|
|
|
166
|
-
//
|
|
223
|
+
// Frame dedup + streaming rate limit: skip re-rendering unchanged frames (prevents flicker);
|
|
224
|
+
// merge token flood to ~25fps
|
|
167
225
|
let lastFrame = ""
|
|
168
226
|
let renderTimer = null
|
|
169
227
|
|
|
@@ -176,8 +234,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
176
234
|
}
|
|
177
235
|
|
|
178
236
|
function render() {
|
|
179
|
-
//
|
|
180
|
-
// (renderFrame
|
|
237
|
+
// Side effects: reset scroll + update ctxCache + clamp overlay scroll
|
|
238
|
+
// (renderFrame is pure, side effects concentrated here)
|
|
181
239
|
const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
182
240
|
const layout = computeLayout(state, dims)
|
|
183
241
|
// clamp conversation scroll
|
|
@@ -202,9 +260,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
202
260
|
})
|
|
203
261
|
if (frame !== lastFrame) {
|
|
204
262
|
lastFrame = frame
|
|
205
|
-
process.stdout.write(frame)
|
|
263
|
+
process.stdout.write(ansi.hideCursor + frame)
|
|
206
264
|
}
|
|
207
|
-
//
|
|
265
|
+
// Cursor: position inside input box when editing; hide during permission/menu mode
|
|
208
266
|
if (state.permission || state.question || state.picker || state.wizard?.step === "provider") {
|
|
209
267
|
process.stdout.write(ansi.hideCursor)
|
|
210
268
|
} else {
|
|
@@ -214,7 +272,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
214
272
|
|
|
215
273
|
process.stdout.on("resize", render)
|
|
216
274
|
|
|
217
|
-
// ----------------------------------------------------------
|
|
275
|
+
// ---------------------------------------------------------- Submit
|
|
218
276
|
|
|
219
277
|
async function submit() {
|
|
220
278
|
const text = state.input.join("").trim()
|
|
@@ -225,10 +283,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
225
283
|
state.historyIndex = -1
|
|
226
284
|
state.scroll = 0
|
|
227
285
|
|
|
228
|
-
//
|
|
286
|
+
// Slash commands: handled locally, don't enter agent loop
|
|
229
287
|
if (text.startsWith("/")) {
|
|
230
288
|
if (state.processing) {
|
|
231
|
-
//
|
|
289
|
+
// While processing: read-only commands (switch/view/help) execute directly;
|
|
290
|
+
// side-effect commands (clear/new/reindex/extract) are queued
|
|
232
291
|
const cmd0 = text.split(/\s+/)[0]
|
|
233
292
|
const ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
|
|
234
293
|
const resolved0 = ALIASES[cmd0] ?? cmd0
|
|
@@ -245,7 +304,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
245
304
|
return
|
|
246
305
|
}
|
|
247
306
|
|
|
248
|
-
//
|
|
307
|
+
// While processing: queue for later, don't execute immediately
|
|
249
308
|
if (state.processing) {
|
|
250
309
|
state.queue.push({ text })
|
|
251
310
|
pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
|
|
@@ -257,15 +316,15 @@ export async function startTUI(agent, opts = {}) {
|
|
|
257
316
|
await turn(text)
|
|
258
317
|
}
|
|
259
318
|
|
|
260
|
-
//
|
|
319
|
+
// Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
|
|
261
320
|
const { askPermission, askQuestion } = createInteraction({
|
|
262
321
|
agent, state, pushLine, pushLabel, render, summarize,
|
|
263
322
|
})
|
|
264
323
|
|
|
265
|
-
//
|
|
324
|
+
// Clipboard image paste: implemented in clipboard.mjs
|
|
266
325
|
const pasteClipboardImage = () => pasteClipboardImageImpl({ agent, state, pushLine, render })
|
|
267
326
|
|
|
268
|
-
//
|
|
327
|
+
// Agent loop: implemented in agent-turn.mjs
|
|
269
328
|
const turnCtx = {
|
|
270
329
|
agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
|
|
271
330
|
askPermission, askQuestion, handleSlash: null, summarize,
|
|
@@ -274,17 +333,17 @@ export async function startTUI(agent, opts = {}) {
|
|
|
274
333
|
}
|
|
275
334
|
const turn = (text) => runAgentTurn(turnCtx, text)
|
|
276
335
|
|
|
277
|
-
// ----------------------------------------------------------
|
|
336
|
+
// ---------------------------------------------------------- Slash Commands
|
|
278
337
|
|
|
279
|
-
//
|
|
338
|
+
// Config helpers: implemented in config-helpers.mjs
|
|
280
339
|
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
281
340
|
|
|
282
|
-
//
|
|
341
|
+
// Model picker + generic picker: implemented in pickers.mjs
|
|
283
342
|
const { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey } = createPickers({
|
|
284
343
|
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
285
344
|
})
|
|
286
345
|
|
|
287
|
-
//
|
|
346
|
+
// First-launch config wizard: implemented in wizard.mjs, closure deps passed via ctx
|
|
288
347
|
const { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems } = createWizard({
|
|
289
348
|
agent, state, pushLine, pushLabel, render, persistRaw,
|
|
290
349
|
openModelPicker: () => openModelPicker(),
|
|
@@ -293,7 +352,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
293
352
|
// /distill: impl in distill-cmd.mjs, ctx-passed
|
|
294
353
|
const runDistill = () => runDistillImpl({ agent, state, pushLine, render, askPermission, distillOpts })
|
|
295
354
|
|
|
296
|
-
//
|
|
355
|
+
// Slash command dispatch + Tab completion: implemented in slash-commands.mjs, closure deps passed via ctx
|
|
297
356
|
const { handleSlash, completions, handleTab } = createSlashCommands({
|
|
298
357
|
agent, state, distillOpts,
|
|
299
358
|
pushLine, pushLabel, render,
|
|
@@ -304,12 +363,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
304
363
|
runDistill,
|
|
305
364
|
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
306
365
|
})
|
|
307
|
-
// handleSlash
|
|
366
|
+
// handleSlash is referenced by turnCtx (circular dep: submit → turn → handleSlash), backfilled here
|
|
308
367
|
turnCtx.handleSlash = handleSlash
|
|
309
368
|
|
|
310
|
-
// ----------------------------------------------------------
|
|
369
|
+
// ---------------------------------------------------------- Keyboard / Mouse
|
|
311
370
|
|
|
312
|
-
// keypress
|
|
371
|
+
// keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
|
|
313
372
|
const onKeypress = createKeyHandler({
|
|
314
373
|
agent, state, render, closePicker, renderPickerLines,
|
|
315
374
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
@@ -318,7 +377,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
318
377
|
})
|
|
319
378
|
keyStream.on("keypress", onKeypress)
|
|
320
379
|
|
|
321
|
-
// ----------------------------------------------------------
|
|
380
|
+
// ---------------------------------------------------------- Startup screen + background indexing
|
|
322
381
|
|
|
323
382
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
324
383
|
backgroundIndex({ agent, state, render })
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
3
|
+
/** Interaction primitives: permission approval + question input.
|
|
4
|
+
* Extracted from index.mjs, receives closure dependencies via createInteraction(ctx).
|
|
5
5
|
* ctx: { agent, state, pushLine, pushLabel, render, summarize } */
|
|
6
6
|
export function createInteraction(ctx) {
|
|
7
7
|
const { agent, state, pushLine, pushLabel, render, summarize } = 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
11
|
const cap = (s, n = 1000) => (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
16
|
return [`${args.path} (write ${(args.content ?? "").length} chars)`, ...cap(args.content ?? "", 1000).split("\n")]
|
|
17
17
|
}
|
|
18
18
|
if (base === "edit") {
|
|
19
|
-
//
|
|
19
|
+
// simple diff: - old content / + new content
|
|
20
20
|
return [
|
|
21
21
|
`${args.path}`,
|
|
22
22
|
...cap(args.old_string ?? "", 500).split("\n").map((l) => `- ${l}`),
|
|
@@ -25,7 +25,7 @@ export function createInteraction(ctx) {
|
|
|
25
25
|
]
|
|
26
26
|
}
|
|
27
27
|
if (base === "apply_patch") {
|
|
28
|
-
//
|
|
28
|
+
// patches are readable diffs themselves, preview directly
|
|
29
29
|
return cap(args.patch ?? "", 1500).split("\n")
|
|
30
30
|
}
|
|
31
31
|
if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
|
|
@@ -35,12 +35,12 @@ export function createInteraction(ctx) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function askPermission(name, args) {
|
|
38
|
-
// auto
|
|
38
|
+
// auto mode: fully authorized, no more prompts
|
|
39
39
|
if (agent.autoApprove) {
|
|
40
40
|
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
41
41
|
return Promise.resolve(true)
|
|
42
42
|
}
|
|
43
|
-
//
|
|
43
|
+
// store preview content in permissionPreview, rendered above input box next to "Allow?" prompt
|
|
44
44
|
state.permissionPreview = formatPermission(name, args)
|
|
45
45
|
return new Promise((resolve) => {
|
|
46
46
|
state.permission = { name, args, resolve }
|
|
@@ -50,13 +50,14 @@ export function createInteraction(ctx) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function askQuestion(text, options = []) {
|
|
53
|
-
//
|
|
54
|
-
//
|
|
53
|
+
// only one question at a time: question is a read-only tool on a parallel channel,
|
|
54
|
+
// a second concurrent one is rejected directly; otherwise the later one overwrites
|
|
55
|
+
// state.question and the first Promise hangs forever (agent deadlock)
|
|
55
56
|
if (state.question) {
|
|
56
57
|
return Promise.resolve("(error: another question is pending; ask one at a time and wait for the answer)")
|
|
57
58
|
}
|
|
58
59
|
if (!options.length) {
|
|
59
|
-
//
|
|
60
|
+
// free text: open input mode for user typing, Enter to submit
|
|
60
61
|
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
61
62
|
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
62
63
|
return new Promise((resolve) => {
|
|
@@ -65,7 +66,7 @@ export function createInteraction(ctx) {
|
|
|
65
66
|
render()
|
|
66
67
|
})
|
|
67
68
|
}
|
|
68
|
-
//
|
|
69
|
+
// options mode: show list in input box, arrow keys to select, Enter to confirm
|
|
69
70
|
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
70
71
|
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
71
72
|
return new Promise((resolve) => {
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
3
|
+
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
4
|
+
* Extracted from index.mjs.
|
|
5
5
|
* ctx: { agent, state, render, renderPickerLines, closePicker,
|
|
6
6
|
* handleSlash, handleTab, submit, pasteClipboardImage,
|
|
7
7
|
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
@@ -10,7 +10,7 @@ export function createKeyHandler(ctx) {
|
|
|
10
10
|
const { agent, state, render, closePicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
|
|
11
11
|
|
|
12
12
|
return function onKeypress(str, key = {}) {
|
|
13
|
-
//
|
|
13
|
+
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
14
14
|
if (state.permission) {
|
|
15
15
|
const answer = (str || "").toLowerCase()
|
|
16
16
|
const isContinue = state.permission.name === "continue"
|
|
@@ -27,7 +27,7 @@ export function createKeyHandler(ctx) {
|
|
|
27
27
|
pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
|
|
28
28
|
}
|
|
29
29
|
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
30
|
-
//
|
|
30
|
+
// leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
|
|
31
31
|
if (!isContinue) {
|
|
32
32
|
pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
|
|
33
33
|
}
|
|
@@ -37,11 +37,11 @@ export function createKeyHandler(ctx) {
|
|
|
37
37
|
return
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// question
|
|
40
|
+
// question tool callback: free text / option selection
|
|
41
41
|
if (state.question) {
|
|
42
42
|
const q = state.question
|
|
43
43
|
if (q.options.length > 0) {
|
|
44
|
-
//
|
|
44
|
+
// options mode: ↑↓ select, Enter confirm, Esc cancel
|
|
45
45
|
if (key.name === "escape") {
|
|
46
46
|
q.resolve("(cancelled)")
|
|
47
47
|
state.question = null
|
|
@@ -62,7 +62,7 @@ export function createKeyHandler(ctx) {
|
|
|
62
62
|
render()
|
|
63
63
|
}
|
|
64
64
|
} else {
|
|
65
|
-
//
|
|
65
|
+
// free text: type answer, Enter submit, Esc cancel
|
|
66
66
|
if (key.name === "escape") {
|
|
67
67
|
q.resolve("(cancelled)")
|
|
68
68
|
state.question = null
|
|
@@ -97,7 +97,7 @@ export function createKeyHandler(ctx) {
|
|
|
97
97
|
setTimeout(() => process.exit(0), 100)
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
//
|
|
100
|
+
// generic list picker: ↑↓ move, Enter confirm, Esc cancel
|
|
101
101
|
if (state.picker) {
|
|
102
102
|
const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
103
103
|
if (key.name === "escape") {
|
|
@@ -111,8 +111,8 @@ export function createKeyHandler(ctx) {
|
|
|
111
111
|
} else if (key.name === "return" && items.length) {
|
|
112
112
|
const selected = items[state.picker.index]
|
|
113
113
|
const handler = state.picker.onSelect
|
|
114
|
-
state.picker = null //
|
|
115
|
-
// onSelect
|
|
114
|
+
state.picker = null // close picker first, avoid picker still being present during onSelect render
|
|
115
|
+
// onSelect is async (e.g. removing a provider writes file), catch errors so they aren't swallowed
|
|
116
116
|
Promise.resolve(handler?.(selected)).catch((err) => {
|
|
117
117
|
pushLine(`[error] ${err.message}`, C.error)
|
|
118
118
|
}).finally(() => render())
|
|
@@ -120,7 +120,7 @@ export function createKeyHandler(ctx) {
|
|
|
120
120
|
return
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
//
|
|
123
|
+
// initial config wizard: menu step ↑↓/Enter/Esc; text step Enter submit, Esc cancel, edit keys fall through to normal input
|
|
124
124
|
if (state.wizard) {
|
|
125
125
|
const w = state.wizard
|
|
126
126
|
if (key.name === "escape") {
|
|
@@ -144,11 +144,11 @@ export function createKeyHandler(ctx) {
|
|
|
144
144
|
wizardSubmitText()
|
|
145
145
|
return
|
|
146
146
|
}
|
|
147
|
-
//
|
|
147
|
+
// text steps: block scroll/history, remaining edit keys fall through to normal input logic below
|
|
148
148
|
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
//
|
|
151
|
+
// page scroll
|
|
152
152
|
if (key.name === "pageup") {
|
|
153
153
|
state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
|
|
154
154
|
render()
|
|
@@ -161,9 +161,9 @@ export function createKeyHandler(ctx) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
if (state.processing) {
|
|
164
|
-
//
|
|
164
|
+
// during processing, allow input (queued), but block arrow-key history and Tab completion
|
|
165
165
|
if (key.name === "tab" || key.name === "up" || key.name === "down") return
|
|
166
|
-
// Ctrl+D
|
|
166
|
+
// Ctrl+D: remove last item from queue
|
|
167
167
|
if (key.ctrl && key.name === "d") {
|
|
168
168
|
if (state.queue.length > 0) {
|
|
169
169
|
state.queue.pop()
|
|
@@ -171,16 +171,24 @@ export function createKeyHandler(ctx) {
|
|
|
171
171
|
}
|
|
172
172
|
return
|
|
173
173
|
}
|
|
174
|
-
//
|
|
174
|
+
// remaining printable characters go into input box normally
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
// Tab
|
|
177
|
+
// Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
|
|
178
178
|
if (key.name === "tab") {
|
|
179
179
|
handleTab()
|
|
180
180
|
return
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// Ctrl+U: clear entire input box
|
|
184
|
+
if ((key.name === "u" && key.ctrl) || str === "\x15") {
|
|
185
|
+
state.input = []
|
|
186
|
+
state.cursor = 0
|
|
187
|
+
render()
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// input history
|
|
184
192
|
if (key.name === "up") {
|
|
185
193
|
if (state.history.length) {
|
|
186
194
|
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
@@ -205,7 +213,7 @@ export function createKeyHandler(ctx) {
|
|
|
205
213
|
return
|
|
206
214
|
}
|
|
207
215
|
|
|
208
|
-
//
|
|
216
|
+
// cursor movement
|
|
209
217
|
if (key.name === "left") {
|
|
210
218
|
state.cursor = Math.max(0, state.cursor - 1)
|
|
211
219
|
render()
|
|
@@ -227,7 +235,7 @@ export function createKeyHandler(ctx) {
|
|
|
227
235
|
return
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
//
|
|
238
|
+
// editing
|
|
231
239
|
if (key.name === "backspace") {
|
|
232
240
|
if (state.cursor > 0) {
|
|
233
241
|
state.input.splice(state.cursor - 1, 1)
|
|
@@ -248,15 +256,15 @@ export function createKeyHandler(ctx) {
|
|
|
248
256
|
return
|
|
249
257
|
}
|
|
250
258
|
|
|
251
|
-
// Ctrl+V (Unix) / Alt+V (Windows)
|
|
259
|
+
// Ctrl+V (Unix) / Alt+V (Windows): paste clipboard image → save temp file → insert read_image into input box
|
|
252
260
|
const isPasteImage = (key.name === "v" && (key.ctrl || key.meta)) || (key.name === "v" && key.alt)
|
|
253
261
|
if (isPasteImage) {
|
|
254
262
|
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
255
263
|
return
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
//
|
|
259
|
-
// \r\n
|
|
266
|
+
// printable characters / paste (str may contain multiple chars at once); Tab always converted to two spaces (\t has variable display width, would blow up input box)
|
|
267
|
+
// \r\n may leak through in Windows raw mode and scramble the display
|
|
260
268
|
if (str && !key.ctrl && !key.meta) {
|
|
261
269
|
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
262
270
|
state.input.splice(state.cursor, 0, ...chars)
|