thincoder 0.12.2 → 0.12.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 +18 -5
- package/package.json +1 -1
- 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 +119 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +93 -5
- 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 +110 -150
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +22 -4
- 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/session.mjs +270 -89
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/codemode.mjs +10 -4
- 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 +169 -66
- package/src/tui/cmd-config.mjs +12 -0
- 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 +7 -6
- package/src/tui/key-handler.mjs +132 -4
- package/src/tui/layout.mjs +5 -5
- package/src/tui/pickers.mjs +184 -44
- package/src/tui/render-conversation.mjs +49 -11
- package/src/tui/render-frame.mjs +38 -12
- package/src/tui/render-loop.mjs +2 -1
- 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/cmd-exit.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** /exit command: exit TUI (same path as Ctrl+C).
|
|
2
2
|
* Direct synchronous process.exit — prevents the post-handler render()
|
|
3
3
|
* from redrawing the TUI over the cleaned terminal. The actual cleanup
|
|
4
|
-
* (
|
|
4
|
+
* (saveSession + closeMcp + terminal reset) runs once
|
|
5
5
|
* via the process.on("exit") handler registered in index.mjs.
|
|
6
6
|
*/
|
|
7
7
|
export async function handleExitCommand(_ctx) {
|
package/src/tui/cmd-fold.mjs
CHANGED
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
import { C } from "./ansi.mjs"
|
|
5
5
|
|
|
6
6
|
export async function handleFoldCommand(ctx, args = []) {
|
|
7
|
-
const { state } = ctx
|
|
7
|
+
const { state, pushLabel } = ctx
|
|
8
8
|
const arg = args[0]?.toLowerCase()
|
|
9
9
|
if (arg === "on") {
|
|
10
10
|
state.foldEnabled = true
|
|
11
|
-
ctx.pushLine("Folding: on (long tool results are collapsed)", C.dim)
|
|
12
11
|
} else if (arg === "off") {
|
|
13
12
|
state.foldEnabled = false
|
|
14
|
-
ctx.pushLine("Folding: off (all results shown in full)", C.dim)
|
|
15
13
|
} else {
|
|
16
14
|
state.foldEnabled = !state.foldEnabled
|
|
17
|
-
ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.dim)
|
|
18
15
|
}
|
|
16
|
+
pushLabel("❯ Fold", C.bold + C.tool)
|
|
17
|
+
ctx.pushLine(`Folding: ${state.foldEnabled ? "on" : "off"}`, C.tool)
|
|
19
18
|
ctx.render()
|
|
20
19
|
}
|
package/src/tui/cmd-model.mjs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/** /model command: open model picker, or switch provider directly via `/model <provider
|
|
3
|
+
/** /model command: open model picker, or switch provider directly via `/model <provider>[:model]`.
|
|
4
4
|
* ctx: { agent, openModelPicker, selectModel, pushLine } */
|
|
5
5
|
export async function handleModelCommand(ctx, args = []) {
|
|
6
|
-
const
|
|
7
|
-
if (!
|
|
6
|
+
const raw = args[0]?.toLowerCase()
|
|
7
|
+
if (!raw) {
|
|
8
8
|
ctx.openModelPicker().catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
9
9
|
return
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
// Parse "provider:model" syntax
|
|
12
|
+
const colonIdx = raw.indexOf(":")
|
|
13
|
+
const providerName = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw
|
|
14
|
+
const modelName = colonIdx >= 0 ? raw.slice(colonIdx + 1) : null
|
|
15
|
+
|
|
16
|
+
const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === providerName)
|
|
12
17
|
if (!target) {
|
|
13
18
|
const available = ctx.agent.providers.map((p) => p.name).join(", ")
|
|
14
|
-
ctx.pushLine(`Unknown provider: ${
|
|
19
|
+
ctx.pushLine(`Unknown provider: ${providerName} (available: ${available})`, C.error)
|
|
15
20
|
return
|
|
16
21
|
}
|
|
17
|
-
await ctx.selectModel({ provider: target.name, model: target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
22
|
+
await ctx.selectModel({ provider: target.name, model: modelName || target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
18
23
|
}
|
package/src/tui/cmd-new.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { newSession } from "../session.mjs"
|
|
2
2
|
import { C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
|
-
/** /new command: start new session
|
|
4
|
+
/** /new command: start a new session in a fresh slot.
|
|
5
5
|
* ctx: { agent, state, pushLine, showPicker, render } */
|
|
6
6
|
export async function handleNewCommand(ctx) {
|
|
7
7
|
const { agent, state, pushLine, showPicker, render } = ctx
|
|
8
8
|
|
|
9
9
|
const doNewSession = () => {
|
|
10
|
+
const slot = newSession(agent.cwd)
|
|
10
11
|
agent.history = []
|
|
11
12
|
agent.tasks = []
|
|
12
13
|
agent.planMode = false
|
|
@@ -15,14 +16,13 @@ export async function handleNewCommand(ctx) {
|
|
|
15
16
|
state.tasks = []
|
|
16
17
|
state.lines = []
|
|
17
18
|
state.streaming = ""
|
|
18
|
-
clearSession(agent.cwd)
|
|
19
19
|
render()
|
|
20
|
-
pushLine(
|
|
20
|
+
pushLine(`New session started (slot ${slot}; /session to switch back)`, C.dim)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (agent.history.length > 0) {
|
|
24
24
|
const e = await showPicker("Start new session?", [
|
|
25
|
-
{ type: "item", text: "Yes,
|
|
25
|
+
{ type: "item", text: "Yes, start new session in a new slot", action: "yes" },
|
|
26
26
|
{ type: "item", text: "Cancel", action: "no" },
|
|
27
27
|
], { defaultIndex: 1 })
|
|
28
28
|
if (e?.action === "yes") doNewSession()
|
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import { listSlots, switchToSlot, applySession } from "../session.mjs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
|
-
/** /session command: list/switch
|
|
4
|
+
/** /session command: list/switch session slots.
|
|
5
5
|
* ctx: { agent, state, showPicker, pushLine, pushLabel, render } */
|
|
6
6
|
export async function handleSessionCommand(ctx) {
|
|
7
7
|
const { agent, state, showPicker, pushLine, pushLabel, render } = ctx
|
|
8
8
|
const slots = listSlots(agent.cwd)
|
|
9
9
|
if (slots.length === 0) {
|
|
10
|
-
pushLine("No
|
|
10
|
+
pushLine("No sessions (use /new to start a new session)", C.dim)
|
|
11
11
|
return
|
|
12
12
|
}
|
|
13
|
+
const shortDate = (d) => {
|
|
14
|
+
const dt = new Date(d)
|
|
15
|
+
return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
|
|
16
|
+
}
|
|
17
|
+
const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + "…"
|
|
13
18
|
const entries = [
|
|
14
|
-
{ type: "header", text: `
|
|
15
|
-
...slots.map((s) =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
{ type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel)` },
|
|
20
|
+
...slots.map((s) => {
|
|
21
|
+
const preview = s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)"
|
|
22
|
+
const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
|
|
23
|
+
const when = shortDate(s.updatedAt)
|
|
24
|
+
const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
|
|
25
|
+
const marker = s.isActive ? " ●" : ""
|
|
26
|
+
return {
|
|
27
|
+
type: "item",
|
|
28
|
+
text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${preview}${model}${marker}`,
|
|
29
|
+
slot: s.slot,
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
20
32
|
]
|
|
21
33
|
const e = await showPicker("Sessions", entries)
|
|
22
34
|
if (!e) return
|
|
@@ -26,9 +38,7 @@ export async function handleSessionCommand(ctx) {
|
|
|
26
38
|
return
|
|
27
39
|
}
|
|
28
40
|
applySession(agent, data)
|
|
29
|
-
state.lines = data.display.length
|
|
30
|
-
? data.display.map((l) => ({ text: l.text, color: l.color }))
|
|
31
|
-
: []
|
|
41
|
+
state.lines = data.display.length ? [...data.display] : []
|
|
32
42
|
state.tasks = agent.tasks ?? []
|
|
33
43
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
34
44
|
state.tasks = []
|
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export async function handleThinkCommand(ctx, args = []) {
|
|
|
74
74
|
if (e.action === "auto") {
|
|
75
75
|
const newAuto = agent.config?.agent?.autoThink === true
|
|
76
76
|
pushLine(`Auto-think: ${newAuto ? "ON" : "OFF"}`, C.tool)
|
|
77
|
+
return // exit loop — no useful actions remain when auto mode just changed
|
|
77
78
|
} else if (e.action === "effort") {
|
|
78
79
|
pushLine(`Reasoning effort: ${e.level}`, C.tool)
|
|
79
80
|
} else {
|
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"
|
|
@@ -59,6 +59,7 @@ 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: [],
|
|
@@ -78,14 +79,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
78
79
|
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
79
80
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
80
81
|
completion: null, // Tab completion state { candidates, index }
|
|
81
|
-
toolStreams: {}, // per-tool live output (isolated by tool name, parallel tools don't interleave)
|
|
82
82
|
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
83
|
currentTool: null, // currently executing tool name (shown in status bar)
|
|
85
84
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
86
85
|
status: "Ready",
|
|
87
86
|
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
88
87
|
interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
|
|
88
|
+
search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
|
|
89
89
|
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
90
90
|
foldEnabled: true, // global fold toggle — /fold on|off
|
|
91
91
|
}
|
|
@@ -195,9 +195,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
195
195
|
const cleanup = () => {
|
|
196
196
|
if (cleanedUp) return
|
|
197
197
|
cleanedUp = true
|
|
198
|
-
// Save session before exit (synchronous write)
|
|
198
|
+
// Save session before exit (synchronous write).
|
|
199
|
+
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
200
|
+
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
199
201
|
try {
|
|
200
|
-
archiveCurrent(agent.cwd)
|
|
201
202
|
saveSession(agent, state.lines)
|
|
202
203
|
} catch {
|
|
203
204
|
// Save failure shouldn't block exit
|
|
@@ -353,7 +354,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
353
354
|
agent, state, render, popPicker, renderPickerLines,
|
|
354
355
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
355
356
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
356
|
-
renderWizard, pushLine, cleanup,
|
|
357
|
+
renderWizard, pushLine, cleanup, showPicker,
|
|
357
358
|
})
|
|
358
359
|
keyStream.on("keypress", (str, key) => {
|
|
359
360
|
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)
|
|
@@ -102,7 +147,66 @@ export function createKeyHandler(ctx) {
|
|
|
102
147
|
return
|
|
103
148
|
}
|
|
104
149
|
|
|
105
|
-
|
|
150
|
+
// Search mode: Ctrl+F to enter, Esc/n/N to navigate/exit
|
|
151
|
+
if (key.ctrl && key.name === "f" && !state.permission && !state.question) {
|
|
152
|
+
if (!state.search) {
|
|
153
|
+
state.search = { query: "", matches: [], index: 0 }
|
|
154
|
+
}
|
|
155
|
+
render()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (state.search) {
|
|
160
|
+
if (key.name === "escape") {
|
|
161
|
+
state.search = null
|
|
162
|
+
render()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (key.name === "n" || (key.ctrl && key.name === "g")) {
|
|
166
|
+
// Next match
|
|
167
|
+
if (state.search.matches.length > 0) {
|
|
168
|
+
state.search.index = (state.search.index + 1) % state.search.matches.length
|
|
169
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
170
|
+
}
|
|
171
|
+
render()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (key.name === "p" || (key.ctrl && key.name === "r")) {
|
|
175
|
+
// Previous match
|
|
176
|
+
if (state.search.matches.length > 0) {
|
|
177
|
+
state.search.index = (state.search.index - 1 + state.search.matches.length) % state.search.matches.length
|
|
178
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
179
|
+
}
|
|
180
|
+
render()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (key.name === "return") {
|
|
184
|
+
// Exit search mode but keep highlighting until next Ctrl+F
|
|
185
|
+
state.search = null
|
|
186
|
+
render()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (key.name === "backspace") {
|
|
190
|
+
if (state.search.query.length > 0) {
|
|
191
|
+
state.search.query = state.search.query.slice(0, -1)
|
|
192
|
+
performSearch(state)
|
|
193
|
+
render()
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
// Regular character input
|
|
198
|
+
if (str && str.length === 1 && !key.ctrl && !key.alt && !key.meta) {
|
|
199
|
+
state.search.query += str
|
|
200
|
+
performSearch(state)
|
|
201
|
+
if (state.search.matches.length > 0) {
|
|
202
|
+
scrollToMatch(state, state.search.matches[state.search.index].lineIndex)
|
|
203
|
+
}
|
|
204
|
+
render()
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (key.ctrl && key.name === "c") {
|
|
106
210
|
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
107
211
|
if (state.picker) {
|
|
108
212
|
popPicker(null)
|
|
@@ -120,6 +224,23 @@ export function createKeyHandler(ctx) {
|
|
|
120
224
|
ctx.exitTimer.unref?.()
|
|
121
225
|
}
|
|
122
226
|
|
|
227
|
+
// F1: 显示快捷键帮助
|
|
228
|
+
if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
|
|
229
|
+
showPicker("Keyboard Shortcuts", [
|
|
230
|
+
{ type: "item", text: "Ctrl+C — Cancel/Abort current operation" },
|
|
231
|
+
{ type: "item", text: "Ctrl+I — Interrupt and inject message" },
|
|
232
|
+
{ type: "item", text: "Ctrl+F — Search conversation history" },
|
|
233
|
+
{ type: "item", text: "Ctrl+L — Clear screen" },
|
|
234
|
+
{ type: "item", text: "Ctrl+U — Clear input line" },
|
|
235
|
+
{ type: "item", text: "Esc — Cancel current input/picker" },
|
|
236
|
+
{ type: "item", text: "↑/↓ — Navigate input history" },
|
|
237
|
+
{ type: "item", text: "PgUp/PgDn — Scroll conversation" },
|
|
238
|
+
{ type: "item", text: "Enter — Send message or confirm selection" },
|
|
239
|
+
{ type: "item", text: "F1 — Show this help" },
|
|
240
|
+
])
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
123
244
|
// Ctrl+I (or Tab during processing): interrupt and inject a message
|
|
124
245
|
if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
|
|
125
246
|
if (state.processing && state.controller && !state.interruptPrompt) {
|
|
@@ -335,7 +456,14 @@ export function createKeyHandler(ctx) {
|
|
|
335
456
|
return
|
|
336
457
|
}
|
|
337
458
|
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
338
|
-
|
|
459
|
+
if (key.shift) {
|
|
460
|
+
// Shift+Enter: insert newline for multiline input
|
|
461
|
+
state.input.splice(state.cursor, 0, '\n')
|
|
462
|
+
state.cursor++
|
|
463
|
+
render()
|
|
464
|
+
} else {
|
|
465
|
+
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
466
|
+
}
|
|
339
467
|
return
|
|
340
468
|
}
|
|
341
469
|
|
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 = []
|