thincoder 0.12.2 → 0.12.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 +29 -6
- package/package.json +3 -3
- package/src/advisor/history.mjs +112 -0
- package/src/advisor/messages.mjs +182 -0
- package/src/advisor/repos.mjs +133 -0
- package/src/advisor/run.mjs +346 -0
- package/src/advisor.mjs +109 -509
- package/src/agent/completion.mjs +134 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +95 -6
- package/src/agent-tools/advisor.mjs +159 -12
- package/src/agent-tools/eng.mjs +64 -0
- package/src/agent-tools/subagent.mjs +73 -3
- package/src/agent-tools/task.mjs +45 -6
- package/src/agent-tools/verify.mjs +18 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +152 -161
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +34 -4
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/advisor-design.md +43 -0
- package/src/prompts/advisor-round1.md +11 -4
- package/src/prompts/advisor-round2.md +12 -7
- package/src/prompts/advisor-round3.md +11 -6
- package/src/prompts/coder.md +9 -3
- package/src/prompts/discipline.md +12 -96
- package/src/prompts/eng-coder.md +34 -0
- package/src/prompts/engineering-sub.md +12 -0
- package/src/prompts/engineering.md +96 -0
- package/src/prompts/main.md +1 -1
- package/src/prompts/methodology-template.md +39 -0
- package/src/prompts/plan.md +2 -2
- package/src/prompts/system.md +43 -61
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +291 -94
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +23 -11
- package/src/tools/delete.md +1 -0
- package/src/tools/edit.md +1 -1
- package/src/tools/execute.md +5 -0
- package/src/tools/file.mjs +4 -0
- package/src/tools/git.md +15 -0
- package/src/tools/git.mjs +1 -6
- package/src/tools/lint.md +8 -0
- package/src/tools/linter.mjs +1 -5
- package/src/tools/lsp.md +7 -0
- package/src/tools/lsp.mjs +8 -9
- package/src/tools/patch.mjs +1 -29
- package/src/tools/read_image.md +5 -1
- package/src/tools/system.mjs +1 -1
- package/src/tools/web.mjs +3 -3
- package/src/tui/agent-turn.mjs +184 -66
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +14 -26
- package/src/tui/cmd-eng.mjs +44 -0
- package/src/tui/cmd-exit.mjs +1 -1
- package/src/tui/cmd-fold.mjs +3 -4
- package/src/tui/cmd-model.mjs +11 -6
- package/src/tui/cmd-new.mjs +5 -5
- package/src/tui/cmd-session.mjs +21 -11
- package/src/tui/cmd-think.mjs +1 -0
- package/src/tui/index.mjs +20 -9
- package/src/tui/key-handler.mjs +177 -9
- package/src/tui/layout.mjs +5 -5
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +190 -45
- package/src/tui/render-conversation.mjs +54 -13
- package/src/tui/render-frame.mjs +39 -12
- package/src/tui/render-loop.mjs +2 -1
- package/src/tui/render.mjs +13 -7
- package/src/tui/slash-commands.mjs +11 -7
- package/src/tui/startup.mjs +4 -3
- package/src/tui/wizard.mjs +3 -0
- package/src/tools/checkpoint.md +0 -15
- package/src/tools/git_diff.md +0 -11
- package/src/tools/git_log.md +0 -10
- package/src/tools/git_status.md +0 -8
- package/src/tools/linter.md +0 -13
- package/src/tools/syntax_check.md +0 -10
package/src/tui/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { emitKeypressEvents } from "node:readline"
|
|
20
20
|
import { PassThrough } from "node:stream"
|
|
21
|
-
import { saveSession
|
|
21
|
+
import { saveSession } from "../session.mjs"
|
|
22
22
|
import { closeAllMcp } from "../mcp.mjs"
|
|
23
23
|
import { ansi, C } from "./ansi.mjs"
|
|
24
24
|
import { createRenderLoop } from "./render-loop.mjs"
|
|
@@ -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 } from "./clipboard.mjs"
|
|
30
|
+
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
|
|
31
31
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
32
32
|
import { createKeyHandler } from "./key-handler.mjs"
|
|
33
33
|
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
@@ -59,10 +59,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
59
59
|
const state = {
|
|
60
60
|
lines: [], // conversation lines: { text, color }
|
|
61
61
|
streaming: "", // current streaming buffer
|
|
62
|
+
advisorStreaming: "", // advisor streaming buffer (formatted like main response)
|
|
62
63
|
input: [], // input buffer (codepoint array)
|
|
63
64
|
cursor: 0,
|
|
64
65
|
history: [],
|
|
65
66
|
historyIndex: -1,
|
|
67
|
+
_draft: null, // stashed unsent input while navigating history (restored on down past newest)
|
|
66
68
|
scroll: 0, // scroll lines from bottom upward
|
|
67
69
|
processing: false,
|
|
68
70
|
controller: null, // AbortController for current agent run
|
|
@@ -78,16 +80,16 @@ export async function startTUI(agent, opts = {}) {
|
|
|
78
80
|
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
79
81
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
80
82
|
completion: null, // Tab completion state { candidates, index }
|
|
81
|
-
toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
|
|
82
83
|
subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
|
|
83
|
-
outputPanels: {}, // tool output panels: { toolName: { parts: [{kind, text}], len, done, closeAt } } — streamed live during execution, kept visible for a grace period after completion
|
|
84
84
|
currentTool: null, // currently executing tool name (shown in status bar)
|
|
85
85
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
86
86
|
status: "Ready",
|
|
87
87
|
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
88
88
|
interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
|
|
89
|
+
search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
|
|
89
90
|
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
90
91
|
foldEnabled: true, // global fold toggle — /fold on|off
|
|
92
|
+
exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
@@ -108,7 +110,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
108
110
|
const startupRows = process.stdout.rows || 24
|
|
109
111
|
emitKeypressEvents(keyStream)
|
|
110
112
|
process.stdin.setRawMode(true)
|
|
111
|
-
|
|
113
|
+
// Keyboard enhancement — enable BOTH protocols (unsupported terminals ignore them):
|
|
114
|
+
// kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
|
|
115
|
+
// modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
|
|
116
|
+
// translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
|
|
117
|
+
process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn)
|
|
112
118
|
|
|
113
119
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
114
120
|
|
|
@@ -181,6 +187,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
181
187
|
text = text.slice(0, -tail[0].length)
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
// Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
|
|
191
|
+
text = translateShiftEnter(text)
|
|
192
|
+
|
|
184
193
|
if (state.scroll !== lastRenderedScroll) {
|
|
185
194
|
lastRenderedScroll = state.scroll
|
|
186
195
|
render()
|
|
@@ -195,9 +204,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
195
204
|
const cleanup = () => {
|
|
196
205
|
if (cleanedUp) return
|
|
197
206
|
cleanedUp = true
|
|
198
|
-
// Save session before exit (synchronous write)
|
|
207
|
+
// Save session before exit (synchronous write).
|
|
208
|
+
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
209
|
+
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
199
210
|
try {
|
|
200
|
-
archiveCurrent(agent.cwd)
|
|
201
211
|
saveSession(agent, state.lines)
|
|
202
212
|
} catch {
|
|
203
213
|
// Save failure shouldn't block exit
|
|
@@ -209,7 +219,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
209
219
|
// Can't close? fine, process is exiting anyway
|
|
210
220
|
}
|
|
211
221
|
process.stdin.setRawMode(false)
|
|
212
|
-
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
222
|
+
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
213
223
|
}
|
|
214
224
|
process.on("exit", cleanup)
|
|
215
225
|
|
|
@@ -259,6 +269,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
259
269
|
state.cursor = 0
|
|
260
270
|
state.history.push(text)
|
|
261
271
|
state.historyIndex = -1
|
|
272
|
+
state._draft = null // submitted — the draft is now history
|
|
262
273
|
state.scroll = 0
|
|
263
274
|
|
|
264
275
|
// Slash commands: handled locally, don't enter agent loop
|
|
@@ -353,7 +364,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
353
364
|
agent, state, render, popPicker, renderPickerLines,
|
|
354
365
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
355
366
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
356
|
-
renderWizard, pushLine, cleanup,
|
|
367
|
+
renderWizard, pushLine, cleanup, showPicker,
|
|
357
368
|
})
|
|
358
369
|
keyStream.on("keypress", (str, key) => {
|
|
359
370
|
try {
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -2,14 +2,59 @@ import { ansi, C } from "./ansi.mjs"
|
|
|
2
2
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
3
|
import { computeLayout } from "./layout.mjs"
|
|
4
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
|
+
}
|
|
49
|
+
|
|
5
50
|
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
6
51
|
* Extracted from index.mjs.
|
|
7
52
|
* ctx: { agent, state, render, renderPickerLines, popPicker,
|
|
8
53
|
* handleSlash, handleTab, submit, pasteClipboardImage,
|
|
9
54
|
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
10
|
-
* renderWizard, pushLine, cleanup } */
|
|
55
|
+
* renderWizard, pushLine, cleanup, showPicker } */
|
|
11
56
|
export function createKeyHandler(ctx) {
|
|
12
|
-
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
|
|
57
|
+
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker } = ctx
|
|
13
58
|
|
|
14
59
|
return function onKeypress(str, key = {}) {
|
|
15
60
|
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
@@ -24,6 +69,8 @@ export function createKeyHandler(ctx) {
|
|
|
24
69
|
state.status = "Processing..."
|
|
25
70
|
if (answer === "a" && !isContinue) {
|
|
26
71
|
agent.autoApprove = true
|
|
72
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
73
|
+
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
27
74
|
pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
|
|
28
75
|
}
|
|
29
76
|
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
@@ -102,7 +149,71 @@ export function createKeyHandler(ctx) {
|
|
|
102
149
|
return
|
|
103
150
|
}
|
|
104
151
|
|
|
105
|
-
|
|
152
|
+
// 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
|
+
}
|
|
215
|
+
|
|
216
|
+
if (key.ctrl && key.name === "c") {
|
|
106
217
|
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
107
218
|
if (state.picker) {
|
|
108
219
|
popPicker(null)
|
|
@@ -114,12 +225,41 @@ export function createKeyHandler(ctx) {
|
|
|
114
225
|
render()
|
|
115
226
|
return
|
|
116
227
|
}
|
|
228
|
+
// 防误触:空闲态第一次 Ctrl+C 仅提示并武装,窗口内再按才真正退出
|
|
229
|
+
if (!state.exitArmed) {
|
|
230
|
+
state.exitArmed = true
|
|
231
|
+
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
232
|
+
ctx.exitArmTimer = setTimeout(() => { state.exitArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
233
|
+
ctx.exitArmTimer.unref?.()
|
|
234
|
+
pushLine("[exit] Press Ctrl+C again within 3s to exit", C.warn)
|
|
235
|
+
render()
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
117
239
|
cleanup()
|
|
118
240
|
// 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
|
|
119
241
|
ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
|
|
120
242
|
ctx.exitTimer.unref?.()
|
|
121
243
|
}
|
|
122
244
|
|
|
245
|
+
// F1: 显示快捷键帮助
|
|
246
|
+
if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
|
|
247
|
+
showPicker("Keyboard Shortcuts", [
|
|
248
|
+
{ type: "item", text: "Ctrl+C — Cancel/Abort; idle: press twice to exit" },
|
|
249
|
+
{ type: "item", text: "Ctrl+I — Interrupt and inject message" },
|
|
250
|
+
{ type: "item", text: "Ctrl+F — Search conversation history" },
|
|
251
|
+
{ type: "item", text: "Shift+Enter / Ctrl+J — Insert newline (multiline input)" },
|
|
252
|
+
{ type: "item", text: "Alt+V — Paste clipboard image" },
|
|
253
|
+
{ type: "item", text: "Ctrl+U — Clear input line" },
|
|
254
|
+
{ type: "item", text: "Esc — Cancel current input/picker" },
|
|
255
|
+
{ type: "item", text: "↑/↓ — Navigate input history" },
|
|
256
|
+
{ type: "item", text: "PgUp/PgDn — Scroll conversation" },
|
|
257
|
+
{ type: "item", text: "Enter — Send message or confirm selection" },
|
|
258
|
+
{ type: "item", text: "F1 — Show this help" },
|
|
259
|
+
])
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
123
263
|
// Ctrl+I (or Tab during processing): interrupt and inject a message
|
|
124
264
|
if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
|
|
125
265
|
if (state.processing && state.controller && !state.interruptPrompt) {
|
|
@@ -274,6 +414,11 @@ export function createKeyHandler(ctx) {
|
|
|
274
414
|
// input history
|
|
275
415
|
if (key.name === "up") {
|
|
276
416
|
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) {
|
|
420
|
+
state._draft = [...state.input]
|
|
421
|
+
}
|
|
277
422
|
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
278
423
|
state.input = [...state.history[state.historyIndex]]
|
|
279
424
|
state.cursor = state.input.length
|
|
@@ -286,7 +431,9 @@ export function createKeyHandler(ctx) {
|
|
|
286
431
|
state.historyIndex++
|
|
287
432
|
if (state.historyIndex >= state.history.length) {
|
|
288
433
|
state.historyIndex = -1
|
|
289
|
-
|
|
434
|
+
// Restore the stashed draft instead of wiping back to blank
|
|
435
|
+
state.input = state._draft ? [...state._draft] : []
|
|
436
|
+
state._draft = null
|
|
290
437
|
} else {
|
|
291
438
|
state.input = [...state.history[state.historyIndex]]
|
|
292
439
|
}
|
|
@@ -335,12 +482,28 @@ export function createKeyHandler(ctx) {
|
|
|
335
482
|
return
|
|
336
483
|
}
|
|
337
484
|
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
338
|
-
|
|
485
|
+
// Multiline newline — three entry points (docs/design/TUI-INPUT-BOX.md §1.5):
|
|
486
|
+
// 1. Alt+Enter: readline parses \x1b\r as meta+return (all terminals)
|
|
487
|
+
// 2. Shift+Enter: keyboard-enhanced terminals send \x1b[13;2u / \x1b[27;2;13~,
|
|
488
|
+
// translateShiftEnter maps them to \x1b\r → also meta+return
|
|
489
|
+
// 3. Ctrl+J: sends \n (0x0A), readline parses as name:"enter" — the universal
|
|
490
|
+
// fallback: \n and \r are distinct bytes in EVERY terminal, no protocol needed
|
|
491
|
+
// (legacy conhost users: Shift+Enter is a bare \r there, physically
|
|
492
|
+
// indistinguishable from Enter — Ctrl+J is their newline key)
|
|
493
|
+
if ((key.name === "return" && key.meta) || key.name === "enter") {
|
|
494
|
+
state.input.splice(state.cursor, 0, "\n")
|
|
495
|
+
state.cursor++
|
|
496
|
+
render()
|
|
497
|
+
} else {
|
|
498
|
+
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
499
|
+
}
|
|
339
500
|
return
|
|
340
501
|
}
|
|
341
502
|
|
|
342
|
-
// Ctrl+V: paste clipboard text into the active text target
|
|
343
|
-
|
|
503
|
+
// Ctrl+V: paste clipboard text into the active text target.
|
|
504
|
+
// Exclude meta (ESC-prefix) so Ctrl+Alt+V — which readline reports as ctrl+meta —
|
|
505
|
+
// falls through to the image-paste branch below instead of being eaten here.
|
|
506
|
+
if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
|
|
344
507
|
;(async () => {
|
|
345
508
|
const text = await readClipboardText()
|
|
346
509
|
if (text) {
|
|
@@ -351,8 +514,13 @@ export function createKeyHandler(ctx) {
|
|
|
351
514
|
return
|
|
352
515
|
}
|
|
353
516
|
|
|
354
|
-
//
|
|
355
|
-
|
|
517
|
+
// Alt+V / Ctrl+Alt+V: paste clipboard image.
|
|
518
|
+
// NOTE: readline reports ESC-prefixed combos as key.meta, NOT key.alt (probe-verified:
|
|
519
|
+
// \x1b + char → { meta: true, alt: false }). Checking key.alt alone is a dead branch —
|
|
520
|
+
// the key fell through to the printable handler which ignores meta keys, so image paste
|
|
521
|
+
// silently did nothing. Accept meta (and alt, for terminals that set it); the text-paste
|
|
522
|
+
// branch above excludes meta so Ctrl+Alt+V reaches here.
|
|
523
|
+
const isPasteImage = key.name === "v" && (key.alt || key.meta)
|
|
356
524
|
if (isPasteImage) {
|
|
357
525
|
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
358
526
|
return
|
package/src/tui/layout.mjs
CHANGED
|
@@ -22,8 +22,8 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
22
22
|
const W = Math.max(20, cols - 1)
|
|
23
23
|
|
|
24
24
|
// --- input box ---
|
|
25
|
-
const inputBuf = state.interruptPrompt ? [...state.interruptPrompt.text] : state.input
|
|
26
|
-
const inputCursor = state.interruptPrompt ? inputBuf.length : state.cursor
|
|
25
|
+
const inputBuf = state.search ? [...state.search.query] : (state.interruptPrompt ? [...state.interruptPrompt.text] : state.input)
|
|
26
|
+
const inputCursor = state.search ? inputBuf.length : (state.interruptPrompt ? inputBuf.length : state.cursor)
|
|
27
27
|
const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
|
|
28
28
|
let inputOffset = 0
|
|
29
29
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
@@ -69,10 +69,10 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
69
69
|
? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
|
|
70
70
|
: 0
|
|
71
71
|
|
|
72
|
-
// Tool output panels: max
|
|
72
|
+
// Tool output panels: max 9 lines per panel (1 title + 8 content), capped at reasonable total.
|
|
73
73
|
// Done panels stay visible until their closeAt grace elapses (render loop prunes them).
|
|
74
|
-
const panels = Object.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
75
|
-
const outputPanelsH = panels.length > 0 ? Math.min(panels.length *
|
|
74
|
+
const panels = Object.values(state.outputPanels ?? {}).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
75
|
+
const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 9, rows - 10) : 0
|
|
76
76
|
|
|
77
77
|
// Permission preview (height depends on wrapped content)
|
|
78
78
|
let permPreviewLines = []
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown.mjs — lightweight inline markdown rendering for the TUI display layer.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies, display-only: turns raw markdown markers into ANSI styling so
|
|
5
|
+
* model replies stop showing literal `**`, `##`, backtick markers (IK5VW3).
|
|
6
|
+
*
|
|
7
|
+
* Design constraints:
|
|
8
|
+
* - Operates on ALREADY-WRAPPED single lines (call after wrapText): inserting ANSI
|
|
9
|
+
* here cannot break width math, because wrapping already happened.
|
|
10
|
+
* - Uses narrow-scope SGR resets (22 = bold off, 27 = reverse off, 29 = strikethrough off)
|
|
11
|
+
* instead of reset(0), so the line's base color (C.text etc.) survives.
|
|
12
|
+
* - Code spans are extracted FIRST: anything inside backticks is styled as code and
|
|
13
|
+
* its `**`/`__` markers are NOT interpreted (markdown semantics).
|
|
14
|
+
* - Unclosed markers are left as-is (streaming safety: mid-token `**bo` renders literally).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BOLD = "\x1b[1m"
|
|
18
|
+
const BOLD_OFF = "\x1b[22m"
|
|
19
|
+
const REVERSE = "\x1b[7m"
|
|
20
|
+
const REVERSE_OFF = "\x1b[27m"
|
|
21
|
+
const STRIKE = "\x1b[9m"
|
|
22
|
+
const STRIKE_OFF = "\x1b[29m"
|
|
23
|
+
|
|
24
|
+
/** Render inline markers on a single text line: `code` spans, **bold**, __bold__, ~~strike~~. */
|
|
25
|
+
export function renderMarkdownInline(line) {
|
|
26
|
+
if (!line || line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("_") === -1 && line.indexOf("~") === -1) {
|
|
27
|
+
return line
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Split on backticks: even indexes are plain text (bold/strike processed),
|
|
31
|
+
// odd indexes are code spans (styled as-is, markers inside untouched).
|
|
32
|
+
const parts = line.split("`")
|
|
33
|
+
let out = ""
|
|
34
|
+
for (let i = 0; i < parts.length; i++) {
|
|
35
|
+
if (i % 2 === 1) {
|
|
36
|
+
out += REVERSE + parts[i] + REVERSE_OFF
|
|
37
|
+
} else {
|
|
38
|
+
out += parts[i]
|
|
39
|
+
.replace(/\*\*([^*\n]+)\*\*/g, `${BOLD}$1${BOLD_OFF}`)
|
|
40
|
+
.replace(/__([^_\n]+)__/g, `${BOLD}$1${BOLD_OFF}`)
|
|
41
|
+
.replace(/~~([^~\n]+)~~/g, `${STRIKE}$1${STRIKE_OFF}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Render a heading line: strip leading `#` markers and bold the whole line. Returns original when not a heading. */
|
|
48
|
+
export function renderMarkdownHeading(line) {
|
|
49
|
+
const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line)
|
|
50
|
+
if (!m || !m[2]) return line
|
|
51
|
+
return `${BOLD}${m[2]}${BOLD_OFF}`
|
|
52
|
+
}
|