rovecode 0.3.2

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.
Files changed (334) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +737 -0
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/bin/rovecode.ts +21 -0
  5. package/package.json +56 -0
  6. package/src/acp/server.ts +374 -0
  7. package/src/cli/auth-login.ts +122 -0
  8. package/src/cli/connect.ts +244 -0
  9. package/src/cli/context-cmd.ts +199 -0
  10. package/src/cli/dispatch.ts +82 -0
  11. package/src/cli/doctor.ts +362 -0
  12. package/src/cli/export.ts +276 -0
  13. package/src/cli/help.ts +293 -0
  14. package/src/cli/is-tui-invocation.ts +8 -0
  15. package/src/cli/main.ts +583 -0
  16. package/src/cli/market-cmd.ts +658 -0
  17. package/src/cli/mcp-login.ts +141 -0
  18. package/src/cli/mcp-market-cmd.ts +302 -0
  19. package/src/cli/output.ts +382 -0
  20. package/src/cli/repl.ts +250 -0
  21. package/src/cli/repomap-root.ts +14 -0
  22. package/src/cli/resume.ts +57 -0
  23. package/src/cli/run-flags.ts +43 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +931 -0
  26. package/src/cli/session-arg.ts +30 -0
  27. package/src/cli/sessions-cmd.ts +145 -0
  28. package/src/cli/setup.ts +153 -0
  29. package/src/cli/skills-cmd.ts +194 -0
  30. package/src/cli/start-chat.ts +65 -0
  31. package/src/cli/trust-cmd.ts +52 -0
  32. package/src/coding/bash.ts +148 -0
  33. package/src/coding/checkpoints.ts +327 -0
  34. package/src/coding/diff.ts +138 -0
  35. package/src/coding/files.ts +341 -0
  36. package/src/coding/hashline.ts +274 -0
  37. package/src/coding/lsp-gate.ts +254 -0
  38. package/src/coding/lsp-servers.ts +147 -0
  39. package/src/coding/lsp.ts +283 -0
  40. package/src/coding/repomap-cache.ts +99 -0
  41. package/src/coding/repomap-files.ts +192 -0
  42. package/src/coding/repomap.ts +481 -0
  43. package/src/core/agents.ts +255 -0
  44. package/src/core/compaction.ts +259 -0
  45. package/src/core/config.ts +289 -0
  46. package/src/core/context-report.ts +228 -0
  47. package/src/core/context.ts +60 -0
  48. package/src/core/count-remote.ts +107 -0
  49. package/src/core/execpolicy-rules.ts +196 -0
  50. package/src/core/execpolicy.ts +385 -0
  51. package/src/core/executor.ts +454 -0
  52. package/src/core/guardrails.ts +400 -0
  53. package/src/core/hooks.ts +411 -0
  54. package/src/core/images.ts +230 -0
  55. package/src/core/intro.ts +266 -0
  56. package/src/core/loop.ts +567 -0
  57. package/src/core/modes.ts +372 -0
  58. package/src/core/orchestrator.ts +245 -0
  59. package/src/core/proc-group.ts +48 -0
  60. package/src/core/project-trust.ts +98 -0
  61. package/src/core/reflection.ts +165 -0
  62. package/src/core/sandbox-config.ts +186 -0
  63. package/src/core/session-id.ts +24 -0
  64. package/src/core/session-images.ts +73 -0
  65. package/src/core/session-ops.ts +183 -0
  66. package/src/core/session-text.ts +29 -0
  67. package/src/core/session.ts +469 -0
  68. package/src/core/settings.ts +170 -0
  69. package/src/core/tasks.ts +646 -0
  70. package/src/core/token-scale.ts +108 -0
  71. package/src/core/tools.ts +309 -0
  72. package/src/core/trust.ts +104 -0
  73. package/src/core/types.ts +330 -0
  74. package/src/core/update-check.ts +171 -0
  75. package/src/core/usage.ts +204 -0
  76. package/src/core/validate.ts +121 -0
  77. package/src/core/verify-gate.ts +159 -0
  78. package/src/core/verify.ts +236 -0
  79. package/src/core/voice.ts +158 -0
  80. package/src/core/win-job.ts +183 -0
  81. package/src/core/workspace.ts +184 -0
  82. package/src/design/audit.ts +797 -0
  83. package/src/design/direction.ts +190 -0
  84. package/src/design/rules.ts +157 -0
  85. package/src/eval/bench.ts +150 -0
  86. package/src/eval/gauntlet-runner.ts +215 -0
  87. package/src/eval/gauntlet-support.ts +84 -0
  88. package/src/eval/gauntlet-wave3.ts +269 -0
  89. package/src/eval/gauntlet-wave4.ts +217 -0
  90. package/src/eval/gauntlet.ts +253 -0
  91. package/src/index.ts +17 -0
  92. package/src/lanes/agy.ts +95 -0
  93. package/src/lanes/approval.ts +24 -0
  94. package/src/lanes/claude.ts +129 -0
  95. package/src/lanes/codex.ts +127 -0
  96. package/src/lanes/events.ts +130 -0
  97. package/src/lanes/job.ts +142 -0
  98. package/src/lanes/opencode.ts +122 -0
  99. package/src/lanes/process.ts +184 -0
  100. package/src/lanes/progress.ts +183 -0
  101. package/src/lanes/registry.ts +178 -0
  102. package/src/lanes/runner.ts +124 -0
  103. package/src/lanes/types.ts +112 -0
  104. package/src/market/catalogs/mcp-docs.json +111 -0
  105. package/src/market/catalogs/plugins.json +111 -0
  106. package/src/market/catalogs/skills.json +478 -0
  107. package/src/market/clone.ts +72 -0
  108. package/src/market/context-cost.ts +121 -0
  109. package/src/market/digest.ts +106 -0
  110. package/src/market/index.ts +22 -0
  111. package/src/market/install.ts +578 -0
  112. package/src/market/manifest.ts +187 -0
  113. package/src/market/prereq.ts +145 -0
  114. package/src/market/registry.ts +363 -0
  115. package/src/market/resolve.ts +111 -0
  116. package/src/market/types.ts +236 -0
  117. package/src/market/validate.ts +227 -0
  118. package/src/mcp/client.ts +449 -0
  119. package/src/mcp/config.ts +252 -0
  120. package/src/mcp/local-package.ts +211 -0
  121. package/src/mcp/market-catalog.ts +84 -0
  122. package/src/mcp/market-install.ts +289 -0
  123. package/src/mcp/market.ts +362 -0
  124. package/src/mcp/oauth.ts +251 -0
  125. package/src/mcp/prompts-resources.ts +249 -0
  126. package/src/mcp/shared.ts +149 -0
  127. package/src/mcp/status.ts +67 -0
  128. package/src/mcp/tools.ts +275 -0
  129. package/src/mcp/transport.ts +122 -0
  130. package/src/mcp/trust.ts +25 -0
  131. package/src/memory/blocks.ts +278 -0
  132. package/src/memory/recall.ts +355 -0
  133. package/src/memory/scope.ts +182 -0
  134. package/src/memory/store.ts +105 -0
  135. package/src/memory/tools.ts +99 -0
  136. package/src/plugins/cli.ts +119 -0
  137. package/src/plugins/discover.ts +108 -0
  138. package/src/plugins/index.ts +50 -0
  139. package/src/plugins/install.ts +184 -0
  140. package/src/plugins/load.ts +124 -0
  141. package/src/plugins/manifest.ts +92 -0
  142. package/src/plugins/state.ts +83 -0
  143. package/src/providers/auth.ts +408 -0
  144. package/src/providers/cache.ts +223 -0
  145. package/src/providers/catalog-local.ts +160 -0
  146. package/src/providers/catalog.ts +421 -0
  147. package/src/providers/middleware-context.ts +86 -0
  148. package/src/providers/middleware.ts +373 -0
  149. package/src/providers/model-list.ts +23 -0
  150. package/src/providers/models-index.json +1 -0
  151. package/src/providers/oauth/common.ts +105 -0
  152. package/src/providers/oauth/device-code.ts +107 -0
  153. package/src/providers/oauth/github-copilot.ts +146 -0
  154. package/src/providers/oauth/loopback.ts +158 -0
  155. package/src/providers/oauth/openai.ts +163 -0
  156. package/src/providers/oauth/openrouter.ts +89 -0
  157. package/src/providers/oauth/pkce.ts +45 -0
  158. package/src/providers/oauth/registry.ts +39 -0
  159. package/src/providers/oauth/seam.ts +89 -0
  160. package/src/providers/profile-glm53.ts +111 -0
  161. package/src/providers/profile-sonnet5-persona.ts +65 -0
  162. package/src/providers/profile-sonnet5-voice.ts +23 -0
  163. package/src/providers/profiles.ts +156 -0
  164. package/src/providers/provider-config.ts +311 -0
  165. package/src/providers/registry.ts +333 -0
  166. package/src/providers/responses.ts +209 -0
  167. package/src/providers/retry.ts +234 -0
  168. package/src/providers/router.ts +294 -0
  169. package/src/providers/sse.ts +26 -0
  170. package/src/providers/stream-errors.ts +117 -0
  171. package/src/providers/stream.ts +566 -0
  172. package/src/providers/thinking.ts +189 -0
  173. package/src/providers/wire-messages.ts +129 -0
  174. package/src/providers/wire-responses.ts +79 -0
  175. package/src/providers/wire-select.ts +53 -0
  176. package/src/server/http.ts +291 -0
  177. package/src/server/openapi.ts +246 -0
  178. package/src/sextant/card-hits.ts +102 -0
  179. package/src/sextant/card-keys.ts +55 -0
  180. package/src/sextant/context-source.ts +157 -0
  181. package/src/sextant/crew-cards.ts +350 -0
  182. package/src/sextant/draw-agents.ts +273 -0
  183. package/src/sextant/draw-code.ts +388 -0
  184. package/src/sextant/draw-context.ts +222 -0
  185. package/src/sextant/draw-frame.ts +164 -0
  186. package/src/sextant/draw-market.ts +573 -0
  187. package/src/sextant/draw-messages.ts +386 -0
  188. package/src/sextant/draw-pet.ts +230 -0
  189. package/src/sextant/draw-plan.ts +187 -0
  190. package/src/sextant/draw-tabs.ts +85 -0
  191. package/src/sextant/draw-util.ts +65 -0
  192. package/src/sextant/draw-wizard.ts +378 -0
  193. package/src/sextant/engine.ts +230 -0
  194. package/src/sextant/frame-hits.ts +25 -0
  195. package/src/sextant/frame.ts +101 -0
  196. package/src/sextant/git-status.ts +197 -0
  197. package/src/sextant/grid.ts +59 -0
  198. package/src/sextant/input.ts +119 -0
  199. package/src/sextant/keys.ts +521 -0
  200. package/src/sextant/layout.ts +86 -0
  201. package/src/sextant/local-commands.ts +169 -0
  202. package/src/sextant/market-source.ts +287 -0
  203. package/src/sextant/mentions.ts +200 -0
  204. package/src/sextant/message-hits.ts +26 -0
  205. package/src/sextant/model.ts +387 -0
  206. package/src/sextant/overlays.ts +456 -0
  207. package/src/sextant/panel-hits.ts +38 -0
  208. package/src/sextant/pet.ts +399 -0
  209. package/src/sextant/screen.ts +324 -0
  210. package/src/sextant/scroll-hits.ts +66 -0
  211. package/src/sextant/scrollbar.ts +82 -0
  212. package/src/sextant/sextant-bridge.ts +174 -0
  213. package/src/sextant/sextant-cards.ts +142 -0
  214. package/src/sextant/sextant-diff-base.ts +63 -0
  215. package/src/sextant/sextant-files.ts +154 -0
  216. package/src/sextant/sextant-frame-loop.ts +335 -0
  217. package/src/sextant/sextant-renderer.ts +574 -0
  218. package/src/sextant/sextant-repo.ts +140 -0
  219. package/src/sextant/theme.ts +66 -0
  220. package/src/sextant/tool-rows.ts +189 -0
  221. package/src/sextant/types.ts +493 -0
  222. package/src/skills/index.ts +387 -0
  223. package/src/skills/pack.ts +220 -0
  224. package/src/skills/spec.ts +162 -0
  225. package/src/skills/tools.ts +69 -0
  226. package/src/skills/versioned.ts +227 -0
  227. package/src/telemetry/otel-export.ts +122 -0
  228. package/src/telemetry/otel-lanes.ts +89 -0
  229. package/src/telemetry/otel-logs.ts +131 -0
  230. package/src/telemetry/otel-metrics.ts +136 -0
  231. package/src/telemetry/otel.ts +397 -0
  232. package/src/telemetry/otlp.ts +76 -0
  233. package/src/tools/ask-user.ts +156 -0
  234. package/src/tools/bash-bg.ts +94 -0
  235. package/src/tools/bash-jobs.ts +237 -0
  236. package/src/tools/design.ts +151 -0
  237. package/src/tools/evalcell.ts +338 -0
  238. package/src/tools/html-text.ts +139 -0
  239. package/src/tools/provider.ts +149 -0
  240. package/src/tools/task.ts +250 -0
  241. package/src/tools/todo.ts +320 -0
  242. package/src/tools/webfetch.ts +332 -0
  243. package/src/tools/websearch.ts +359 -0
  244. package/src/tui/agents-cmd.ts +41 -0
  245. package/src/tui/app.ts +749 -0
  246. package/src/tui/attach.ts +127 -0
  247. package/src/tui/boot-notes.ts +41 -0
  248. package/src/tui/builtin-prompts.ts +59 -0
  249. package/src/tui/checkpoints-cmd.ts +70 -0
  250. package/src/tui/clipboard-image.ts +81 -0
  251. package/src/tui/clipboard.ts +78 -0
  252. package/src/tui/commands.ts +283 -0
  253. package/src/tui/config-view.ts +53 -0
  254. package/src/tui/context-cmds.ts +282 -0
  255. package/src/tui/cost.ts +108 -0
  256. package/src/tui/crash-guard.ts +173 -0
  257. package/src/tui/focus-terminal.ts +34 -0
  258. package/src/tui/git-cmds.ts +273 -0
  259. package/src/tui/git-plain.ts +58 -0
  260. package/src/tui/info-cmd.ts +150 -0
  261. package/src/tui/input-plain.ts +76 -0
  262. package/src/tui/mcp-cmd.ts +128 -0
  263. package/src/tui/memory-note.ts +77 -0
  264. package/src/tui/modes-cmd.ts +45 -0
  265. package/src/tui/notify-seq.ts +100 -0
  266. package/src/tui/notify.ts +318 -0
  267. package/src/tui/overlays.ts +97 -0
  268. package/src/tui/pi-renderer.ts +428 -0
  269. package/src/tui/providers-cmd.ts +377 -0
  270. package/src/tui/reasoning-view.ts +56 -0
  271. package/src/tui/renderer.ts +128 -0
  272. package/src/tui/replay-marker.ts +29 -0
  273. package/src/tui/session-cmd.ts +148 -0
  274. package/src/tui/session-manage.ts +95 -0
  275. package/src/tui/sextant-attach.ts +102 -0
  276. package/src/tui/sextant-io.ts +202 -0
  277. package/src/tui/sextant-smoke.ts +110 -0
  278. package/src/tui/shell-cmd.ts +158 -0
  279. package/src/tui/smoke.ts +72 -0
  280. package/src/tui/staged-terminal.ts +50 -0
  281. package/src/tui/startup.ts +12 -0
  282. package/src/tui/theme.ts +59 -0
  283. package/src/tui/todo-label.ts +7 -0
  284. package/src/tui/trust-card.ts +107 -0
  285. package/src/tui/tui-commands.ts +87 -0
  286. package/tsconfig.json +30 -0
  287. package/vendor/pi-tui/LICENSE +21 -0
  288. package/vendor/pi-tui/PATCHES.md +12 -0
  289. package/vendor/pi-tui/PROVENANCE.md +12 -0
  290. package/vendor/pi-tui/README.upstream.md +854 -0
  291. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  292. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  293. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  294. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  295. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  296. package/vendor/pi-tui/src/components/box.ts +138 -0
  297. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  298. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  299. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  300. package/vendor/pi-tui/src/components/image.ts +128 -0
  301. package/vendor/pi-tui/src/components/input.ts +448 -0
  302. package/vendor/pi-tui/src/components/loader.ts +93 -0
  303. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  304. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  305. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  306. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  307. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  308. package/vendor/pi-tui/src/components/stack.ts +155 -0
  309. package/vendor/pi-tui/src/components/text.ts +108 -0
  310. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  311. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  312. package/vendor/pi-tui/src/editor-component.ts +75 -0
  313. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  314. package/vendor/pi-tui/src/index.ts +149 -0
  315. package/vendor/pi-tui/src/keybindings.ts +321 -0
  316. package/vendor/pi-tui/src/keys.ts +1402 -0
  317. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  318. package/vendor/pi-tui/src/latex.ts +1381 -0
  319. package/vendor/pi-tui/src/layout-node.ts +52 -0
  320. package/vendor/pi-tui/src/layout.ts +411 -0
  321. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  322. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  323. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  324. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  325. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  326. package/vendor/pi-tui/src/terminal.ts +554 -0
  327. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  328. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  329. package/vendor/pi-tui/src/tui.ts +1264 -0
  330. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  331. package/vendor/pi-tui/src/utils.ts +1327 -0
  332. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  333. package/vendor/pi-tui/test/test-themes.ts +39 -0
  334. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
