thincoder 0.11.1 → 0.12.1
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 +11 -0
- package/package.json +1 -1
- package/src/advisor.mjs +238 -31
- package/src/agent/helpers.mjs +11 -2
- package/src/agent.mjs +14 -7
- package/src/cli/make-agent.mjs +20 -0
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +6 -5
- package/src/prompts/advisor-round2.md +4 -4
- package/src/prompts/advisor-round3.md +3 -3
- package/src/prompts/discipline.md +1 -1
- package/src/skills.mjs +67 -31
- package/src/tools/shared.mjs +27 -2
- package/src/tui/agent-turn.mjs +57 -12
- package/src/tui/cmd-advisor.mjs +190 -95
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-exit.mjs +17 -3
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +2 -1
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +0 -2
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-frame.mjs +67 -65
- package/src/tui/render-loop.mjs +43 -114
package/src/tui/cmd-exit.mjs
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
/** /exit command: exit TUI.
|
|
2
|
-
* ctx: {
|
|
1
|
+
/** /exit command: exit TUI (same path as Ctrl+C).
|
|
2
|
+
* ctx: { agent, state } */
|
|
3
|
+
import { saveSession, archiveCurrent } from "../session.mjs"
|
|
4
|
+
import { closeAllMcp } from "../mcp.mjs"
|
|
5
|
+
import { ansi } from "./ansi.mjs"
|
|
6
|
+
|
|
3
7
|
export async function handleExitCommand(ctx) {
|
|
4
|
-
ctx
|
|
8
|
+
const { agent, state } = ctx
|
|
9
|
+
// Same cleanup sequence as Ctrl+C in key-handler.mjs
|
|
10
|
+
try {
|
|
11
|
+
archiveCurrent(agent.cwd)
|
|
12
|
+
saveSession(agent, state.lines)
|
|
13
|
+
} catch { /* save failure shouldn't block exit */ }
|
|
14
|
+
try { closeAllMcp(agent) } catch { /* exiting anyway */ }
|
|
15
|
+
process.stdin.setRawMode(false)
|
|
16
|
+
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
17
|
+
// Exit synchronously — prevents post-handler render() from redrawing the TUI
|
|
18
|
+
process.exit(0)
|
|
5
19
|
}
|
package/src/tui/cmd-mcp.mjs
CHANGED
|
@@ -21,7 +21,7 @@ async function addAndConnect(ctx, srv) {
|
|
|
21
21
|
const entry = { name: srv.name }
|
|
22
22
|
if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
|
|
23
23
|
else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.headers) entry.headers = srv.headers }
|
|
24
|
-
else { entry.command = srv.command; if (srv.args) entry.args = srv.args }
|
|
24
|
+
else { entry.command = srv.command; if (srv.args) entry.args = srv.args; if (srv.env) entry.env = srv.env }
|
|
25
25
|
raw.mcp.servers.push(entry)
|
|
26
26
|
})
|
|
27
27
|
agent.config ??= {}
|
|
@@ -136,7 +136,9 @@ Return ONLY the JSON object:`,
|
|
|
136
136
|
if (!cmd) return
|
|
137
137
|
const argsInput = await askQuestion("Arguments (space-separated, or leave empty):")
|
|
138
138
|
const cmdArgs = argsInput ? argsInput.split(/\s+/) : undefined
|
|
139
|
-
await
|
|
139
|
+
const envInput = await askQuestion("Environment variables (KEY=value, space-separated, or leave empty):")
|
|
140
|
+
const env = envInput ? parseHeaders(envInput.split(/\s+/)) : undefined
|
|
141
|
+
await addAndConnect(ctx, { name, command: cmd, args: cmdArgs, env })
|
|
140
142
|
} else {
|
|
141
143
|
const urlPrompt = transport === "ws" ? "WebSocket URL (ws://…):" : "HTTP URL (https://…):"
|
|
142
144
|
const url = await askQuestion(urlPrompt)
|
package/src/tui/cmd-plan.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/** /plan command: toggle plan mode (read-only explore → design → implement).
|
|
2
|
-
* ctx: { agent } */
|
|
2
|
+
* ctx: { agent, pushLine, pushLabel } */
|
|
3
|
+
import { ansi, C } from "./ansi.mjs"
|
|
4
|
+
|
|
3
5
|
export async function handlePlanCommand(ctx) {
|
|
4
|
-
const { agent } = ctx
|
|
6
|
+
const { agent, pushLine, pushLabel } = ctx
|
|
5
7
|
agent.planMode = !agent.planMode
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
agent._pendingReminders.push("[System reminder: plan mode is now ON. You are restricted to READ-ONLY tools — explore, search, read, analyze. DO NOT write, edit, or run mutation commands. Present your design to the user first.]")
|
|
9
|
-
} else {
|
|
10
|
-
agent._pendingReminders.push("[System reminder: plan mode is now OFF. You may edit files, run commands, and implement changes.]")
|
|
11
|
-
}
|
|
8
|
+
pushLabel("❯ Plan", ansi.bold + C.tool)
|
|
9
|
+
pushLine(`Plan mode: ${agent.planMode ? "ON" : "OFF"}`, C.tool)
|
|
12
10
|
}
|
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -1,68 +1,27 @@
|
|
|
1
|
-
import { C } from "./ansi.mjs"
|
|
2
|
-
|
|
3
1
|
/** /think command: toggle thinking mode, set reasoning effort.
|
|
4
|
-
*
|
|
5
|
-
* ctx: { agent, showPicker, syncProviderField, pushLine } */
|
|
2
|
+
* Interactive loop UX — stays in menu after each action, Esc to exit.
|
|
3
|
+
* ctx: { agent, showPicker, syncProviderField, pushLine, pushLabel } */
|
|
4
|
+
import { ansi, C } from "./ansi.mjs"
|
|
5
|
+
|
|
6
6
|
export async function handleThinkCommand(ctx, args = []) {
|
|
7
|
-
const { agent, showPicker, syncProviderField, pushLine } = ctx
|
|
8
|
-
const cur = agent.provider
|
|
7
|
+
const { agent, showPicker, syncProviderField, pushLine, pushLabel } = ctx
|
|
9
8
|
const { specForModel } = await import("../config.mjs")
|
|
9
|
+
|
|
10
|
+
// Fast path: direct args — exit immediately
|
|
11
|
+
const cur = agent.provider
|
|
10
12
|
const spec = specForModel(cur.model)
|
|
11
13
|
const isEffortOnly = spec.thinkApi === "effort"
|
|
12
14
|
const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
|
|
13
15
|
const isCustomThink = thinkOnValue !== "enabled"
|
|
14
16
|
const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
|
|
15
17
|
|
|
16
|
-
async function apply(e) {
|
|
17
|
-
if (e.action === "auto") {
|
|
18
|
-
const cfg = agent.config.agent ??= {}
|
|
19
|
-
cfg.autoThink = !cfg.autoThink
|
|
20
|
-
agent._pendingReminders = agent._pendingReminders ?? []
|
|
21
|
-
if (cfg.autoThink) {
|
|
22
|
-
// Turn off manual effort — auto will set it per-turn
|
|
23
|
-
delete cur.reasoningEffort
|
|
24
|
-
await syncProviderField("reasoningEffort", undefined)
|
|
25
|
-
agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
|
|
26
|
-
} else {
|
|
27
|
-
agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
|
|
28
|
-
}
|
|
29
|
-
} else if (e.action === "effort") {
|
|
30
|
-
cur.reasoningEffort = e.level
|
|
31
|
-
await syncProviderField("reasoningEffort", e.level)
|
|
32
|
-
} else {
|
|
33
|
-
const enable = e.action === "on"
|
|
34
|
-
if (isEffortOnly) {
|
|
35
|
-
if (!enable) delete cur.reasoningEffort
|
|
36
|
-
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
37
|
-
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
38
|
-
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
39
|
-
} else {
|
|
40
|
-
if (enable) {
|
|
41
|
-
cur.thinking = { type: thinkOnValue }
|
|
42
|
-
if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
43
|
-
} else {
|
|
44
|
-
// Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
|
|
45
|
-
cur.thinking = isCustomThink ? undefined : { type: "disabled" }
|
|
46
|
-
delete cur.reasoningEffort
|
|
47
|
-
}
|
|
48
|
-
await syncProviderField("thinking", cur.thinking)
|
|
49
|
-
if (enable) {
|
|
50
|
-
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
51
|
-
} else {
|
|
52
|
-
await syncProviderField("reasoningEffort", undefined)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Direct args: /think on|off │ /think effort <level>
|
|
59
|
-
// autoThink 开启时手动值每轮被覆盖(picker 里也隐藏了开关/effort 项),直参同样拒绝
|
|
60
18
|
const autoThinkEnabled = agent.config?.agent?.autoThink === true
|
|
61
19
|
const sub = args[0]?.toLowerCase()
|
|
62
20
|
if (sub === "on" || sub === "off") {
|
|
63
21
|
if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
|
|
64
|
-
await
|
|
65
|
-
|
|
22
|
+
await applyThink({ action: sub }, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue)
|
|
23
|
+
pushLabel("❯ Think", ansi.bold + C.tool)
|
|
24
|
+
pushLine(`Thinking: ${sub}`, C.tool)
|
|
66
25
|
return
|
|
67
26
|
}
|
|
68
27
|
if (sub === "effort") {
|
|
@@ -72,29 +31,93 @@ export async function handleThinkCommand(ctx, args = []) {
|
|
|
72
31
|
return
|
|
73
32
|
}
|
|
74
33
|
if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
|
|
75
|
-
await
|
|
76
|
-
|
|
34
|
+
await applyThink({ action: "effort", level }, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue)
|
|
35
|
+
pushLabel("❯ Think", ansi.bold + C.tool)
|
|
36
|
+
pushLine(`Thinking effort: ${level}`, C.tool)
|
|
77
37
|
return
|
|
78
38
|
}
|
|
79
39
|
if (sub) { pushLine("Usage: /think [on|off|effort <level>]", C.error); return }
|
|
80
40
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
41
|
+
// ── Interactive loop ──
|
|
42
|
+
let mainIdx = 0
|
|
43
|
+
for (;;) {
|
|
44
|
+
const autoOn = agent.config?.agent?.autoThink === true
|
|
45
|
+
const thinkingEnabled = cur.thinking?.type === thinkOnValue
|
|
46
|
+
|| (cur.thinking?.type === undefined && !isCustomThink)
|
|
47
|
+
|
|
48
|
+
const entries = [
|
|
49
|
+
{ type: "header", text: `Auto: ${autoOn ? "ON" : "OFF"} | Thinking: ${thinkingEnabled ? "ON" : "OFF"} | Effort: ${cur.reasoningEffort || "—"}` },
|
|
50
|
+
{ type: "item", text: `Auto: ${autoOn ? "ON" : "OFF"}`, action: "auto" },
|
|
51
|
+
]
|
|
52
|
+
if (!isEffortOnly && !autoOn) {
|
|
53
|
+
entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
54
|
+
}
|
|
55
|
+
if (!autoOn) {
|
|
56
|
+
for (const level of effortLevels) {
|
|
57
|
+
const mark = cur.reasoningEffort === level ? "▸ " : " "
|
|
58
|
+
entries.push({ type: "item", text: `${mark}effort: ${level}`, action: "effort", level })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const e = await showPicker("Think", entries, { defaultIndex: mainIdx })
|
|
63
|
+
if (!e) return // Esc
|
|
64
|
+
mainIdx = Math.max(0, entries.filter((en) => en.type === "item").indexOf(e))
|
|
65
|
+
|
|
66
|
+
const prevAuto = autoOn
|
|
67
|
+
const prevThinking = thinkingEnabled
|
|
68
|
+
const prevEffort = cur.reasoningEffort
|
|
69
|
+
|
|
70
|
+
await applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue)
|
|
71
|
+
|
|
72
|
+
// Feedback
|
|
73
|
+
pushLabel("❯ Think", ansi.bold + C.tool)
|
|
74
|
+
if (e.action === "auto") {
|
|
75
|
+
const newAuto = agent.config?.agent?.autoThink === true
|
|
76
|
+
pushLine(`Auto-think: ${newAuto ? "ON" : "OFF"}`, C.tool)
|
|
77
|
+
} else if (e.action === "effort") {
|
|
78
|
+
pushLine(`Reasoning effort: ${e.level}`, C.tool)
|
|
79
|
+
} else {
|
|
80
|
+
const nowEnabled = cur.thinking?.type === thinkOnValue
|
|
81
|
+
|| (cur.thinking?.type === undefined && !isCustomThink)
|
|
82
|
+
pushLine(`Thinking: ${nowEnabled ? "ON" : "OFF"}`, C.tool)
|
|
83
|
+
}
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Shared apply logic — extracted from handleThinkCommand for reuse in both fast path and loop */
|
|
88
|
+
async function applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue) {
|
|
89
|
+
const cur = agent.provider
|
|
90
|
+
if (e.action === "auto") {
|
|
91
|
+
const cfg = agent.config.agent ??= {}
|
|
92
|
+
cfg.autoThink = !cfg.autoThink
|
|
93
|
+
if (cfg.autoThink) {
|
|
94
|
+
delete cur.reasoningEffort
|
|
95
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
96
|
+
}
|
|
97
|
+
} else if (e.action === "effort") {
|
|
98
|
+
cur.reasoningEffort = e.level
|
|
99
|
+
await syncProviderField("reasoningEffort", e.level)
|
|
100
|
+
} else {
|
|
101
|
+
const enable = e.action === "on"
|
|
102
|
+
if (isEffortOnly) {
|
|
103
|
+
if (!enable) delete cur.reasoningEffort
|
|
104
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
105
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
106
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
107
|
+
} else {
|
|
108
|
+
if (enable) {
|
|
109
|
+
cur.thinking = { type: thinkOnValue }
|
|
110
|
+
if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
111
|
+
} else {
|
|
112
|
+
cur.thinking = isCustomThink ? undefined : { type: "disabled" }
|
|
113
|
+
delete cur.reasoningEffort
|
|
114
|
+
}
|
|
115
|
+
await syncProviderField("thinking", cur.thinking)
|
|
116
|
+
if (enable) {
|
|
117
|
+
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
118
|
+
} else {
|
|
119
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
120
|
+
}
|
|
93
121
|
}
|
|
94
|
-
} else if (!autoThinkEnabled) {
|
|
95
|
-
entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
|
|
96
|
-
entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
|
|
97
122
|
}
|
|
98
|
-
const e = await showPicker("Think", entries)
|
|
99
|
-
if (e) await apply(e)
|
|
100
123
|
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
80
80
|
completion: null, // Tab completion state { candidates, index }
|
|
81
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: {}, //
|
|
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",
|
|
@@ -416,5 +416,6 @@ export async function startTUI(agent, opts = {}) {
|
|
|
416
416
|
|
|
417
417
|
function summarize(obj) {
|
|
418
418
|
const s = JSON.stringify(obj)
|
|
419
|
+
if (s === "{}") return "" // no-arg tools (advisor/verify/…) — don't render empty braces
|
|
419
420
|
return s.length > 80 ? s.slice(0, 80) + "…" : s
|
|
420
421
|
}
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -37,7 +37,8 @@ export function createInteraction(ctx) {
|
|
|
37
37
|
function askPermission(name, args) {
|
|
38
38
|
// auto mode: fully authorized, no more prompts
|
|
39
39
|
if (agent.autoApprove) {
|
|
40
|
-
|
|
40
|
+
const argSummary = summarize(args)
|
|
41
|
+
pushLine(` [auto] ${name}${argSummary ? ` ${argSummary}` : ""}`, C.warn)
|
|
41
42
|
return Promise.resolve(true)
|
|
42
43
|
}
|
|
43
44
|
// store preview content in permissionPreview, rendered above input box next to "Allow?" prompt
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -24,8 +24,6 @@ export function createKeyHandler(ctx) {
|
|
|
24
24
|
state.status = "Processing..."
|
|
25
25
|
if (answer === "a" && !isContinue) {
|
|
26
26
|
agent.autoApprove = true
|
|
27
|
-
agent._pendingReminders = agent._pendingReminders ?? []
|
|
28
|
-
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
29
27
|
pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
|
|
30
28
|
}
|
|
31
29
|
const approved = answer === "y" || (answer === "a" && !isContinue)
|
package/src/tui/layout.mjs
CHANGED
|
@@ -69,8 +69,9 @@ 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 8 lines per panel, capped at reasonable total
|
|
73
|
-
|
|
72
|
+
// Tool output panels: max 8 lines per panel, capped at reasonable total.
|
|
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())
|
|
74
75
|
const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
|
|
75
76
|
|
|
76
77
|
// Permission preview (height depends on wrapped content)
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -83,24 +83,47 @@ export function renderSubagent(allSubs, W) {
|
|
|
83
83
|
return out
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/** Per-kind panel colors: reasoning faint, tool progress cyan, main output gray. */
|
|
87
|
+
const PANEL_KIND_COLORS = { think: C.reason, tool: C.tool, text: C.dim }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Flatten panel parts into display lines, tracking each line's kind.
|
|
91
|
+
* A part not starting mid-line continues the previous line (kind of the line's first fragment wins).
|
|
92
|
+
*/
|
|
93
|
+
function panelLines(p) {
|
|
94
|
+
const lines = []
|
|
95
|
+
for (const part of p.parts ?? []) {
|
|
96
|
+
part.text.split("\n").forEach((seg, j) => {
|
|
97
|
+
if (j === 0 && lines.length > 0) {
|
|
98
|
+
const last = lines[lines.length - 1]
|
|
99
|
+
// Empty trailing line = previous part ended exactly at a line break — the new
|
|
100
|
+
// part owns this line's kind. Otherwise it's a genuine mid-line continuation.
|
|
101
|
+
if (last.text === "") last.kind = part.kind
|
|
102
|
+
last.text += seg
|
|
103
|
+
} else {
|
|
104
|
+
lines.push({ kind: part.kind, text: seg })
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return lines.filter((l) => l.text.trim())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Tool output panels (streaming output like tail -f). Returns empty when no visible output. */
|
|
87
112
|
export function renderOutput(state, W, panelH) {
|
|
88
|
-
|
|
113
|
+
// Same visibility predicate as computeLayout: done panels linger until closeAt
|
|
114
|
+
const active = Object.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
89
115
|
if (active.length === 0) return []
|
|
90
116
|
const out = []
|
|
91
117
|
const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
out.push(`${
|
|
118
|
+
const perPanel = active.map((p) => panelLines(p).slice(-linesPerPanel))
|
|
119
|
+
for (const lines of perPanel) {
|
|
120
|
+
for (const l of lines) {
|
|
121
|
+
const color = PANEL_KIND_COLORS[l.kind] ?? C.dim
|
|
122
|
+
out.push(`${color} │ ${sliceByWidth(sanitizeDisplay(l.text), W - 5)}${ansi.reset}`)
|
|
97
123
|
}
|
|
98
124
|
}
|
|
99
125
|
// Fill remaining rows to match panelH exactly
|
|
100
|
-
const used =
|
|
101
|
-
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
102
|
-
return s + tl.length
|
|
103
|
-
}, 0)
|
|
126
|
+
const used = perPanel.reduce((s, lines) => s + lines.length, 0)
|
|
104
127
|
for (let i = used; i < panelH; i++) out.push("")
|
|
105
128
|
return out
|
|
106
129
|
}
|
|
@@ -230,80 +253,59 @@ export function renderStatus(state, agent, cols, slashCommands) {
|
|
|
230
253
|
}
|
|
231
254
|
|
|
232
255
|
// ====================================================================
|
|
233
|
-
//
|
|
256
|
+
// Frame composition
|
|
234
257
|
// ====================================================================
|
|
235
258
|
|
|
236
259
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
260
|
+
* Compose the whole screen as a rows array, placing each panel at its
|
|
261
|
+
* layout-computed y coordinate. Single source of truth for what the screen
|
|
262
|
+
* should look like — the render loop diffs these rows against what it last
|
|
263
|
+
* wrote and repaints only changed rows (absolute positioning).
|
|
264
|
+
*
|
|
265
|
+
* @returns {{ rows: string[], cursorRow: number, cursorCol: number, layout: object }}
|
|
240
266
|
*/
|
|
241
|
-
export function
|
|
267
|
+
export function renderRows(state, agent, opts) {
|
|
242
268
|
const cols = opts.cols || 80
|
|
243
269
|
const rows = opts.rows || 24
|
|
244
270
|
const slashCommands = opts.slashCommands ?? []
|
|
245
|
-
const platform = opts.platform ?? process.platform
|
|
246
271
|
|
|
247
272
|
const layout = computeLayout(state, { cols, rows })
|
|
248
273
|
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
|
|
249
274
|
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
out.push(`${renderHeader(agent, cols)}\x1b[K`)
|
|
255
|
-
|
|
256
|
-
// conversation
|
|
257
|
-
for (const l of renderConversation(state, cols, panels.conversation.h, state.scroll)) {
|
|
258
|
-
out.push(`${l}\x1b[K`)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// picker
|
|
262
|
-
if (panels.picker) {
|
|
263
|
-
for (const l of renderPicker(state, cols, panels.picker, overlay)) {
|
|
264
|
-
out.push(`${l}\x1b[K`)
|
|
275
|
+
const screen = new Array(rows).fill("")
|
|
276
|
+
const put = (y, lines) => {
|
|
277
|
+
for (let i = 0; i < lines.length && y + i < rows; i++) {
|
|
278
|
+
if (y + i >= 0) screen[y + i] = `${lines[i]}\x1b[K`
|
|
265
279
|
}
|
|
266
280
|
}
|
|
267
281
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (panels.
|
|
273
|
-
|
|
274
|
-
|
|
282
|
+
put(panels.header.y, [renderHeader(agent, cols)])
|
|
283
|
+
put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll))
|
|
284
|
+
if (panels.subagent) put(panels.subagent.y, renderSubagent(allSubs, W))
|
|
285
|
+
if (panels.output) put(panels.output.y, renderOutput(state, W, panels.output.h))
|
|
286
|
+
if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
|
|
287
|
+
if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
|
|
288
|
+
if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
|
|
289
|
+
if (panels.queue) put(panels.queue.y, [renderQueue(state, W)])
|
|
290
|
+
put(panels.inputBox.y, renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset))
|
|
291
|
+
put(panels.status.y, [renderStatus(state, agent, cols, slashCommands)])
|
|
275
292
|
|
|
276
|
-
|
|
277
|
-
if (panels.output) {
|
|
278
|
-
for (const l of renderOutput(state, W, panels.output.h)) out.push(`${l}\x1b[K`)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// permission preview
|
|
282
|
-
if (panels.permission) {
|
|
283
|
-
for (const l of renderPermission(permPreviewLines)) out.push(`${l}\x1b[K`)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// queue preview
|
|
287
|
-
if (panels.queue) {
|
|
288
|
-
const qLine = renderQueue(state, W)
|
|
289
|
-
if (qLine) out.push(`${qLine}\x1b[K`)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// input box
|
|
293
|
-
for (const l of renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset)) out.push(`${l}\x1b[K`)
|
|
294
|
-
|
|
295
|
-
// status bar
|
|
296
|
-
out.push(`${renderStatus(state, agent, cols, slashCommands)}\x1b[K`)
|
|
297
|
-
|
|
298
|
-
const frame = out.join("\r\n")
|
|
299
|
-
|
|
300
|
-
// cursor position
|
|
293
|
+
let cursorRow = 0, cursorCol = 0
|
|
301
294
|
if (!state.permission && !state.question && !state.picker && state.wizard?.step !== "provider") {
|
|
302
295
|
cursorRow = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
|
|
303
296
|
cursorCol = 3 + inputLayout.cursorCol
|
|
304
297
|
}
|
|
305
298
|
|
|
306
|
-
return {
|
|
299
|
+
return { rows: screen, cursorRow, cursorCol, layout }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Render one frame, returns { frame, cursorRow, cursorCol }.
|
|
304
|
+
* @deprecated Use renderRows + row-diff (render-loop). Kept for tests/legacy callers.
|
|
305
|
+
*/
|
|
306
|
+
export function renderFrame(state, agent, opts) {
|
|
307
|
+
const { rows, cursorRow, cursorCol } = renderRows(state, agent, opts)
|
|
308
|
+
return { frame: rows.join("\r\n"), cursorRow, cursorCol }
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
// ====================================================================
|