thincoder 0.11.1 → 0.12.0

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.
@@ -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 addAndConnect(ctx, { name, command: cmd, args: cmdArgs })
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)
@@ -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
- agent._pendingReminders = agent._pendingReminders ?? []
7
- if (agent.planMode) {
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
  }
@@ -1,68 +1,27 @@
1
- import { C } from "./ansi.mjs"
2
-
3
1
  /** /think command: toggle thinking mode, set reasoning effort.
4
- * Extracted from slash-commands.mjs.
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 apply({ action: sub })
65
- pushLine(`Thinking: ${sub}`, C.dim)
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 apply({ action: "effort", level })
76
- pushLine(`Thinking effort: ${level}`, C.dim)
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
- // "enabled" when thinking.type matches the model's enabled value, or when thinking is absent and the model is NOT a custom-think model (defaults to on for standard models)
82
- const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
83
- const entries = []
84
- // Auto-think: classify difficulty per-prompt and auto-set reasoning effort
85
- entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
86
- if (!isEffortOnly) {
87
- if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
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
- if (spec.reasoningEffortEnum && !autoThinkEnabled) {
90
- for (const level of spec.reasoningEffortEnum) {
91
- const mark = cur.reasoningEffort === level ? "▸ " : " "
92
- entries.push({ type: "item", text: `${mark}effort: ${level}`, action: "effort", level })
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: {}, // generic tool output panel: { toolName: { text, done } } — streamed live during execution, collapsed to summary on 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",
@@ -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
  }
@@ -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
- pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
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
@@ -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)
@@ -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
- const panels = Object.values(state.outputPanels).filter((p) => !p.done || p._pendingDone)
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)
@@ -83,24 +83,47 @@ export function renderSubagent(allSubs, W) {
83
83
  return out
84
84
  }
85
85
 
86
- /** Tool output panels (streaming output like tail -f). Returns empty when no active output. */
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
- const active = Object.values(state.outputPanels).filter((p) => !p.done)
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
- for (const p of active) {
93
- const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
94
- const tail = textLines.slice(-linesPerPanel)
95
- for (const line of tail) {
96
- out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}`)
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 = active.reduce((s, p) => {
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
- // Legacy: full-frame renderer (wraps individual panel functions)
256
+ // Frame composition
234
257
  // ====================================================================
235
258
 
236
259
  /**
237
- * Render one frame, returns { frame, cursorRow, cursorCol }.
238
- * Pure function: does not modify state/agent.
239
- * @deprecated Prefer individual panel functions for incremental rendering.
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 renderFrame(state, agent, opts) {
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 out = [ansi.home]
251
- let cursorRow = 0, cursorCol = 0
252
-
253
- // header
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
- // todo
269
- for (const l of renderTodo(visibleTasks, cols)) out.push(`${l}\x1b[K`)
270
-
271
- // subagent
272
- if (panels.subagent) {
273
- for (const l of renderSubagent(allSubs, W)) out.push(`${l}\x1b[K`)
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
- // output panels
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 { frame, cursorRow, cursorCol }
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
  // ====================================================================