@@ -0,0 +1,574 @@
1
+ /** SextantRenderer (port #44): the sextant surface as rovecode's `Renderer` — the ONE controller stays
2
+ * runTui (ADR-003, no second loop); this class maps the Renderer seam plus the optional onEvent/attach
3
+ * members onto the SextantState (#41 model), paints through FrameLoop (#40 screen, #41-#46 painters)
4
+ * and routes keys through #43. Ported from the user's sextant v0.4.0 app.js:1599-1632 (boot: enter
5
+ * the alt screen, state, tick, input, resize, leave).
6
+ *
7
+ * ONE source of truth for transcript rows while a run is busy: the RunEvent stream (onEvent →
8
+ * applyEvent). The per-event Renderer calls app.ts makes for the same events are therefore no-ops
9
+ * while busy (beginAssistant views, toolStart/toolUpdate/toolEnd) or dropped by exact count (the
10
+ * compaction / "↪ steering applied" / run_end notes — sextant-bridge duplicateNotes); every other
11
+ * note (boot, slash commands, task settlements, "queued as steering", router notes) is a row + toast.
12
+ * When NOT busy (history replay after /resume, /rewind, /new) the same methods build rows directly.
13
+ * Renderer-local slash commands (/theme /open /diff /focus /agents, the /help card) run in keys.ts
14
+ * runLocal through the `local` hooks below — src/sextant/local-commands.ts is their future home.
15
+ * I/O: the terminal through TerminalIO; git/fs through sextant-repo.ts (RepoWatcher: scheduled OFF
16
+ * the frame loop from the tick and run async, so a slow `git status` never freezes a frame). */
17
+
18
+ import { homedir } from "node:os";
19
+ import { basename, join } from "node:path";
20
+ import pkg from "../../package.json";
21
+ import type { RunEvent } from "../core/types.ts";
22
+ import { loadTodos } from "../tools/todo.ts";
23
+ import type { ApprovalAnswer, AssistantView, PickItem, QuestionAnswer, QuestionPrompt, Renderer, RendererHooks, RendererStartOptions, SlashCommand, StatusInfo } from "../tui/renderer.ts";
24
+ import { drawAgents } from "./draw-agents.ts";
25
+ import { openMarket } from "./draw-market.ts";
26
+ import { closeWizard, openWizard, wizardEndpointRegistered, wizardKeyStored, wizardModelConfirmed, type WizardProvider } from "./draw-wizard.ts";
27
+ import { docsFor, install, loadMarket, planFor } from "./market-source.ts";
28
+ import { openContext } from "./draw-context.ts";
29
+ import { fixedFrom, loadContext } from "./context-source.ts";
30
+ import { hunksFromUnified, setAgentsPainter } from "./draw-code.ts";
31
+ import { fuzzy } from "./engine.ts";
32
+ import { spawnGitAsync, toAsync, type GitRunner, type GitRunnerAsync } from "./git-status.ts";
33
+ import { enterSequence, leaveSequence } from "./input.ts";
34
+ import { ESC_WINDOW_MS, type KeyCtx } from "./keys.ts";
35
+ import { initialState, makeApplyEvent, planCounts, pushToast, setCrew, setPlan, setUsage } from "./model.ts";
36
+ import { suggestions } from "./overlays.ts";
37
+ import { createPet, type Pet } from "./pet.ts";
38
+ import { argsFromPreview, duplicateNotes, petOnEvent, petOnTask, replayToolRow, userRow, type LiveCall } from "./sextant-bridge.ts";
39
+ import { CardHost } from "./sextant-cards.ts";
40
+ import { editOpsOf, type HashlineOp } from "./sextant-diff-base.ts";
41
+ import { readFileBounded } from "./sextant-files.ts";
42
+ import { FrameLoop } from "./sextant-frame-loop.ts";
43
+ import { RepoWatcher } from "./sextant-repo.ts";
44
+ import { buildTheme, isThemeName } from "./theme.ts";
45
+ import { clipText, relPath, summarizeEnd } from "./tool-rows.ts";
46
+ import type { ApplyEvent, InputEvent, SextantAttach, SextantState, TerminalIO, Theme, ThemeName, ToolRow } from "./types.ts";
47
+
48
+ export { IDLE_SCAN_MS } from "./sextant-repo.ts";
49
+
50
+ export interface SextantRendererOptions {
51
+ io: TerminalIO;
52
+ /** the frame clock (Date.now in production; tests inject a controllable one) */
53
+ clock?: () => number;
54
+ /** starting palette (ROVECODE_THEME / --theme); an unknown name falls back to night */
55
+ theme?: string;
56
+ /** the pet's name (`--pet <name>`); attach() may override it */
57
+ pet?: string;
58
+ /** emit truecolor SGR (default true; false quantizes to xterm-256) */
59
+ truecolor?: boolean;
60
+ /** the project dir until attach() supplies the runtime's cwd */
61
+ cwd?: string;
62
+ /** git seam for tests (a sync fake is wrapped); undefined = spawn git asynchronously */
63
+ git?: GitRunner | GitRunnerAsync;
64
+ /** false disables the repo scan entirely (pure tests) */
65
+ scan?: boolean;
66
+ }
67
+
68
+ const TOAST_MAX = 48;
69
+ const NOOP_VIEW: AssistantView = { append() {}, done() {} };
70
+ const NO_HOOKS: RendererHooks = { onSubmit() {}, onInterrupt() {}, onExit() {} };
71
+
72
+ export class SextantRenderer implements Renderer {
73
+ /** the surface state (keys.ts and the reducer mutate it; tests read it) */
74
+ readonly state: SextantState;
75
+ private theme: Theme;
76
+ private readonly io: TerminalIO;
77
+ private readonly clock: () => number;
78
+ private readonly pet: Pet;
79
+ private readonly loop: FrameLoop;
80
+ /** the git/fs work, off the loop and async (sextant-repo.ts) */
81
+ private readonly repo: RepoWatcher;
82
+ private readonly applyEv: ApplyEvent;
83
+ /** SGR mode the Screen emits (false = the xterm-256 quantizer; tests/pickRenderer read it) */
84
+ readonly truecolor: boolean;
85
+ private hooks: RendererHooks | null = null;
86
+ private ctx: SextantAttach | null = null;
87
+ private unsubTasks: (() => void) | null = null;
88
+ private started = false;
89
+ private stopped = false;
90
+ /** between setBusy(true) and setBusy(false): the event stream owns the rows */
91
+ private busy = false;
92
+ private dropNotes = 0;
93
+ /** the approval / question / picker promises (sextant-cards.ts) */
94
+ private readonly cards: CardHost;
95
+ private readonly liveCalls = new Map<string, LiveCall>();
96
+ /** per cwd-relative path: the content when its edit/write was APPROVED (null = did not exist) — the
97
+ * pre-edit diff base. Captured at askApproval (the one moment before the tool runs), NOT at
98
+ * tool_execution_start: that event reaches the renderer through the loop's buffered pump after
99
+ * rovecode's synchronous edit/write already wrote, so a snapshot then is the post-edit file. Ungated
100
+ * calls (yolo / always) rebuild their base from the hashline ops instead (sextant-diff-base.ts). */
101
+ private readonly before = new Map<string, string | null>();
102
+ /** per live edit callId: its hashline ops (the base of an ungated edit is rebuilt from them at the end) */
103
+ private readonly editOps = new Map<string, HashlineOp[]>();
104
+ /** the file whose content the code panel holds (null → reload on the next tick) */
105
+ private loadedFile: string | null = null;
106
+ private planLoaded = false;
107
+ private todoDone = 0;
108
+
109
+ /** the model step's ONE fetch: the chosen provider through the wizardModels seam, the answer
110
+ * written with wizardModelConfirmed. Empty or failed leaves the step on the typed fallback. */
111
+ private fetchWizardModels(): void {
112
+ const w = this.state.wizard;
113
+ if (!w) return;
114
+ w.modelsLoading = true;
115
+ this.loop.markDirty();
116
+ const provider = w.providers[w.selected]?.key ?? "";
117
+ void (this.ctx?.wizardModels?.(provider) ?? Promise.resolve([]))
118
+ .then((models) => {
119
+ if (!this.state.wizard) return;
120
+ wizardModelConfirmed(this.state.wizard, models);
121
+ if (models.length === 0) this.state.wizard.error = "the endpoint listed no models — type the id by hand (it still pins)";
122
+ this.loop.markDirty();
123
+ })
124
+ .catch(() => {
125
+ if (!this.state.wizard) return;
126
+ this.state.wizard.modelsLoading = false;
127
+ this.state.wizard.error = "the model list could not be read — type the id by hand";
128
+ this.loop.markDirty();
129
+ });
130
+ }
131
+
132
+ constructor(o: SextantRendererOptions) {
133
+ this.io = o.io; this.clock = o.clock ?? Date.now; this.truecolor = o.truecolor ?? true;
134
+ const themeName: ThemeName = isThemeName(o.theme ?? "") ? (o.theme as ThemeName) : "night";
135
+ this.theme = buildTheme(themeName);
136
+ const cwd = o.cwd ?? process.cwd();
137
+ this.state = initialState({ cwd, repo: { name: basename(cwd) || cwd, branch: null }, version: pkg.version, theme: themeName, mode: "act", yolo: false, commands: [], now: this.clock() });
138
+ this.pet = createPet({ name: o.pet });
139
+ // the reducer's diffFor seam stays empty: the hunks arrive through the watcher's scheduleDiff (a
140
+ // setTimeout(0) from the end event, then async git — nothing runs inside the event dispatch or a painter)
141
+ this.applyEv = makeApplyEvent();
142
+ this.repo = new RepoWatcher({ state: this.state, clock: this.clock, git: o.git ? toAsync(o.git) : spawnGitAsync, dirty: () => this.loop.markDirty() }, o.scan ?? true);
143
+ this.cards = new CardHost({
144
+ state: this.state, dirty: () => this.loop.markDirty(), stopped: () => this.stopped,
145
+ onApprovalOpen: () => this.pet.event("permission", undefined, this.clock()),
146
+ onAllowed: () => this.pet.event("allowed", undefined, this.clock()),
147
+ });
148
+ setAgentsPainter(drawAgents); // #46: the crew board paints the ∷ mode
149
+ this.loop = new FrameLoop({
150
+ io: this.io, clock: this.clock, state: this.state, theme: () => this.theme, pet: this.pet, truecolor: this.truecolor,
151
+ keyCtx: () => ({ hooks: this.hooks ?? NO_HOOKS, local: this.local }),
152
+ beforeInput: (ev, now) => this.beforeInput(ev, now),
153
+ afterInput: (ev, now) => this.afterInput(ev, now),
154
+ onTick: (now) => this.onTick(now),
155
+ });
156
+ }
157
+
158
+ // ------------------------------------------------------------------ test/smoke seams
159
+ /** the last painted frame as text ("" before start) */
160
+ frameText(): string { return this.loop.frameText(); }
161
+ /** the frame interval is live */
162
+ get active(): boolean { return this.loop.active; }
163
+ /** frames painted so far (tests: "nothing paints after stop") */
164
+ get frames(): number { return this.loop.frames; }
165
+ get themeName(): ThemeName { return this.theme.name; }
166
+ /** run one frame-loop tick now (tests: deterministic instead of waiting 40 ms) */
167
+ tick(): void { this.loop.tick(); }
168
+ /** parse input held by the ESC-hold timer now (tests) */
169
+ flushInput(): void { this.loop.flushInput(); }
170
+
171
+ // ------------------------------------------------------------------ lifecycle
172
+ start(hooks: RendererHooks, options?: RendererStartOptions): void | Promise<void> {
173
+ if (this.started) return;
174
+ this.started = true; this.hooks = hooks;
175
+ this.state.bootAt = options?.animate === false ? -Infinity : this.clock();
176
+ options?.beforeFirstRender?.();
177
+ const reveal = (): void => {
178
+ if (this.stopped) return;
179
+ // Rebuild at the current size/state before handing off: a resize or file refresh may have landed.
180
+ this.loop.start(() => {
181
+ options?.onReveal?.();
182
+ this.terminalEntered = true;
183
+ this.io.enterRaw();
184
+ this.io.write(enterSequence(true));
185
+ }, options?.beforeReveal !== undefined);
186
+ };
187
+ if (!options?.beforeReveal) { reveal(); return; }
188
+ this.loop.prepare(); // textbox, cursor and all panels really rendered behind the intro
189
+ const files = this.repo.prepare().catch(() => {
190
+ if (!this.stopped) this.addSystemNote("initial file list unavailable — file tools remain available", "warn");
191
+ });
192
+ return Promise.all([files, options.beforeReveal()]).then(reveal);
193
+ }
194
+ private terminalEntered = false;
195
+
196
+ /** clear the interval, settle every open card/picker (deny / null), restore the terminal */
197
+ /** settled once stop() has run AND the repo watcher's git children are gone (sextant-repo.ts stop) */
198
+ private drained: Promise<void> | null = null;
199
+ /** wait for the git children a stop() killed to be gone — call after stop(); a caller that removes
200
+ * the cwd (a test's scratch repo) must await this first, or Windows answers EBUSY */
201
+ drain(): Promise<void> { return this.drained ?? this.repo.stop(); }
202
+ stop(): void {
203
+ if (this.stopped) return;
204
+ this.stopped = true;
205
+ this.loop.stop();
206
+ this.drained = this.repo.stop(); // kills the git children in flight; drain() awaits them
207
+ this.unsubTasks?.(); this.unsubTasks = null;
208
+ this.cards.settleAll();
209
+ this.restoreTerminal();
210
+ this.hooks = null;
211
+ }
212
+
213
+ /** The terminal, and nothing else. `stop()` ends with this; the crash guard (tui/crash-guard.ts)
214
+ * calls it on its own, because everything above in `stop()` — settling cards, killing the repo
215
+ * watcher's git children — can throw, and a throw before the restore leaves the alt screen open with
216
+ * mouse reporting on, which is the state where every pointer movement types escape sequences at the
217
+ * shell prompt. Idempotent (the second call has nothing to undo) and silent on failure: if writing to
218
+ * stdout is itself what broke, there is nothing left to try. */
219
+ restoreTerminal(): void {
220
+ if (!this.terminalEntered || this.restored) return;
221
+ this.restored = true;
222
+ try { this.io.write(leaveSequence()); } catch { /* the pipe may already be gone */ }
223
+ try { this.io.leaveRaw(); } catch { /* ditto */ }
224
+ }
225
+ private restored = false;
226
+
227
+ attach(ctx: SextantAttach): void {
228
+ this.ctx = ctx;
229
+ const s = this.state;
230
+ s.cwd = ctx.cwd; s.repo.name = basename(ctx.cwd) || ctx.cwd;
231
+ if (ctx.petName) this.pet.state.name = ctx.petName.slice(0, 14);
232
+ this.unsubTasks?.();
233
+ const sync = (): void => {
234
+ const list = ctx.tasks.list();
235
+ if (list.length > s.crew.length) s.code.lane = list.length - 1; // the newest lane is selected
236
+ setCrew(s, list); this.loop.markDirty();
237
+ };
238
+ sync();
239
+ this.unsubTasks = ctx.tasks.subscribe((t) => { sync(); petOnTask(this.pet, t, this.clock()); });
240
+ this.repo.afterAttach();
241
+ this.refreshPlan();
242
+ }
243
+
244
+ // ------------------------------------------------------------------ the event stream (source of truth while busy)
245
+ onEvent(ev: RunEvent): void {
246
+ const now = this.clock(), s = this.state;
247
+ this.applyEv(s, ev, now);
248
+ petOnEvent(this.pet, ev, this.liveCalls, s.cwd, now);
249
+ this.dropNotes += duplicateNotes(ev);
250
+ if (ev.type === "tool_execution_start" && ev.tool === "edit") this.editOps.set(ev.callId, editOpsOf(ev.args));
251
+ if (ev.type === "tool_execution_end" || ev.type === "tool_call_failed") {
252
+ const row = this.rowOf(ev.callId);
253
+ const before = row?.path !== undefined ? this.before.get(row.path) : undefined;
254
+ if (row?.path !== undefined) this.before.delete(row.path);
255
+ const ops = this.editOps.get(ev.callId) ?? [];
256
+ this.editOps.delete(ev.callId);
257
+ if (row && (row.verb === "edit" || row.verb === "write" || row.verb === "remove" || row.verb === "run")) {
258
+ this.repo.afterMutation();
259
+ if (row.path !== undefined && row.path === s.code.file) s.code.content = null; // reload the edited file (before the next paint: onTick)
260
+ this.loadedFile = null;
261
+ if (ev.type === "tool_execution_end" && ev.ok && row.path !== undefined && (row.verb === "edit" || row.verb === "write")) this.repo.scheduleDiff(row, row.path, before, ops);
262
+ }
263
+ }
264
+ this.loop.markDirty();
265
+ }
266
+
267
+ /** pre-approval (the approver runs BEFORE the tool executes — the one real-time moment): capture the
268
+ * file's current content as the diff base, and let the card's previewDiff text fill the code panel's
269
+ * diff view for the pending file; returns the cwd-relative file (null when the card names none) */
270
+ private previewInCodePanel(tool: string, argsPreview: string, detail: string | undefined): string | null {
271
+ if (tool !== "edit" && tool !== "write") return null;
272
+ const path = argsFromPreview(argsPreview).path;
273
+ if (typeof path !== "string" || !path) return null;
274
+ const file = relPath(this.state.cwd, path);
275
+ this.before.set(file, readFileBounded(this.state.cwd, file));
276
+ const hunks = detail ? hunksFromUnified(detail) : [];
277
+ if (!hunks.length) return file;
278
+ const rows = hunks.flatMap((h) => h.rows);
279
+ this.state.code.diff = { file, hunks, add: rows.filter((r) => r.op === "+").length, del: rows.filter((r) => r.op === "-").length };
280
+ this.state.code.mode = "diff";
281
+ return file;
282
+ }
283
+
284
+ // ------------------------------------------------------------------ Renderer → state
285
+ setCommands(cmds: SlashCommand[]): void {
286
+ this.state.commands = cmds.map((c) => ({ name: c.name, description: c.description, ...(c.choices ? { choices: c.choices } : {}), ...(c.choicesThen ? { choicesThen: c.choicesThen } : {}) }));
287
+ this.loop.markDirty();
288
+ }
289
+
290
+ addUser(text: string): void { this.push(userRow(text, this.clock())); }
291
+
292
+ addSystemNote(text: string, tone: "info" | "warn" | "error" = "info"): void {
293
+ if (this.dropNotes > 0) { this.dropNotes--; return; } // the reducer already made this row from the event
294
+ this.push({ kind: "system", text, tone });
295
+ // toasts: every warning/error, and one-line info notes; a multi-line info note (boot banner, /help,
296
+ // /cost) is a transcript row only — a clipped first line would only smear the top of the code panel
297
+ if (tone !== "info" || (!text.includes("\n") && text.length <= TOAST_MAX)) this.toast(text.split("\n")[0] ?? "", tone);
298
+ }
299
+
300
+ beginAssistant(): AssistantView {
301
+ if (this.busy) return NOOP_VIEW; // message_update events stream the row
302
+ const row: Extract<SextantState["messages"][number], { kind: "assistant" }> = { kind: "assistant", text: "", streaming: true };
303
+ this.push(row);
304
+ return { append: (delta) => { row.text += delta; this.loop.markDirty(); }, done: () => { row.streaming = false; this.loop.markDirty(); } };
305
+ }
306
+
307
+ toolStart(callId: string, tool: string, argsPreview: string): void {
308
+ if (this.busy) return;
309
+ this.push(replayToolRow(callId, tool, argsPreview, this.state.cwd));
310
+ }
311
+ toolUpdate(callId: string, note: string): void {
312
+ if (this.busy) return;
313
+ const row = this.rowOf(callId);
314
+ if (row) { row.detail = clipText(note, 80); this.loop.markDirty(); }
315
+ }
316
+ toolEnd(callId: string, ok: boolean, outputPreview: string, durationMs: number): void {
317
+ if (this.busy) return;
318
+ let row = this.rowOf(callId);
319
+ if (!row) { row = replayToolRow(callId, "tool", "{}", this.state.cwd); this.push(row); }
320
+ row.running = false; row.ok = ok; row.ms = durationMs;
321
+ const end = summarizeEnd({ verb: row.verb }, row.tool, ok, outputPreview);
322
+ if (end.detail) row.detail = end.detail;
323
+ this.loop.markDirty();
324
+ }
325
+
326
+ /** the approval card (sextant-cards.ts); an edit/write preview also fills the code panel's diff view.
327
+ * A denied call never executes (no row, no end event), so its captured base is dropped here — a later
328
+ * ungated edit of the same file must not diff against that stale snapshot */
329
+ askApproval(tool: string, argsPreview: string, detail?: string): Promise<ApprovalAnswer> {
330
+ const file = this.previewInCodePanel(tool, argsPreview, detail);
331
+ const answer = this.cards.approval(tool, argsPreview, detail);
332
+ if (file !== null) void answer.then((a) => { if (a === "deny") this.before.delete(file); });
333
+ return answer;
334
+ }
335
+ /** the palette as a picker (sextant-cards.ts) */
336
+ pickOne(items: PickItem[], title?: string): Promise<string | null> { return this.cards.pick(items, title); }
337
+ /** the question card (sextant-cards.ts): `signal` abort dismisses it, a second concurrent ask is rejected */
338
+ askQuestion(q: QuestionPrompt, signal?: AbortSignal): Promise<QuestionAnswer | null> { return this.cards.question(q, signal); }
339
+
340
+ clearTranscript(): void { this.state.messages = []; this.state.stick = true; this.pet.event("fresh", undefined, this.clock()); this.loop.markDirty(); }
341
+
342
+ prefillEditor(text: string): void {
343
+ const I = this.state.input;
344
+ I.text = text; I.cur = text.length; I.sgSel = 0; I.histIdx = -1; this.state.focus = "messages"; this.loop.markDirty();
345
+ }
346
+
347
+ /** busy = a run is in flight: the event stream owns the rows. setBusy(false) after a run the
348
+ * reducer never saw end (an interrupted generator yields no run_end) settles what run_end would have. */
349
+ setBusy(busy: boolean, label?: string): void {
350
+ const s = this.state, now = this.clock();
351
+ this.busy = busy;
352
+ if (busy) { s.running = true; if (label) s.activity.label = label.replace(/…$/, ""); }
353
+ else {
354
+ this.dropNotes = 0;
355
+ if (s.running) {
356
+ s.running = false;
357
+ if (s.activity.startedAt !== null && s.activity.endedAt === null) s.activity.endedAt = now;
358
+ if (s.activity.state !== "SUCCESS" && s.activity.state !== "ERROR") { s.activity.state = "IDLE"; s.activity.label = "stopped"; }
359
+ for (const r of s.messages) if (r.kind === "tool" && r.running) { r.running = false; r.ok = false; r.detail ??= "interrupted"; }
360
+ this.pet.event("stopped", undefined, now);
361
+ }
362
+ s.escUntil = 0; delete s.ctrlCUntil;
363
+ }
364
+ this.loop.markDirty();
365
+ }
366
+
367
+ setStatus(info: StatusInfo): void {
368
+ const s = this.state;
369
+ s.yolo = info.yolo; s.acceptEdits = info.permission === "accept-edits"; if (info.mode) s.mode = info.mode;
370
+ const u = this.ctx?.usage?.();
371
+ setUsage(s, {
372
+ provider: info.provider, model: info.model, turns: info.turns, tokensIn: info.tokensIn, tokensOut: info.tokensOut,
373
+ ...(info.effort !== undefined ? { effort: info.effort } : {}),
374
+ contextTokens: u?.contextTokens ?? 0, contextWindow: this.ctx?.contextWindow(), costUsd: u ? u.costUsd : null,
375
+ });
376
+ this.refreshPlan();
377
+ this.loop.markDirty();
378
+ }
379
+
380
+ // ------------------------------------------------------------------ internals
381
+ private push(row: SextantState["messages"][number]): void { this.state.messages.push(row); this.state.stick = true; this.loop.markDirty(); }
382
+ private rowOf(callId: string): ToolRow | undefined {
383
+ for (let i = this.state.messages.length - 1; i >= 0; i--) { const r = this.state.messages[i]!; if (r.kind === "tool" && r.callId === callId) return r; }
384
+ return undefined;
385
+ }
386
+ private toast(text: string, tone: "info" | "warn" | "error" = "info"): void { pushToast(this.state, clipText(text.trim(), TOAST_MAX), this.clock(), tone); this.loop.markDirty(); }
387
+
388
+ /** the session's todos.json → plan panel; a newly completed step is a pet quip */
389
+ private refreshPlan(): void {
390
+ if (!this.ctx) return;
391
+ setPlan(this.state, loadTodos(join(this.ctx.sessionsDir, this.ctx.store().id)));
392
+ const c = planCounts(this.state);
393
+ if (this.planLoaded && c.completed > this.todoDone) this.pet.event("todo_done", { d: c.completed, n: c.total }, this.clock());
394
+ this.planLoaded = true; this.todoDone = c.completed;
395
+ }
396
+
397
+ /** the renderer-owned effects keys.ts fires after writing the state field (theme rebuild, file/diff loading, toasts) */
398
+ private readonly local: KeyCtx["local"] = {
399
+ setTheme: (name) => { this.theme = buildTheme(name); this.state.theme = name; this.toast(`theme · ${name}`); this.pet.event("theme", undefined, this.clock()); },
400
+ // port #54: an @image mention stages onto the store the surface attached (SextantAttach.attachImage)
401
+ attachImage: (abs) => this.ctx?.attachImage?.(abs),
402
+ setMode: (mode) => { if (mode === "diff" && this.state.code.file) this.repo.loadDiff(this.state.code.file); },
403
+ openFile: (path) => { this.state.code.content = readFileBounded(this.state.cwd, path); this.state.code.hl = null; this.loadedFile = path; },
404
+ toast: (text) => this.toast(text),
405
+ // the market: the overlay is opened at once in its loading state so the frame after ⌃m already shows
406
+ // the box, and the catalog is joined in when the module answers (it may touch the network)
407
+ openMarket: () => {
408
+ openMarket(this.state, [], { kind: "loading" });
409
+ this.loop.markDirty();
410
+ void loadMarket(this.state.cwd, homedir())
411
+ .then((load) => { if (this.state.market) { this.state.market.rows = load.rows; this.state.market.status = load.status; this.state.market.notes = load.notes; this.loop.markDirty(); } })
412
+ .catch((e: unknown) => { if (this.state.market) { this.state.market.status = { kind: "error", reason: e instanceof Error ? e.message : String(e) }; this.loop.markDirty(); } });
413
+ },
414
+ // /context (⌃g). The panel opens on the NEXT frame with what we already have — the model and an
415
+ // empty count — so the chord feels instant, and the real numbers are joined in when counting is
416
+ // done. Counting is synchronous but not free on a long transcript, so it happens off the frame.
417
+ openWizard: () => {
418
+ const rows = this.ctx?.wizardProviders?.();
419
+ if (!rows || rows.length === 0) { this.toast("no provider table behind this surface"); return; }
420
+ openWizard(this.state, rows satisfies WizardProvider[]);
421
+ this.loop.markDirty();
422
+ },
423
+ openContext: () => {
424
+ const ref = this.ctx?.model() ?? { provider: "unknown", model: "unknown" };
425
+ const inputs = this.ctx?.contextInputs?.();
426
+ openContext(this.state, {
427
+ model: `${ref.provider}/${ref.model}`, raw: 0, estimated: 0,
428
+ scale: { factor: 1, measured: false, note: "counting…" },
429
+ slices: [], images: 0, billed: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
430
+ unpricedTurns: 0, live: false, tolerance: 0.05,
431
+ });
432
+ this.loop.markDirty();
433
+ if (!inputs) return; // the panel stays in its "not counted" state and says so
434
+ void loadContext({
435
+ messages: inputs.messages, current: ref, lookup: inputs.lookup,
436
+ fixed: fixedFrom(inputs.runtime, ref),
437
+ }).then((state) => {
438
+ // the human may have closed it while we counted; writing into a closed panel would reopen it
439
+ if (!this.state.context) return;
440
+ this.state.context = { ...state, scroll: this.state.context.scroll };
441
+ this.loop.markDirty();
442
+ }).catch(() => { /* loadContext does not reject; this is belt and braces on the dynamic import */ });
443
+ },
444
+ marketPlan: (row, local) => {
445
+ void planFor(row, { scope: "user", cwd: this.state.cwd, home: homedir() }, local)
446
+ .then((plan) => {
447
+ const m = this.state.market;
448
+ if (!m) return;
449
+ if ("error" in plan) { this.toast(plan.error); return; }
450
+ m.plan = plan;
451
+ this.loop.markDirty();
452
+ });
453
+ },
454
+ // the connect wizard (draw-wizard.ts): the overlay owns the WALK, the attach seams own the
455
+ // registry — a surface without them (the smoke harness) degrades to honest errors, the same
456
+ // way reloadMcp does
457
+ wizardRegister: (id, url, protocol) => {
458
+ const w = this.state.wizard;
459
+ const r = this.ctx?.wizardRegister?.(id, url, protocol);
460
+ if (!w) return;
461
+ // on success the chosen row IS this endpoint now — the walk continues at KEY with a live row
462
+ wizardEndpointRegistered(w, r?.ok ?? false, r?.error ?? "this surface cannot register endpoints — use rovecode provider add");
463
+ if (r?.ok) w.providers[w.selected] = { key: id, label: `${id} — ${url}`, configured: false };
464
+ this.loop.markDirty();
465
+ },
466
+ wizardStoreKey: (provider, secret) => {
467
+ const w = this.state.wizard;
468
+ const r = this.ctx?.wizardStoreKey?.(provider, secret);
469
+ if (!w) return;
470
+ wizardKeyStored(w, r?.ok ?? false, r?.error ?? "this surface cannot store keys — use rovecode auth set");
471
+ // a stored key is what makes the endpoint ASKABLE: the model step that just opened fetches now
472
+ if (r?.ok) this.fetchWizardModels();
473
+ this.loop.markDirty();
474
+ },
475
+ wizardLoadModels: () => { this.fetchWizardModels(); },
476
+ // confirmModels: pin the checked rows as the provider's active list + name the default. The
477
+ // seam (SextantAttach.wizardActivateModels → app.ts activateModels → registry setModels) is
478
+ // Berkay's; this only runs it and advances on its answer. The confirmed id rides the state
479
+ // (modelDefaultTo), so a walk back to re-edit the list keeps the marker where the person left it.
480
+ wizardActivate: (provider, models, makeDefault) => {
481
+ const w = this.state.wizard;
482
+ if (!w) return;
483
+ const r = this.ctx?.wizardActivateModels?.(provider, models, makeDefault);
484
+ if (r?.ok) {
485
+ closeWizard(this.state); // the walk is DONE: the list is pinned, the default is set
486
+ this.toast(`connected — ${r.models !== undefined && r.models.length > 0 ? `${r.models.length} models active` : "all models live"}`);
487
+ } else {
488
+ w.error = r?.error ?? "the model list could not be written";
489
+ }
490
+ this.loop.markDirty();
491
+ },
492
+ wizardModel: (model) => {
493
+ const w = this.state.wizard;
494
+ if (!w) return;
495
+ const provider = w.providers[w.selected]?.key ?? "";
496
+ void (this.ctx?.wizardModels?.(provider) ?? Promise.resolve([]))
497
+ .then((models) => {
498
+ if (!this.state.wizard) return;
499
+ if (models.length === 0) { closeWizard(this.state); this.toast(`connected — ${model} pinned by hand`); } // the typed fallback IS the finish
500
+ else wizardModelConfirmed(this.state.wizard, models);
501
+ this.loop.markDirty();
502
+ })
503
+ .catch(() => { if (this.state.wizard) { this.state.wizard.error = "the model list could not be read — type the id instead"; this.loop.markDirty(); } });
504
+ },
505
+ marketDocs: (row) => {
506
+ void docsFor(row).then((docs) => {
507
+ const m = this.state.market;
508
+ if (!m || !docs) return;
509
+ // write onto the row the list holds, so closing and reopening the pane costs nothing
510
+ const target = m.rows.find((r) => r.kind === row.kind && r.id === row.id);
511
+ if (target) target.docs = docs;
512
+ this.loop.markDirty();
513
+ });
514
+ },
515
+ marketInstall: (row, local) => {
516
+ // `local` is the approved card's own answer (keys.ts hands over req.plan.local): npm runs only now
517
+ void install(row, { scope: "user", cwd: this.state.cwd, home: homedir() }, local === true ? { local: true } : {}).then(async (outcome) => {
518
+ // An MCP server lands in a file this session already read, so the install used to end in
519
+ // "restart rovecode" — a poor answer to "I just installed it". Ask the runtime to re-read the
520
+ // files and connect what is new, and report what actually happened instead of what to do next.
521
+ // Only on success, only for MCP, and never fatal: a reload that throws leaves the install
522
+ // reported exactly as it was, because the write DID happen and saying otherwise would be worse.
523
+ let text = outcome.text;
524
+ if (outcome.ok && row.kind === "mcp" && this.ctx?.reloadMcp) {
525
+ try {
526
+ const r = await this.ctx.reloadMcp();
527
+ const failed = r.failed.find((f) => r.added.includes(f.name));
528
+ text = text.replace(/ · restart rovecode[^·]*/, "");
529
+ // `skipped` before `added`: an entry the loader refused never reaches `added`, so without this
530
+ // the install read "installed" and then said nothing at all about why no tools appeared.
531
+ const skipped = r.skipped.length > 0 ? ` · ${r.skipped.join(" · ")}` : "";
532
+ text += failed
533
+ ? ` · ${failed.name} did not connect: ${failed.error}`
534
+ : r.added.length > 0 ? ` · connected as ${r.added.join(", ")} — no restart needed${skipped}` : skipped;
535
+ } catch { /* the install stands on its own */ }
536
+ }
537
+ const m = this.state.market;
538
+ if (!m?.plan) return;
539
+ m.plan.running = false;
540
+ m.plan.outcome = { ...outcome, text };
541
+ this.loop.markDirty();
542
+ });
543
+ },
544
+ };
545
+
546
+ /** renderer-level intercepts: the picker's Enter/Esc, and the two-press ⌃c guarantee (a ⌃c while busy
547
+ * interrupts through keys.ts and arms ctrlCUntil; a second one inside the window quits even before the
548
+ * interrupted run settled — keys.ts alone would interrupt again while s.running is still true) */
549
+ private beforeInput(ev: InputEvent, now: number): boolean {
550
+ const s = this.state;
551
+ if (this.cards.interceptKey(ev)) return true;
552
+ if (ev.type === "key" && ev.ctrl && ev.name === "c" && s.running && s.ctrlCUntil !== undefined && now < s.ctrlCUntil) { this.hooks?.onExit(); return true; }
553
+ return false;
554
+ }
555
+ private afterInput(ev: InputEvent, now: number): void {
556
+ const s = this.state;
557
+ this.cards.afterKey();
558
+ if (ev.type === "key" && ev.ctrl && ev.name === "c" && s.running) s.ctrlCUntil = now + ESC_WINDOW_MS;
559
+ if (ev.type === "key" && !ev.ctrl && s.input.text.startsWith("/")) { const first = suggestions(s, s.files.paths, fuzzy)[0]; if (first?.kind === "slash") this.pet.suggest(first.label, now); }
560
+ }
561
+
562
+ /** off-frame work, BEFORE the tick's paint: the code panel's file content (a bounded sync read, so
563
+ * the frame that follows an edit shows the file, never `cannot read`) and the repo scan (full after
564
+ * a mutating tool, statuses while idle — async, sextant-repo.ts) */
565
+ private onTick(now: number): void {
566
+ const s = this.state;
567
+ if (s.code.file && s.code.content === null && this.loadedFile !== s.code.file) { s.code.content = readFileBounded(s.cwd, s.code.file); this.loadedFile = s.code.file; this.loop.markDirty(); }
568
+ // the staged images are owned by the store (tui/attach.ts); mirror their names so the prompt shows
569
+ // chips before the message is sent — one read per tick, never a second copy of the list
570
+ const staged = this.ctx?.staged?.() ?? [];
571
+ if (staged.length !== s.staged.length || staged.some((n, i) => n !== s.staged[i])) { s.staged = staged; this.loop.markDirty(); }
572
+ this.repo.onTick(now, s.running);
573
+ }
574
+ }