thincoder 0.8.2 → 0.8.4
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.mjs +25 -25
- package/package.json +1 -1
- 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 +12 -11
- package/src/agent-tools/plan.mjs +5 -6
- 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 +7 -26
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +82 -92
- 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 +56 -53
- package/src/context.mjs +59 -61
- 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 +0 -6
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/main.md +24 -24
- 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 -42
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +20 -14
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/ls.md +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 +55 -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 +5 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +3 -12
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +108 -23
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +11 -3
- package/src/tui/cmd-goal.mjs +3 -12
- package/src/tui/cmd-help.mjs +2 -2
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +38 -25
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +33 -13
- package/src/tui/cmd-plan.mjs +3 -12
- 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 +4 -11
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +4 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +34 -26
- 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 +22 -22
- 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/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,13 +37,13 @@ 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
|
-
q.resolve("
|
|
46
|
+
q.resolve("")
|
|
47
47
|
state.question = null
|
|
48
48
|
state.status = "Processing..."
|
|
49
49
|
render()
|
|
@@ -62,15 +62,15 @@ 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
|
-
q.resolve("
|
|
67
|
+
q.resolve("")
|
|
68
68
|
state.question = null
|
|
69
69
|
state.status = "Processing..."
|
|
70
70
|
render()
|
|
71
71
|
} else if (key.name === "return") {
|
|
72
72
|
const answer = (q.answer ?? "").trim()
|
|
73
|
-
q.resolve(answer || "
|
|
73
|
+
q.resolve(answer || "")
|
|
74
74
|
state.question = null
|
|
75
75
|
state.status = "Processing..."
|
|
76
76
|
pushLine(` → ${answer || "(empty)"}`, C.tool)
|
|
@@ -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)
|
package/src/tui/layout.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* layout.mjs — TUI
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* layout.mjs — TUI layout engine (pure function)
|
|
3
|
+
* Computes position and height of each panel from state + terminal dimensions.
|
|
4
|
+
* Does not modify state — side effects are performed by the caller before rendering.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* header → conversation →
|
|
8
|
-
*
|
|
6
|
+
* Panel layout (top to bottom):
|
|
7
|
+
* header → conversation → subagent → output → todo → permission → queue → input → status
|
|
8
|
+
* Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
|
|
9
9
|
*/
|
|
10
10
|
import { layoutInput, wrapText } from "./render.mjs"
|
|
11
11
|
|
|
@@ -15,13 +15,13 @@ export const MAX_SUB_LINES = 4
|
|
|
15
15
|
const QWIN = 5
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Computes layout. Returns panel coordinates + precomputed content (height-affecting parts).
|
|
19
|
+
* Pure function: does not modify state.
|
|
20
20
|
*/
|
|
21
21
|
export function computeLayout(state, { cols, rows }) {
|
|
22
22
|
const W = Math.max(20, cols - 1)
|
|
23
23
|
|
|
24
|
-
// ---
|
|
24
|
+
// --- input box ---
|
|
25
25
|
const inputLayout = layoutInput(state.input, state.cursor, W - 4)
|
|
26
26
|
let inputOffset = 0
|
|
27
27
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
@@ -41,11 +41,11 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
41
41
|
}
|
|
42
42
|
const inputBoxH = boxLines.length + 2
|
|
43
43
|
|
|
44
|
-
// ---
|
|
44
|
+
// --- fixed panels ---
|
|
45
45
|
const headerH = 1
|
|
46
46
|
const statusH = 1
|
|
47
47
|
|
|
48
|
-
// ---
|
|
48
|
+
// --- conditional panels ---
|
|
49
49
|
const overlay = state.picker ?? state.wizard
|
|
50
50
|
const pickerH = overlay ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12)) : 0
|
|
51
51
|
|
|
@@ -67,6 +67,10 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
67
67
|
? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
|
|
68
68
|
: 0
|
|
69
69
|
|
|
70
|
+
// Tool output panels: max 8 lines per panel, capped at reasonable total
|
|
71
|
+
const panels = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
72
|
+
const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
|
|
73
|
+
|
|
70
74
|
// Permission preview (height depends on wrapped content)
|
|
71
75
|
let permPreviewLines = []
|
|
72
76
|
let permPreviewH = 0
|
|
@@ -84,17 +88,18 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
84
88
|
// Queue preview (1 line when queue has items and processing)
|
|
85
89
|
const queueH = state.queue.length > 0 && state.processing ? 1 : 0
|
|
86
90
|
|
|
87
|
-
// ---
|
|
88
|
-
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + permPreviewH + queueH
|
|
91
|
+
// --- elastic panel: conversation takes remaining space ---
|
|
92
|
+
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
|
|
89
93
|
const convH = Math.max(1, rows - fixedH)
|
|
90
94
|
|
|
91
|
-
// --- Y
|
|
95
|
+
// --- Y coordinates (0-indexed, +1 when used with ANSI) ---
|
|
92
96
|
let y = 0
|
|
93
97
|
const header = { y, h: headerH }; y += headerH
|
|
94
98
|
const conversation = { y, h: convH }; y += convH
|
|
99
|
+
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
100
|
+
const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
|
|
95
101
|
const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
|
|
96
102
|
const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
|
|
97
|
-
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
98
103
|
const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
|
|
99
104
|
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
100
105
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|
|
@@ -102,8 +107,8 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
102
107
|
|
|
103
108
|
return {
|
|
104
109
|
W, cols, rows,
|
|
105
|
-
panels: { header, conversation, picker, todo, subagent, permission, queue, inputBox, status },
|
|
106
|
-
//
|
|
110
|
+
panels: { header, conversation, picker, todo, subagent, output, permission, queue, inputBox, status },
|
|
111
|
+
// precomputed content (affects height, reused during render)
|
|
107
112
|
inputLayout,
|
|
108
113
|
inputOffset,
|
|
109
114
|
boxLines,
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { sliceByWidth } from "./render.mjs"
|
|
2
2
|
import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
/** Generic list picker + unified model/provider management picker.
|
|
5
|
+
* Receives closure dependencies via createPickers(ctx),
|
|
6
|
+
* returns { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey }. */
|
|
7
7
|
export function createPickers(ctx) {
|
|
8
8
|
const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey } = ctx
|
|
9
9
|
|
|
10
10
|
const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* onSelect
|
|
12
|
+
/** Open a generic list picker. entries contain { type: "header"|"item", text, note?, ...extra },
|
|
13
|
+
* onSelect receives the selected entry (with extra fields forwarded), onCancel called on Esc. */
|
|
14
14
|
function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
|
|
15
15
|
state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
|
|
16
16
|
renderPickerLines()
|
|
@@ -22,7 +22,7 @@ export function createPickers(ctx) {
|
|
|
22
22
|
render()
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/** Rebuild display lines from entries and refresh */
|
|
26
26
|
function renderPickerLines() {
|
|
27
27
|
const p = state.picker
|
|
28
28
|
if (!p) return
|
|
@@ -48,7 +48,7 @@ export function createPickers(ctx) {
|
|
|
48
48
|
render()
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// ==========
|
|
51
|
+
// ========== unified picker: model switch + provider management ==========
|
|
52
52
|
|
|
53
53
|
async function openModelPicker() {
|
|
54
54
|
const entries = buildModelEntries()
|
|
@@ -64,14 +64,14 @@ export function createPickers(ctx) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
openPicker({ title: "Models & Providers", entries, onSelect })
|
|
67
|
-
//
|
|
67
|
+
// default select the currently active model
|
|
68
68
|
const current = pickerItems().findIndex(
|
|
69
69
|
(e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
70
70
|
)
|
|
71
71
|
if (current >= 0) state.picker.index = current
|
|
72
72
|
renderPickerLines()
|
|
73
73
|
|
|
74
|
-
//
|
|
74
|
+
// async fetch remote model list, append under each provider
|
|
75
75
|
const { listModels } = await import("../provider/index.mjs")
|
|
76
76
|
await Promise.all(
|
|
77
77
|
agent.providers.map(async (p) => {
|
|
@@ -84,7 +84,7 @@ export function createPickers(ctx) {
|
|
|
84
84
|
{ baseURL: p.baseURL, apiKey: apiKey ?? "" },
|
|
85
85
|
{ signal: AbortSignal.timeout(10000) },
|
|
86
86
|
)
|
|
87
|
-
//
|
|
87
|
+
// find the model item position for this provider in entries, insert new models after it
|
|
88
88
|
const at = entries.findLastIndex(
|
|
89
89
|
(e) => e.type === "item" && e.action === "switch" && e.provider === p.name,
|
|
90
90
|
)
|
|
@@ -97,7 +97,7 @@ export function createPickers(ctx) {
|
|
|
97
97
|
.map((m) => ({ type: "item", text: m, action: "switch", provider: p.name, model: m })),
|
|
98
98
|
)
|
|
99
99
|
}
|
|
100
|
-
//
|
|
100
|
+
// update header note to remove "loading..."
|
|
101
101
|
const header = entries.find((e) => e.type === "header" && e.text === p.name)
|
|
102
102
|
if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
|
|
103
103
|
} catch (error) {
|
|
@@ -109,7 +109,7 @@ export function createPickers(ctx) {
|
|
|
109
109
|
)
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/** Build picker entries: each provider gets a header + model list, management actions at the bottom */
|
|
113
113
|
function buildModelEntries() {
|
|
114
114
|
const entries = []
|
|
115
115
|
for (const p of agent.providers) {
|
|
@@ -128,7 +128,7 @@ export function createPickers(ctx) {
|
|
|
128
128
|
marker: active ? "●" : "",
|
|
129
129
|
})
|
|
130
130
|
}
|
|
131
|
-
//
|
|
131
|
+
// management actions
|
|
132
132
|
entries.push({ type: "header", text: "Provider Management" })
|
|
133
133
|
entries.push({ type: "item", text: "Add provider…", action: "add" })
|
|
134
134
|
if (agent.providers.length > 1) {
|
|
@@ -138,7 +138,7 @@ export function createPickers(ctx) {
|
|
|
138
138
|
return entries
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
141
|
+
/** Switch provider + model, persist, threshold follows model */
|
|
142
142
|
async function selectModel(item) {
|
|
143
143
|
closePicker()
|
|
144
144
|
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
@@ -167,7 +167,7 @@ export function createPickers(ctx) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/**
|
|
170
|
+
/** Add provider: preset menu → input key → done, or custom step-by-step input */
|
|
171
171
|
async function addProviderFlow() {
|
|
172
172
|
const presetEntries = [
|
|
173
173
|
{ type: "header", text: "Select a preset provider" },
|
|
@@ -204,7 +204,7 @@ export function createPickers(ctx) {
|
|
|
204
204
|
openModelPicker().catch(() => {})
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
|
-
//
|
|
207
|
+
// preset
|
|
208
208
|
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
|
|
209
209
|
const preset = PRESETS[se.name]
|
|
210
210
|
const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
|
|
@@ -222,7 +222,7 @@ export function createPickers(ctx) {
|
|
|
222
222
|
})
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
/**
|
|
225
|
+
/** Remove provider (cannot remove the currently active one) */
|
|
226
226
|
async function removeProviderFlow() {
|
|
227
227
|
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
228
228
|
if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
|
|
@@ -243,7 +243,7 @@ export function createPickers(ctx) {
|
|
|
243
243
|
})
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
/**
|
|
246
|
+
/** Set/change API key: select provider → enter key */
|
|
247
247
|
async function setKeyFlow() {
|
|
248
248
|
const keyEntries = [
|
|
249
249
|
{ type: "header", text: "Select provider to configure key" },
|
|
@@ -266,7 +266,7 @@ export function createPickers(ctx) {
|
|
|
266
266
|
})
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
269
|
+
/** Write key for a given provider (memory + config file); if it's the currently active one, sync runtime too */
|
|
270
270
|
async function setProviderKey(name, key) {
|
|
271
271
|
const target = agent.providers.find((p) => p.name === name)
|
|
272
272
|
if (!target) return
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* render-frame.mjs —
|
|
3
|
-
*
|
|
2
|
+
* render-frame.mjs — terminal frame renderer (pure computation, no side effects)
|
|
3
|
+
* Produces an ANSI frame string from state + agent + layout, returns cursor position.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Layout calculation lives in layout.mjs (computeLayout pure function).
|
|
6
|
+
* Side effects (scroll clamping, ctxCache update) are performed by the caller before rendering.
|
|
7
7
|
*/
|
|
8
8
|
import { ansi, C, ESC } from "./ansi.mjs"
|
|
9
9
|
import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { specForModel } from "../config.mjs"
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
|
|
16
|
-
// ---------- status bar slash-command hints (
|
|
16
|
+
// ---------- status bar slash-command hints (lookup table instead of if-else chain) ----------
|
|
17
17
|
const SLASH_HINTS = {
|
|
18
18
|
"/config": "open config menu",
|
|
19
19
|
"/model": "select model & manage providers",
|
|
@@ -25,8 +25,8 @@ const SLASH_HINTS = {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Render one frame, returns { frame, cursorRow, cursorCol }.
|
|
29
|
+
* Pure function: does not modify state/agent.
|
|
30
30
|
*/
|
|
31
31
|
export function renderFrame(state, agent, opts) {
|
|
32
32
|
const cols = opts.cols || 80
|
|
@@ -111,6 +111,29 @@ export function renderFrame(state, agent, opts) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ---- tool output panels (streaming output like tail -f, auto-clears when done) ----
|
|
115
|
+
if (panels.output) {
|
|
116
|
+
const active = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
117
|
+
if (active.length > 0) {
|
|
118
|
+
const linesPerPanel = Math.max(1, Math.floor(panels.output.h / active.length))
|
|
119
|
+
for (const p of active) {
|
|
120
|
+
const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
|
|
121
|
+
const tail = textLines.slice(-linesPerPanel)
|
|
122
|
+
for (const line of tail) {
|
|
123
|
+
out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}${ansi.clearLine}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// fill remaining rows
|
|
127
|
+
const used = active.reduce((s, p) => {
|
|
128
|
+
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
129
|
+
return s + tl.length
|
|
130
|
+
}, 0)
|
|
131
|
+
for (let i = used; i < panels.output.h; i++) {
|
|
132
|
+
out.push(ansi.clearLine)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
// ---- permission preview ----
|
|
115
138
|
if (panels.permission) {
|
|
116
139
|
out.push(`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}${ansi.clearLine}`)
|
|
@@ -122,7 +145,7 @@ export function renderFrame(state, agent, opts) {
|
|
|
122
145
|
// ---- queue preview ----
|
|
123
146
|
if (panels.queue) {
|
|
124
147
|
const preview = sliceByWidth(state.queue[0].text, W - 20)
|
|
125
|
-
out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D
|
|
148
|
+
out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D delete${ansi.reset}${ansi.clearLine}`)
|
|
126
149
|
}
|
|
127
150
|
|
|
128
151
|
// ---- input box ----
|
|
@@ -131,6 +154,9 @@ export function renderFrame(state, agent, opts) {
|
|
|
131
154
|
if (title === " Input " && isMultimodal) {
|
|
132
155
|
const hint = platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
|
|
133
156
|
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
157
|
+
} else if (title === " Input ") {
|
|
158
|
+
const hint = " Ctrl+U clear "
|
|
159
|
+
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
134
160
|
} else {
|
|
135
161
|
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
136
162
|
}
|
|
@@ -161,7 +187,7 @@ export function renderFrame(state, agent, opts) {
|
|
|
161
187
|
return { frame, cursorRow, cursorCol }
|
|
162
188
|
}
|
|
163
189
|
|
|
164
|
-
// ----------------------------------------------------------
|
|
190
|
+
// ---------------------------------------------------------- internal helpers
|
|
165
191
|
|
|
166
192
|
/** Count conversation lines after sanitize + wrap (for scroll clamping). Pure. */
|
|
167
193
|
export function countConvLines(state, cols) {
|
|
@@ -282,8 +308,8 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
282
308
|
const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
|
|
283
309
|
const ctxHint = ctxPct > 0
|
|
284
310
|
? ctxPct >= 80
|
|
285
|
-
? ` │ ${ansi.reset}${C.warn}
|
|
286
|
-
: ` │
|
|
311
|
+
? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}`
|
|
312
|
+
: ` │ context ${ctxPct}%`
|
|
287
313
|
: ""
|
|
288
314
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
289
315
|
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
package/src/tui/render.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui-render.mjs —
|
|
3
|
-
*
|
|
2
|
+
* tui-render.mjs — terminal display utilities (pure functions, zero dependencies)
|
|
3
|
+
* Character width calculation, CJK/emoji typesetting, text wrapping, markdown table reformatting.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/** Character display width: CJK/emoji count as 2, combining characters as 0, rest as 1 */
|
|
7
7
|
export function charWidth(cp) {
|
|
8
8
|
if (
|
|
9
|
-
(cp >= 0x300 && cp <= 0x36f) || //
|
|
10
|
-
(cp >= 0x200b && cp <= 0x200f) || //
|
|
11
|
-
cp === 0xfe0f // emoji
|
|
9
|
+
(cp >= 0x300 && cp <= 0x36f) || // combining diacritics
|
|
10
|
+
(cp >= 0x200b && cp <= 0x200f) || // zero-width
|
|
11
|
+
cp === 0xfe0f // emoji variation selector
|
|
12
12
|
) {
|
|
13
13
|
return 0
|
|
14
14
|
}
|
|
@@ -29,13 +29,14 @@ export function charWidth(cp) {
|
|
|
29
29
|
return 1
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Compute the display width of a string (CJK characters count as 2) */
|
|
32
33
|
export function stringWidth(text) {
|
|
33
34
|
let w = 0
|
|
34
35
|
for (const ch of text) w += charWidth(ch.codePointAt(0))
|
|
35
36
|
return w
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
+
/** Slice by display width */
|
|
39
40
|
export function sliceByWidth(text, maxWidth) {
|
|
40
41
|
let w = 0
|
|
41
42
|
let out = ""
|
|
@@ -48,19 +49,19 @@ export function sliceByWidth(text, maxWidth) {
|
|
|
48
49
|
return out
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/** Right-pad by display width */
|
|
52
53
|
function padByWidth(text, width) {
|
|
53
54
|
return text + " ".repeat(Math.max(0, width - stringWidth(text)))
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// ---------------------------------------------------------------- markdown
|
|
57
|
+
// ---------------------------------------------------------------- markdown table reformatting
|
|
57
58
|
|
|
58
59
|
const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
|
|
59
60
|
const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
-
* width
|
|
63
|
+
* Identify markdown table blocks in text, reformat by display width (fix CJK misalignment).
|
|
64
|
+
* width is the available display width; over-wide tables shrink columns. Non-table lines are kept as-is.
|
|
64
65
|
*/
|
|
65
66
|
export function formatTables(text, width) {
|
|
66
67
|
const lines = text.split("\n")
|
|
@@ -94,31 +95,31 @@ function renderTable(block, width) {
|
|
|
94
95
|
const colCount = Math.max(...rows.map((r) => r.length))
|
|
95
96
|
for (const r of rows) while (r.length < colCount) r.push("")
|
|
96
97
|
|
|
97
|
-
//
|
|
98
|
+
// Column widths: content first; if too wide, shrink from widest column (down to at least 3)
|
|
98
99
|
const widths = Array.from({ length: colCount }, (_, c) =>
|
|
99
100
|
Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
|
|
100
101
|
)
|
|
101
|
-
const borders = colCount * 3 + 1 // " │ "
|
|
102
|
+
const borders = colCount * 3 + 1 // " │ " separators + leading/trailing |
|
|
102
103
|
while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
|
|
103
104
|
const widest = widths.indexOf(Math.max(...widths))
|
|
104
105
|
widths[widest]--
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
//
|
|
108
|
+
// Cell rendering: sliceByWidth truncates (single-line for header), padByWidth pads
|
|
108
109
|
const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
|
|
109
110
|
const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
+
// separator line
|
|
112
113
|
const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
|
|
113
114
|
|
|
114
115
|
const out = []
|
|
115
|
-
//
|
|
116
|
+
// Header: single-line truncation (header labels are usually short, truncation beats wrapping)
|
|
116
117
|
out.push(fmtRow(rows[0]))
|
|
117
118
|
out.push(separator)
|
|
118
119
|
|
|
119
|
-
//
|
|
120
|
+
// Data rows: over-long cells wrap by column width; one logical row may produce multiple display lines
|
|
120
121
|
for (let r = 2; r < rows.length; r++) {
|
|
121
|
-
// wrapText
|
|
122
|
+
// wrapText returns array of lines wrapped by width, preserving internal \n
|
|
122
123
|
const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
|
|
123
124
|
const height = Math.max(...wrapped.map((lines) => lines.length))
|
|
124
125
|
for (let lineIdx = 0; lineIdx < height; lineIdx++) {
|
|
@@ -129,7 +130,7 @@ function renderTable(block, width) {
|
|
|
129
130
|
return out
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
/**
|
|
133
|
+
/** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width) */
|
|
133
134
|
export function layoutInput(chars, cursor, width) {
|
|
134
135
|
const PROMPT = "\u25b8 "
|
|
135
136
|
const lines = []
|
|
@@ -147,8 +148,6 @@ export function layoutInput(chars, cursor, width) {
|
|
|
147
148
|
}
|
|
148
149
|
for (let i = 0; i <= chars.length; i++) {
|
|
149
150
|
const ch = chars[i]
|
|
150
|
-
// 先处理 flush:宽字符可能触发换行,cursorCol 必须在 flush 之后取,
|
|
151
|
-
// 否则光标会停在上一行的末尾而非下一行的起始
|
|
152
151
|
if (ch !== undefined && ch !== "\n") {
|
|
153
152
|
const w = charWidth(ch.codePointAt(0))
|
|
154
153
|
if (col + w > avail()) flush()
|
|
@@ -171,8 +170,10 @@ export function layoutInput(chars, cursor, width) {
|
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
173
|
+
* Display sanitization: control characters can break terminal grid math (\r carriage return overwrite,
|
|
174
|
+
* \t width misjudgment causing entire frame misalignment, ANSI/bell screen floods).
|
|
175
|
+
* Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
|
|
176
|
+
* are also cleaned during replay.
|
|
176
177
|
*/
|
|
177
178
|
const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
|
|
178
179
|
export function sanitizeDisplay(s) {
|
|
@@ -185,7 +186,7 @@ export function sanitizeDisplay(s) {
|
|
|
185
186
|
.replace(/\n+$/, "")
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
/**
|
|
189
|
+
/** Wrap text by display width (preserving \n), returns array of lines */
|
|
189
190
|
export function wrapText(text, width) {
|
|
190
191
|
const lines = []
|
|
191
192
|
for (const rawLine of text.split("\n")) {
|