@visual-z/xeno 0.8.3 → 0.9.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.
Files changed (348) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2066
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1713
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3001
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/canvas.ts +0 -96
  312. package/src/tui/ui/colors.ts +0 -70
  313. package/src/tui/ui/composer.ts +0 -200
  314. package/src/tui/ui/control-panel.ts +0 -25
  315. package/src/tui/ui/control-sidebar.ts +0 -49
  316. package/src/tui/ui/dialog.ts +0 -398
  317. package/src/tui/ui/draft.ts +0 -23
  318. package/src/tui/ui/duration.ts +0 -16
  319. package/src/tui/ui/edge.ts +0 -186
  320. package/src/tui/ui/image-size.ts +0 -44
  321. package/src/tui/ui/ink.ts +0 -210
  322. package/src/tui/ui/items.ts +0 -42
  323. package/src/tui/ui/keys.ts +0 -40
  324. package/src/tui/ui/layout.ts +0 -74
  325. package/src/tui/ui/list-model.ts +0 -86
  326. package/src/tui/ui/list-view.ts +0 -172
  327. package/src/tui/ui/mascot.ts +0 -67
  328. package/src/tui/ui/messages.ts +0 -518
  329. package/src/tui/ui/motion.ts +0 -28
  330. package/src/tui/ui/paths.ts +0 -10
  331. package/src/tui/ui/pictures.ts +0 -108
  332. package/src/tui/ui/screen.ts +0 -548
  333. package/src/tui/ui/status.ts +0 -403
  334. package/src/tui/ui/styled.ts +0 -85
  335. package/src/tui/ui/tool-card.ts +0 -150
  336. package/src/tui/width.ts +0 -567
  337. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  338. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  339. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  340. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  341. package/src/vendor/pi-rich/html.ts +0 -51
  342. package/src/vendor/pi-rich/image-convert.ts +0 -49
  343. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  344. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  345. package/src/vendor/pi-rich/image-resize.ts +0 -123
  346. package/src/vendor/pi-rich/mime.ts +0 -116
  347. package/src/vendor/pi-rich/photon.ts +0 -139
  348. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
@@ -1,1713 +0,0 @@
1
- /**
2
- * The interactive controller (D20): owns one OpenTUI renderer, turns key
3
- * events into the same business operations line mode uses (shell.ts), and
4
- * re-projects the log into view items after every change.
5
- *
6
- * Flow of a frame: something changes (a key, a stream delta, a log event)
7
- * → `paint()` schedules one projection per tick → items, chrome and sidebar
8
- * are pushed to the Screen → OpenTUI diffs cells and writes one frame.
9
- * The log stays the only durable state; everything here is UI state that is
10
- * either rebuilt from the log (transcript) or deliberately lost on exit
11
- * (draft queue that was never sent, scroll position).
12
- */
13
- import { ladderView } from "./control-model.ts";
14
- import { criterionSummary, draftGoal } from "../goal-flow.ts";
15
- import type { RequestOptions } from "../../runtime/loop.ts";
16
- import { closeAppSession } from "../session-lifecycle.ts";
17
- import { homedir } from "node:os";
18
- import { basename, dirname, resolve } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- import { createCliRenderer, resolveImageRenderProtocol, type CliRenderer, type KeyEvent, type PasteEvent, type Selection, type TerminalColors } from "@opentui/core";
21
- import type { LogEvent } from "../../core/events.ts";
22
- import { droppedImagePaths } from "../../core/attachment-input.ts";
23
- import { userInputData, type ImageRef, type UserInput } from "../../core/images.ts";
24
- import { fileReferences } from "../../core/file-references.ts";
25
- import { deliveryBatch, type Phase, type QueuedMessage, type QueueKind } from "../../core/queue.ts";
26
- import { DEFAULT_CONTEXT_POLICY } from "../../core/context-policy.ts";
27
- import { defaultKeymap } from "../../core/keymap.ts";
28
- import { pickerRows } from "../../core/model-picker.ts";
29
- import { modelKey } from "../../core/session-position.ts";
30
- import { flattenTree, type TreeFilter, type TreeNode } from "../../core/session-tree.ts";
31
- import { selectSessions, type SessionSort } from "../../core/session-browser.ts";
32
- import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settings.ts";
33
- import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
34
- import { builtinTheme } from "../../core/theme.ts";
35
- import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
36
- import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from "../../core/session-exit.ts";
37
- import { lastAssistantText } from "../../core/export.ts";
38
- import type { PermissionRequest } from "../../core/permissions.ts";
39
- import type { ApprovalChoice } from "../../runtime/tools.ts";
40
- import { formatExternalEffects, type ExternalEffect } from "../../core/external-effects.ts";
41
- import { hostExternalOptions } from "../../runtime/turn-effects.ts";
42
- import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
43
- import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
44
- import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
45
- import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
46
- import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer } from "../../runtime/oauth.ts";
47
- import { XENO_VERSION } from "../../runtime/version.ts";
48
- import { reportedCost } from "../../runtime/usage.ts";
49
- import { UpdateChecks } from "../update-check.ts";
50
- import { updateNotice } from "../../core/update-check.ts";
51
- import { assertNever } from "../../core/exhaustive.ts";
52
- import { isUpgrade } from "../../core/version.ts";
53
- import type { Effort } from "../../runtime/model.ts";
54
- import { readNode, readCursor, setSessionLabel, forkedAt } from "../../store/log.ts";
55
- import { readNodeHistory } from "../../store/restore.ts";
56
- import { sessionsDir } from "../../store/sessions.ts";
57
- import { readGitStatus } from "../../store/git-status.ts";
58
- import type { GitStatus } from "../../core/git-status.ts";
59
- import { activityStatus, retryLine, type RetryFacts } from "../../core/activity-status.ts";
60
- import { goalStatus } from "../../core/goal-view.ts";
61
- import { generationSpeed } from "../../runtime/usage.ts";
62
- import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
63
- import { projectTrust } from "../../store/trust.ts";
64
- import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
65
- import { nextResourceOverride } from "../../core/resource-selection.ts";
66
- import { archiveSession, renameSession, scanSessionRoots, sessionRoots } from "../../store/session-browser.ts";
67
- import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, loadUiResources } from "../../store/resources.ts";
68
- import { commitUserSettings } from "../preferences.ts";
69
- import { fuzzyMatch } from "../../tui/fuzzy.ts";
70
- import type { DialogSpec, ListAction } from "../../tui/ui/dialog.ts";
71
- import { keyPress, bound, type KeyPress } from "../../tui/ui/keys.ts";
72
- import type { ListItem } from "../../tui/ui/list-model.ts";
73
- import { Screen } from "../../tui/ui/screen.ts";
74
- import type { StatusModel } from "../../tui/ui/status.ts";
75
- import { terminalTitle } from "../../tui/ui/status.ts";
76
- import { fileChanges, firstPrompt, handoffStatus, sessionClock } from "./status-model.ts";
77
- import { ANSI, createStyle } from "../../tui/style.ts";
78
- import { displayPath } from "../../tui/ui/paths.ts";
79
- import { approvalHeading, approvalItems, choiceFromValue } from "../approve.ts";
80
- import { completionAt, completionSources, commandEntries } from "../completions.ts";
81
- import { commandDefinition } from "../commands.ts";
82
- import { completionAction } from "../../core/completion-action.ts";
83
- import { expandInput } from "../input.ts";
84
- import type { TrustChoice } from "../project-trust.ts";
85
- import { describeError, firstLine } from "../render.ts";
86
- import { reloadAppResources } from "../resources.ts";
87
- import { contextBudget, editInExternalEditor, resolveEditorCommand, type ContextBudget, type interactiveHooks } from "../shell.ts";
88
- import type { App } from "../startup.ts";
89
- import { startConnect } from "../provider-connect.ts";
90
- import { ExtensionHost } from "./extension-host.ts";
91
- import { paletteItems, type PaletteInput } from "./palette.ts";
92
- import { imageView, projectItems, type LiveStream, type Notice } from "./project.ts";
93
- import type { ViewItem } from "../../tui/ui/items.ts";
94
- import type { OAuthEffects, Ui } from "./surface.ts";
95
- import { styleTokens, terminalPalette, uiColors } from "./theme.ts";
96
- import { ImageCache } from "./image-cache.ts";
97
- import { ImageDraft } from "../image-draft.ts";
98
- import { sessionControl, type SessionControl, type ControlSnapshot } from "../../runtime/session-control.ts";
99
- import { controlModel } from "./control-model.ts";
100
- import type { ControlTab } from "../../tui/ui/control-panel.ts";
101
- import { programStatus } from "../../core/program-status.ts";
102
- import { ProgramStatusReporter } from "../../runtime/program-status.ts";
103
- import type { PictureSource } from "../../tui/ui/pictures.ts";
104
- import { DEFAULT_CELL } from "../../tui/ui/image-size.ts";
105
- import type { TerminalPalette } from "../../tui/ui/ink.ts";
106
-
107
- type Hooks = typeof interactiveHooks;
108
- type Tone = "info" | "success" | "warning" | "error";
109
-
110
- const loginEffects: OAuthEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
111
- openBrowser: url => { try { Bun.spawn([process.platform === "darwin" ? "open" : "xdg-open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" }); } catch { /* the URL is shown */ } },
112
- callback: startCallbackServer, device: loginGitHubCopilot };
113
-
114
- /** Coalesce projections: at most one per frame interval while streaming. Assumed 16ms (60 fps). */
115
- const PAINT_INTERVAL_MS = 16;
116
- /** Handoff preview refresh window: about 5 Hz at most, inside the 100–250 ms the research suggests. */
117
- const DERIVED_DEBOUNCE_MS = 200;
118
- /** How long to wait for the terminal to report its palette. Ghostty, iTerm2, kitty and WezTerm answer within a few ms; assumed. */
119
- const PALETTE_TIMEOUT_MS = 500;
120
-
121
- function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
122
- return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
123
- }
124
-
125
- /** Only the endpoint host is chrome; credentials, query and path stay out of it. */
126
- function endpointHost(baseUrl: string | undefined): string | null {
127
- try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
128
- }
129
-
130
- export class InteractiveApp implements Ui {
131
- readonly app: App;
132
- readonly screen: Screen;
133
- readonly renderer: CliRenderer;
134
- private readonly hooks: Hooks;
135
- private readonly extensions: ExtensionHost;
136
- oauthEffects: OAuthEffects = { ...loginEffects };
137
- clipboardWriter: (text: string) => boolean | Promise<boolean> = writeClipboard;
138
- clipboardReader: () => Promise<ClipboardContent> = readClipboard;
139
- /** Test seam for /provider custom endpoints. */
140
- connectFetch: ((input: string, init?: RequestInit) => Promise<Response>) | undefined;
141
- sessionName: string | null = null;
142
- queue: QueuedMessage[] = [];
143
- readonly imageDraft: ImageDraft;
144
- get pendingImages(): ImageRef[] { return this.imageDraft.images; }
145
- inflight = false;
146
- toolsExpanded = false;
147
- thinkingExpanded = false;
148
- executionKind: "idle" | "model" | "bash" = "idle";
149
- private abort: AbortController | null = null;
150
- private turn: Promise<void> | null = null;
151
- private readonly work = new Set<Promise<void>>();
152
- private sessionTransition = false;
153
- private transcriptItems: TranscriptItem[] = [];
154
- private ancestors: LogEvent[] = [];
155
- private historyDir = "";
156
- private historyNode = -1;
157
- private historyLength = -1;
158
- private notices: Notice[] = [];
159
- private noticeSerial = 0;
160
- private live: { thinking: string; text: string; tools: Map<string, { name: string; arguments: string }>; output: string; bash: string | null; retryUntil: number; retry: RetryFacts | null } = emptyLive();
161
- private retryTimer: ReturnType<typeof setInterval> | undefined;
162
- private bashDecoder = new TextDecoder();
163
- private branch: string | null;
164
- private git: GitStatus | null = null;
165
- private gitRun: Promise<void> | undefined;
166
- private gitAgain = false;
167
- private attention: ControlSnapshot["waiting"] = null;
168
- /** The objective of a /goal whose criteria are being drafted. */
169
- private drafting: string | undefined;
170
- /** Index of this turn's first usage sample; generation speed reads from here. */
171
- private turnUsageStart = 0;
172
- /** Where this process began counting a goal's tokens: usage samples are kept per process, not in the log. */
173
- private goalUsage: { goal: string; start: number } | undefined;
174
- /** The last goal view and what it was derived from; see goalAndChecks. */
175
- private goalCache: { key: string; view: import("../../core/goal-view.ts").GoalStatus | null; goal?: string } | undefined;
176
- private lastOutcome: ExecutionOutcome | undefined;
177
- private paintTimer: ReturnType<typeof setTimeout> | undefined;
178
- /** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
179
- private derived: ReturnType<typeof handoffStatus> | undefined;
180
- private control: SessionControl | undefined;
181
- private stopControl: (() => void) | undefined;
182
- private controlSnapshot: ControlSnapshot | undefined;
183
- private readonly program: ProgramStatusReporter | undefined;
184
- private readonly stopOsc: (() => void) | undefined;
185
- private programResult = false;
186
- private derivedKey = "";
187
- private derivedTimer: ReturnType<typeof setTimeout> | undefined;
188
- private derivedRun: Promise<void> | null = null;
189
- private derivedAgain = false;
190
- private derivedLast = 0;
191
- /** Projected views and transformed Markdown of durable items, reused across paints (refactor-tui-chrome-cleanup DoD 5). */
192
- private readonly viewCache = new Map<string, { readonly key: string; readonly view: ViewItem | undefined }>();
193
- private readonly textCache = new Map<string, { readonly key: string; readonly text: string }>();
194
- private cacheLabel: string | undefined;
195
- /** Transcript facts recomputed only when the log changes, not on every paint. */
196
- private files: ReturnType<typeof fileChanges> = [];
197
- private prompt: string | null = null;
198
- private startedAt: string | undefined;
199
- /** Set for a forked session: its clock starts at the fork, not at the copied parent history (fix-fork-start-time). */
200
- private forkedAt: string | null = null;
201
- private budgetKey = "";
202
- private budgetCache: ContextBudget | undefined;
203
- /** An extension's own terminal title wins over ours; ours is written only when it changes. */
204
- private titleOverride: string | undefined;
205
- private writtenTitle = "";
206
- private lastInterrupt = 0;
207
- private lastEscape = 0;
208
- private stopThemeWatch: (() => void) | undefined;
209
- private readonly terminalThemeChanged = (mode: string): void => {
210
- const name = this.app.ui?.settings.theme;
211
- if ((name === "system" || name === "auto") && (mode === "light" || mode === "dark")) this.applyTheme(builtinTheme(mode));
212
- };
213
- /** Decoded pictures shared by every node that shows them (feat-tui-inline-images). */
214
- private readonly images = new ImageCache(() => this.app.blobDir);
215
- /** Shared update requests are aborted on close so quitting never waits on the registry. */
216
- private readonly updateChecks = new UpdateChecks();
217
- private manualUpdateRequested = false;
218
- /** The terminal finished answering capability queries: picture support may have changed. */
219
- private readonly capabilitiesChanged = (): void => this.applyPictures();
220
- /** What the terminal reported (OSC 4/10/11); null until it answers, or forever if it never does. */
221
- private palette: TerminalPalette | null = null;
222
- private readonly paletteChanged = (colors: TerminalColors): void => this.setPalette(terminalPalette(colors));
223
- /**
224
- * A finished mouse selection is copied, as the terminal's own copy-on-select
225
- * would (feat-tui-copy-selection). The selection holds only text nodes, so
226
- * borders, glyphs and rules never reach the clipboard. Pi's
227
- * `fullscreenCopyOnSelect` turns it off; both TUI modes share this renderer,
228
- * so it applies to both (fix-tui-copy-setting).
229
- */
230
- private readonly selectionFinished = (selection: Selection): void => {
231
- if (this.disposed || selection.isDragging || this.app.ui?.settings.fullscreenCopyOnSelect === false) return;
232
- const text = selection.getSelectedText();
233
- if (text.trim()) this.track(() => this.copyText(text));
234
- };
235
- private fileIndex: readonly string[] = [];
236
- private closedResolve!: () => void;
237
- readonly closed: Promise<void>;
238
- shutdownRequested = false;
239
- /** Set once the renderer is gone; late async work (timers, warmed grammars) must not touch it. */
240
- private disposed = false;
241
-
242
- constructor(app: App, hooks: Hooks, renderer: CliRenderer, program?: ProgramStatusReporter) {
243
- this.app = app;
244
- this.hooks = hooks;
245
- this.renderer = renderer;
246
- this.program = program;
247
- this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
248
- this.closed = new Promise(resolve => { this.closedResolve = resolve; });
249
- this.branch = null;
250
- this.sessionName = app.args.sessionName || null;
251
- this.thinkingExpanded = app.ui?.settings.hideThinkingBlock === false;
252
- this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
253
- codeBlock: (code, language) => highlightCode(code, language, (token, text) => this.app.ui?.style.token?.(token, text) ?? ANSI.token?.(token, text) ?? text),
254
- });
255
- this.screen.onControlAction = action => {
256
- if (action === "interrupt") this.interrupt();
257
- else this.track(async () => { if (action === "refresh") await this.refreshControl(); else this.acceptControlCriterion(); });
258
- };
259
- this.imageDraft = new ImageDraft(this.screen.composer);
260
- this.extensions = new ExtensionHost({
261
- dialog: spec => this.screen.dialogs.push(spec),
262
- closeDialog: spec => this.screen.dialogs.remove(spec),
263
- notify: (message, tone) => this.notice(message, tone),
264
- redraw: () => this.paint(),
265
- theme: () => this.app.ui?.theme ?? builtinTheme("dark"),
266
- colored: () => true,
267
- editorText: () => this.screen.composer.text,
268
- setEditorText: text => this.screen.composer.setText(text),
269
- insertEditorText: text => this.screen.composer.insert(text),
270
- toolsExpanded: () => this.toolsExpanded,
271
- setToolsExpanded: value => { this.toolsExpanded = value; this.paint(); },
272
- setTitle: title => { this.titleOverride = title; this.writeTitle(title); },
273
- cwd: () => this.app.workspace,
274
- });
275
- this.screen.dialogs.onError = error => this.notice(describeError(error), "error");
276
- this.screen.composer.onChange = () => { this.updateCompletion(); this.paint(); };
277
- this.screen.composer.onCursor = () => this.paint();
278
- renderer.on("theme_mode", this.terminalThemeChanged);
279
- // OpenTUI re-queries the palette after a light/dark switch only while someone listens.
280
- renderer.on("palette", this.paletteChanged);
281
- renderer.on("selection", this.selectionFinished);
282
- renderer.on("capabilities", this.capabilitiesChanged);
283
- renderer.keyInput.on("keypress", (event: KeyEvent) => this.onKey(event));
284
- renderer.keyInput.on("paste", (event: PasteEvent) => this.onPaste(event));
285
- // Chrome depends on width; a resize can also move status into view (bare → topbar / sidebar).
286
- renderer.on("resize", () => { this.requestDerived(); this.paint(); });
287
- void warmSyntaxLanguages().then(() => this.paint());
288
- }
289
-
290
- // ---------------------------------------------------------------- Ui surface
291
- get extensionUi() { return this.extensions.context; }
292
- get signal(): AbortSignal | undefined { return this.abort?.signal; }
293
- get queueLength(): number { return this.queue.length; }
294
-
295
- private keymap() { return this.app.ui?.keymap ?? defaultKeymap(); }
296
- private colors() { return uiColors(this.app.ui?.theme ?? builtinTheme("dark"), this.palette); }
297
-
298
- /** Ask the terminal for its palette once; until (or unless) it answers, colours use the dark/light hint. */
299
- private async detectPalette(): Promise<void> {
300
- try { this.setPalette(terminalPalette(await this.renderer.getPalette({ size: 16, timeout: PALETTE_TIMEOUT_MS }))); }
301
- catch { /* no answer: the fallback inks stay */ }
302
- }
303
- private setPalette(palette: TerminalPalette | null): void {
304
- if (this.disposed || !palette || samePalette(palette, this.palette)) return;
305
- this.palette = palette;
306
- const theme = this.app.ui?.theme;
307
- if (this.app.ui && theme) this.app.ui.style = createStyle(styleTokens(theme, palette));
308
- this.screen.setColors(this.colors());
309
- this.paint();
310
- }
311
-
312
- notice(text: string, tone: Tone = "info"): void {
313
- const trimmed = text.replace(/^\n+|\n+$/g, "");
314
- if (!trimmed.trim()) return;
315
- const after = this.transcriptItems.length;
316
- const last = this.notices.at(-1);
317
- // Consecutive lines from one command read as one block, not a ladder of gaps.
318
- if (last && last.after === after && last.item.kind === "lines" && (last as Notice & { tone?: Tone }).tone === tone) {
319
- const lines = [...(last as Notice & { lines: string[] }).lines, ...trimmed.split("\n")];
320
- this.notices[this.notices.length - 1] = noticeOf(last.id, after, lines, tone);
321
- } else this.notices.push(noticeOf(`notice:${this.noticeSerial++}`, after, trimmed.split("\n"), tone));
322
- this.paint();
323
- }
324
- showDocument(title: string, text: string): void {
325
- this.screen.dialogs.push({ kind: "lines", title, render: () => text.split("\n") });
326
- }
327
- toast(text: string, tone: Tone = "info"): void { this.screen.toast(text, tone); }
328
- redraw(): void { this.paint(); }
329
-
330
- askSecret(title: string, label: string, hint: string): Promise<string | undefined> {
331
- const control = sessionControl(this.app.session); control.setWaiting("auth");
332
- return new Promise(resolve => this.screen.dialogs.push({ kind: "form", title, label, secret: true, hint,
333
- onSubmit: value => { control.setWaiting(null); resolve(value); },
334
- onCancel: () => { control.setWaiting(null); resolve(undefined); } }));
335
- }
336
- confirm(title: string, message: string): Promise<boolean> {
337
- const control = sessionControl(this.app.session); control.setWaiting("question");
338
- return new Promise(resolve => this.screen.dialogs.push({ kind: "confirm", title, message, onAnswer: yes => { control.setWaiting(null); resolve(yes); } }));
339
- }
340
- waiting(title: string, lines: readonly string[], onCancel: () => void, pasteLabel?: string, onPaste?: (value: string) => void): () => void {
341
- const control = sessionControl(this.app.session); control.setWaiting("auth");
342
- const cancel = () => { control.setWaiting(null); onCancel(); };
343
- const spec: DialogSpec = pasteLabel && onPaste
344
- ? { kind: "form", title, label: pasteLabel, secret: true, intro: lines, hint: "waiting for the browser · paste the redirect URL + enter · esc cancel", onSubmit: onPaste, onCancel: cancel }
345
- : { kind: "busy", title, message: lines.join("\n"), onCancel: cancel };
346
- this.screen.dialogs.push(spec);
347
- return () => { this.screen.dialogs.remove(spec); control.setWaiting(null); };
348
- }
349
-
350
- // ----------------------------------------------------------------- lifecycle
351
- async start(): Promise<void> {
352
- this.program?.start();
353
- this.applyConfiguration();
354
- this.followTheme();
355
- void this.detectPalette();
356
- await this.syncTranscript();
357
- await this.hooks.bindExtensions(this.app, this);
358
- this.app.askSlot.current = request => this.ask(request);
359
- void this.refreshFileIndex();
360
- this.requestDerived();
361
- this.paint(true);
362
- // Never awaited: startup and exit do not wait on the network (feat-update-check DoD 3).
363
- void this.checkForUpdateOnStartup();
364
- }
365
-
366
- /**
367
- * Say once when a newer xeno is published (docs/tasks/feat-update-check.md).
368
- * The cached answer is shown at startup; a due check runs in the background
369
- * and is shown now only while the session is still empty, so it never
370
- * interrupts work. xeno names the update command and never runs it.
371
- */
372
- private async checkForUpdateOnStartup(): Promise<void> {
373
- try {
374
- const { input, plan } = await this.updateChecks.prepare({ offline: this.app.args.offline === true, enabled: this.app.ui?.settings.updateCheck });
375
- if (plan.notice && !this.manualUpdateRequested && !this.disposed && !this.inflight) this.notice(plan.notice);
376
- if (!plan.check || this.manualUpdateRequested) return;
377
- const latest = await this.updateChecks.refresh(input);
378
- if (!this.manualUpdateRequested && !plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, input.install));
379
- } catch { /* an update hint is never worth an error */ }
380
- }
381
-
382
- checkForUpdate(): void {
383
- // The explicit action owns its result even when it joins a startup request.
384
- this.manualUpdateRequested = true;
385
- void this.updateChecks.manual({ offline: this.app.args.offline === true, install: true }, (text, tone) => {
386
- if (!this.disposed) this.notice(text, tone);
387
- }, activity => {
388
- if (this.disposed) return;
389
- switch (activity.state) {
390
- case "checking": this.screen.setTransientActivity({ label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs }); break;
391
- case "installing": this.screen.setTransientActivity({ label: `Installing xeno ${activity.version}`, timeoutMs: activity.timeoutMs,
392
- startedAt: activity.startedAt, ...(activity.detail ? { detail: activity.detail } : {}) }); break;
393
- case "idle": this.screen.setTransientActivity(undefined); break;
394
- default: assertNever(activity, "manual update activity");
395
- }
396
- }).catch(() => {
397
- if (!this.disposed) {
398
- this.screen.setTransientActivity(undefined);
399
- this.notice("Could not check for xeno updates. Try /update again.", "error");
400
- }
401
- });
402
- }
403
-
404
- private emptySession(): boolean {
405
- return !this.disposed && !this.inflight && !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell");
406
- }
407
-
408
- requestShutdown(): void {
409
- if (this.shutdownRequested) return;
410
- this.shutdownRequested = true;
411
- void this.hooks.cancelPendingOAuth(this.app);
412
- this.extensions.cancelDialogs();
413
- this.screen.dialogs.clear();
414
- this.abort?.abort();
415
- this.closedResolve();
416
- }
417
- interrupt(): void {
418
- this.abort?.abort();
419
- const control = sessionControl(this.app.session);
420
- if (control.activeTurnId) control.interrupt(control.activeTurnId);
421
- this.returnQueueToEditor();
422
- }
423
-
424
- async settle(): Promise<void> {
425
- await this.hooks.cancelPendingOAuth(this.app);
426
- this.abort?.abort();
427
- this.app.session.cancel();
428
- await this.turn;
429
- await this.app.session.whenIdle();
430
- await Promise.all([...this.work]);
431
- await this.screen.dialogs.idle();
432
- await this.app.extensions?.settle();
433
- }
434
-
435
- dispose(): void {
436
- this.disposed = true;
437
- this.stopControl?.();
438
- this.stopOsc?.(); this.program?.close();
439
- clearTimeout(this.paintTimer); clearInterval(this.retryTimer); clearTimeout(this.derivedTimer); this.derivedTimer = undefined;
440
- this.stopThemeWatch?.();
441
- this.renderer.off("theme_mode", this.terminalThemeChanged);
442
- this.renderer.off("palette", this.paletteChanged);
443
- this.renderer.off("selection", this.selectionFinished);
444
- this.renderer.off("capabilities", this.capabilitiesChanged);
445
- this.updateChecks.cancel();
446
- this.images.dispose();
447
- this.screen.dispose();
448
- delete this.app.askSlot.current;
449
- }
450
-
451
- snapshot(): { width: number; height: number; lines: string[] } {
452
- const buffer = this.renderer.currentRenderBuffer;
453
- const text = new TextDecoder().decode(buffer.getRealCharBytes(true));
454
- return { width: this.renderer.width, height: this.renderer.height, lines: text.split("\n") };
455
- }
456
-
457
- // --------------------------------------------------------------- transcript
458
- async syncTranscript(): Promise<void> {
459
- this.bindControl();
460
- const session = this.app.session, dir = this.app.logDir, node = session.state.node;
461
- if (dir !== this.historyDir || node !== this.historyNode) {
462
- const history = await readNodeHistory(dir, node);
463
- if (!history.ok) { this.notice(`Cannot load complete session history: ${history.error}`, "error"); return; }
464
- if (this.app.session !== session || this.app.logDir !== dir) return;
465
- if (this.historyDir !== dir) {
466
- // A different session: its notices, queue and draft attachments do not carry over.
467
- if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
468
- this.clearStream();
469
- void this.refreshGit();
470
- this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
471
- this.forkedAt = await forkedAt(dir).catch(() => null);
472
- this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
473
- void this.refreshFileIndex();
474
- }
475
- this.ancestors = history.nodes.filter(entry => entry.node !== node).flatMap(entry => entry.events);
476
- // Another node's history: drop views of items it may not contain.
477
- this.clearViewCache();
478
- this.historyDir = dir; this.historyNode = node; this.historyLength = -1;
479
- }
480
- const physical = await readNode(dir, node);
481
- if (this.app.session !== session || session.state.node !== node) return;
482
- if (physical.corrupt.length > 0) throw new Error("Cannot render corrupt session history");
483
- if (this.historyLength !== physical.events.length) {
484
- const events = [...this.ancestors, ...physical.events];
485
- this.transcriptItems = projectTranscript(events, new Set(this.app.extensions?.entryRenderers.keys()));
486
- this.historyLength = physical.events.length;
487
- this.files = fileChanges(this.transcriptItems);
488
- this.prompt = firstPrompt(this.transcriptItems);
489
- this.startedAt = this.forkedAt ?? events.find(event => typeof event.at === "string")?.at ?? physical.header?.created_at;
490
- this.requestDerived();
491
- }
492
- this.paint();
493
- }
494
-
495
- pushDelta(kind: "text" | "thinking", text: string): void {
496
- this.stopRetry();
497
- this.screen.markActivity();
498
- if (kind === "thinking") this.live.thinking += text; else this.live.text += text;
499
- this.paint();
500
- }
501
- pushProgress(progress: NonNullable<import("../../runtime/loop.ts").LoopEvent["progress"]>): void {
502
- this.screen.markActivity();
503
- if (progress.kind === "retry") {
504
- this.stopRetry();
505
- this.live.retryUntil = Date.now() + progress.delayMs;
506
- this.live.retry = { attempt: progress.attempt, seconds: 0, ...(progress.max === undefined ? {} : { max: progress.max }), ...(progress.reason === undefined ? {} : { reason: progress.reason }) };
507
- this.retryTimer = setInterval(() => this.paint(), 200);
508
- } else {
509
- this.stopRetry();
510
- if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
511
- else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
512
- else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
513
- }
514
- this.paint();
515
- }
516
- discardStream(): void { this.clearStream(); this.paint(); }
517
- commitStream(): void { this.clearStream(); this.paint(); }
518
- beginBash(_command: string): void { this.live.bash = ""; this.bashDecoder = new TextDecoder(); this.paint(); }
519
- pushBash(chunk: Uint8Array): void {
520
- this.screen.markActivity();
521
- this.live.bash = `${this.live.bash ?? ""}${this.bashDecoder.decode(chunk, { stream: true })}`.slice(-64 * 1024);
522
- this.paint();
523
- }
524
-
525
- private stopRetry(): void { clearInterval(this.retryTimer); this.retryTimer = undefined; this.live.retryUntil = 0; }
526
- private clearStream(): void { this.stopRetry(); this.live = emptyLive(); this.extensions.clearPartials(); }
527
-
528
- // ------------------------------------------------------------------ painting
529
- /** Schedule one projection; `now` paints synchronously (start, tests). */
530
- paint(now = false): void {
531
- if (this.disposed) return;
532
- if (now) { clearTimeout(this.paintTimer); this.paintTimer = undefined; this.project(); return; }
533
- if (this.paintTimer) return;
534
- this.paintTimer = setTimeout(() => { this.paintTimer = undefined; this.project(); }, PAINT_INTERVAL_MS);
535
- }
536
-
537
- private project(): void {
538
- if (this.disposed) return;
539
- const app = this.app, spec = app.providerConfig.spec;
540
- this.screen.messages.setExpanded(this.toolsExpanded, this.thinkingExpanded);
541
- const live = this.liveStream();
542
- const mermaid = app.ui?.settings.markdown?.mermaid ?? "streaming";
543
- // A changed thinking label changes every thinking view; start the caches over.
544
- if (this.extensions.hiddenThinkingLabel !== this.cacheLabel) { this.cacheLabel = this.extensions.hiddenThinkingLabel; this.clearViewCache(); }
545
- const width = Math.max(20, this.screen.width - 8);
546
- const items = projectItems(this.transcriptItems, {
547
- notices: this.notices,
548
- style: () => app.ui?.style,
549
- ...(live ? { live } : {}),
550
- cache: this.viewCache,
551
- customTool: item => this.extensions.toolRenderer(item.name, item.input, item.result, item.callId),
552
- customMessage: item => this.extensions.messageRenderer(item.customType, item.text, item.data, item.entry),
553
- ...(this.extensions.hiddenThinkingLabel !== undefined ? { hiddenThinkingLabel: this.extensions.hiddenThinkingLabel } : {}),
554
- }).map(item => {
555
- if (item.kind !== "assistant" && item.kind !== "thinking") return item;
556
- const streaming = item.streaming === true;
557
- // Durable text is transformed once per width; a streaming reply changes on every delta and is not cached.
558
- const key = `${item.rev}|${width}|${mermaid}`;
559
- const hit = streaming ? undefined : this.textCache.get(item.id);
560
- let text: string;
561
- if (hit && hit.key === key) text = hit.text;
562
- else {
563
- text = this.extensions.transformMarkdown(item.text, { streaming, messageType: item.kind === "assistant" ? "assistant" : "assistant-thinking", width });
564
- if (item.kind === "assistant" && mermaid !== "off") text = transformMermaid(text, width, mermaid, streaming, (_token, value) => value);
565
- if (!streaming) this.textCache.set(item.id, { key, text });
566
- }
567
- return text === item.text ? item : { ...item, text };
568
- });
569
- const home = !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell" || item.kind === "custom") && !this.inflight;
570
- const clock = this.startedAt ? sessionClock(this.startedAt, new Date()) : undefined;
571
- const view = projectWithStart(items, clock, this.forkedAt !== null);
572
- this.screen.setGreeting(home ? {
573
- version: XENO_VERSION, model: app.credential.ok ? spec.name : null, thinking: app.providerConfig.effort,
574
- workspace: displayPath(app.workspace, homedir()), branch: this.branch,
575
- } : undefined);
576
- // Before the first prompt this region is command feedback, not a transcript.
577
- // Segment provenance remains in the log and appears when conversation starts.
578
- this.screen.setItems(home ? view.filter(item => item.kind !== "segment" || item.id === "session-start") : view);
579
- const working = this.extensions.workingMessage ?? (this.executionKind === "bash" ? "running command" : "working");
580
- this.screen.setDock({ model: spec.name, thinking: app.providerConfig.effort, busy: this.inflight,
581
- activity: this.live.thinking && !this.live.text ? "thinking" : this.live.text ? "streaming" : working });
582
- const status = this.statusModel(items);
583
- this.screen.setStatus(status);
584
- this.writeTitle(this.titleOverride ?? terminalTitle(status));
585
- this.screen.setQueue(this.queue.map(item => `${item.kind === "steering" ? "steer" : "next"} · ${firstLine(item.text, 140)}${item.images?.length ? ` · ${item.images.length} image(s)` : ""}`));
586
- const focused = !this.screen.dialogs.open && !this.screen.composer.completing ? this.imageDraft.focusedImage : undefined;
587
- this.screen.setAttachments(focused ? [imageView(focused.image)] : []);
588
- this.screen.setWidgets(this.extensions.widgetLines);
589
- }
590
-
591
- private statusModel(items: readonly import("../../tui/ui/items.ts").ViewItem[]): StatusModel {
592
- const app = this.app, spec = app.providerConfig.spec, budget = this.budget();
593
- const t = app.session.totals, cost = reportedCost(t, spec.price);
594
- const tools = items.filter(item => item.kind === "tool");
595
- const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
596
- const pending = tools.find(tool => tool.status === "pending");
597
- const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
598
- const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
599
- ...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
600
- ...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
601
- ...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
602
- ...(this.live.retryUntil && this.live.retry ? { retry: { ...this.live.retry, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
603
- return {
604
- version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
605
- workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
606
- mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
607
- provider: { name: spec.provider, api: spec.api, host: endpointHost(spec.baseUrl), role: app.args.role?.profile.name ?? app.args.agent ?? null, ready: app.credential.ok },
608
- activity, git: this.git,
609
- ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
610
- ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
611
- // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
612
- cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null,
613
- ...speedOf(app.session.state.usage.slice(this.turnUsageStart)) } } : {}),
614
- tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
615
- ...(this.derived ?? {}),
616
- ...this.goalAndChecks(),
617
- files: this.files,
618
- resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
619
- ...(extension ? { extension } : {}),
620
- };
621
- }
622
-
623
- /**
624
- * The sidebar's evidence: an explicit goal's block, or -- without one -- a
625
- * Checks line for a request that changed files or declared a contract
626
- * (feat-sidebar-goal-and-speed). Both read the request ledger the control
627
- * snapshot already derived. The goal view is a scan of the whole log, so it
628
- * is cached per log length; only its clock is read on every paint.
629
- */
630
- private goalAndChecks(): Pick<StatusModel, "goal" | "checks"> {
631
- const state = this.controlSnapshot?.goal?.state, session = this.app.session;
632
- const key = `${this.app.logDir}:${session.state.node}:${this.ancestors.length}:${session.state.events.length}:${this.inflight}:${this.drafting ?? ""}:${state?.request.event ?? ""}:${state?.status ?? ""}`;
633
- if (this.goalCache?.key !== key) {
634
- const events = [...this.ancestors, ...session.state.events];
635
- const set = events.findLast(event => event.t === "goal" && event.action === "set");
636
- this.goalCache = { key, view: goalStatus(events, state, { running: this.inflight, ...(this.drafting !== undefined ? { drafting: this.drafting } : {}) }),
637
- ...(set ? { goal: set.id } : {}) };
638
- }
639
- const view = this.goalCache.view;
640
- if (view) {
641
- // The session's own clock stamps the log, so a running goal's time is read on it too.
642
- const now = Date.parse(session.clock.now());
643
- const since = this.goalCache.goal ? session.state.usage.slice(this.goalUsageStart(this.goalCache.goal)) : [];
644
- const tokens = since.reduce((total, sample) => total + sample.input_tokens + sample.cache_creation_input_tokens + sample.cache_read_input_tokens + sample.output_tokens, 0);
645
- return { goal: { phase: view.phase, objective: view.objective, checks: view.checks, requests: view.requests,
646
- elapsedMs: view.phase === "drafting" ? 0 : Math.max(0, (view.endedAt ?? now) - view.startedAt),
647
- ...(state && view.phase !== "drafting" ? ladderView(state) : {}), ...(tokens ? { tokens } : {}) } };
648
- }
649
- if (state?.acceptance) {
650
- // A declared contract without /goal: how many of its criteria hold; /acceptance lists them.
651
- const items = state.acceptance.criteria, passed = items.filter(item => item.status === "pass" || item.status === "accepted").length;
652
- return { checks: { status: passed === items.length ? "pass" : "partial", command: `${passed}/${items.length} passed · /acceptance` } };
653
- }
654
- if (!state?.changed) return {};
655
- const fresh = state.criteria.find(item => item.kind === "fresh_check");
656
- const run = fresh?.command ? state.commands.findLast(command => command.command === fresh.command) : state.commands.at(-1);
657
- if (!fresh || fresh.status === "not_run" || !run) return { checks: { status: "unchecked" } };
658
- return { checks: { status: fresh.status === "pass" ? "pass" : fresh.status === "stale" ? "stale" : "fail", command: run.command, exit: run.exit } };
659
- }
660
-
661
- /** Usage samples belong to this process; a goal's spend counts those recorded after this process first saw it. */
662
- private goalUsageStart(goal: string): number {
663
- if (this.goalUsage?.goal !== goal) this.goalUsage = { goal, start: this.app.session.state.usage.length };
664
- return this.goalUsage.start;
665
- }
666
-
667
- private writeTitle(title: string): void {
668
- if (title === this.writtenTitle || this.disposed) return;
669
- this.writtenTitle = title;
670
- this.renderer.setTerminalTitle(title);
671
- }
672
-
673
- private async refreshGit(): Promise<void> {
674
- if (this.disposed) return;
675
- if (this.gitRun) { this.gitAgain = true; return; }
676
- const workspace = this.app.workspace;
677
- this.gitRun = (async () => {
678
- const status = await readGitStatus(workspace);
679
- if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
680
- })();
681
- try { await this.gitRun; }
682
- finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
683
- }
684
-
685
- private liveStream(): LiveStream | undefined {
686
- const l = this.live;
687
- if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
688
- return { thinking: l.thinking, text: l.text, tools: [...l.tools].map(([id, tool]) => ({ id, ...tool })), output: l.output, bash: l.bash,
689
- ...(l.retryUntil && l.retry ? { retry: retryLine({ ...l.retry, seconds: (l.retryUntil - Date.now()) / 1000 }) } : {}) };
690
- }
691
-
692
- /** The context budget changes only with the log or the model; it is not recounted on every paint. */
693
- private budget(): ContextBudget | undefined {
694
- const s = this.app.session, spec = s.provider.config.spec;
695
- const key = `${this.app.logDir}:${s.state.node}:${s.state.events.length}:${spec.provider}/${spec.id}:${s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY}`;
696
- if (key === this.budgetKey) return this.budgetCache;
697
- this.budgetKey = key;
698
- try {
699
- this.budgetCache = contextBudget({ ...s.contextInput(), anchor: s.state.anchor, policy: s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
700
- contextWindow: spec.contextWindow, outputReserve: s.provider.config.maxTokens,
701
- ...(s.config.counter ? { counter: s.config.counter } : {}), ...(s.config.boundary ? { boundary: s.config.boundary } : {}) });
702
- } catch { this.budgetCache = undefined; }
703
- return this.budgetCache;
704
- }
705
-
706
- /**
707
- * The handoff preview replays the whole log, so it is refreshed at most once
708
- * per debounce window, one run at a time, and only while a surface shows it.
709
- */
710
- private requestDerived(): void {
711
- if (this.disposed || (this.screen.tier === "bare" && !this.screen.controlOpen)) return;
712
- if (this.derivedKey === this.derivedTarget()) return;
713
- if (this.derivedRun || this.derivedTimer) { this.derivedAgain = true; return; }
714
- // Throttle, not a pure debounce: the first change after a quiet spell refreshes at once,
715
- // and a burst collapses into one trailing refresh.
716
- const wait = Math.max(0, this.derivedLast + DERIVED_DEBOUNCE_MS - Date.now());
717
- if (wait === 0) { this.track(() => this.refreshDerived()); return; }
718
- this.derivedTimer = setTimeout(() => { this.derivedTimer = undefined; this.track(() => this.refreshDerived()); }, wait);
719
- }
720
- private derivedTarget(): string { return `${this.app.logDir}:${this.app.session.state.node}:${this.historyLength}`; }
721
- private async refreshDerived(): Promise<void> {
722
- this.derivedLast = Date.now();
723
- const target = this.derivedTarget();
724
- this.derivedRun = (async () => {
725
- const session = this.app.session;
726
- try {
727
- const derived = handoffStatus(await session.previewHandoff());
728
- const snapshot = await sessionControl(session).snapshot(false);
729
- if (this.disposed || this.app.session !== session) return;
730
- this.derived = derived; this.controlSnapshot = snapshot; this.derivedKey = target;
731
- // Idle acceptance changes have no model delta. Reconcile terminal attention
732
- // with the same evidence projection that refreshes the visible goal.
733
- if (!this.inflight) this.program?.update(programStatus({ running: false, waiting: snapshot.waiting,
734
- ...(snapshot.goal?.state.execution ? { outcome: snapshot.goal.state.execution } : {}),
735
- ...(snapshot.goal ? { goal: snapshot.goal.state.status } : {}) }));
736
- this.screen.setControlModel(controlModel(snapshot));
737
- }
738
- catch {
739
- // Unavailable evidence cannot leave a stale verified goal visible.
740
- if (this.disposed || this.app.session !== session) return;
741
- this.controlSnapshot = undefined;
742
- this.screen.setControlModel(undefined);
743
- }
744
- })();
745
- await this.derivedRun;
746
- this.derivedRun = null;
747
- if (this.derivedAgain) { this.derivedAgain = false; this.requestDerived(); }
748
- this.paint();
749
- }
750
-
751
- /** Timers this controller holds right now; none while idle (DoD 9). */
752
- get timers(): number { return this.screen.timers + (this.paintTimer ? 1 : 0) + (this.retryTimer ? 1 : 0) + (this.derivedTimer ? 1 : 0); }
753
-
754
- // ------------------------------------------------------------------- input
755
- private onPaste(event: PasteEvent): void {
756
- const text = new TextDecoder().decode(event.bytes);
757
- if (this.screen.dialogs.open) { if (this.screen.dialogs.paste(text)) return; event.preventDefault(); return; }
758
- this.screen.focusComposer();
759
- const paths = droppedImagePaths(text);
760
- if (paths) { event.preventDefault(); this.track(() => this.pasteClipboard({ files: paths })); }
761
- }
762
-
763
- private onKey(event: KeyEvent): void {
764
- const key = keyPress(event);
765
- if (this.handleKey(key)) event.preventDefault();
766
- }
767
-
768
- /** True when the key was handled and must not reach the focused textarea. Public for tests. */
769
- handleKey(key: KeyPress): boolean {
770
- const b = this.keymap().byAction;
771
- if (bound(b, "app.exit", key) && !this.screen.dialogs.open && !this.screen.composer.text && !this.pendingImages.length) { this.requestShutdown(); return true; }
772
- if (bound(b, "app.suspend", key)) { this.suspend(); return true; }
773
- if (this.screen.dialogs.open) return this.screen.dialogs.key(key);
774
- if (this.screen.handleControlKey(key)) return true;
775
- const composer = this.screen.composer;
776
- if (composer.completing) {
777
- if (key.chord === "up") { composer.moveCompletion(-1); return true; }
778
- if (key.chord === "down") { composer.moveCompletion(1); return true; }
779
- if (key.chord === "escape") { composer.setCompletion(undefined); return true; }
780
- if (key.chord === "tab" || key.chord === "enter") {
781
- const kind = composer.completionView!.kind;
782
- const item = composer.acceptCompletion();
783
- if (item) {
784
- const definition = commandDefinition(composer.text);
785
- const required = !!definition && "argumentHint" in definition && definition.argumentHint.startsWith("<");
786
- if (completionAction(kind, key.chord, required) === "submit") {
787
- const text = composer.text;
788
- this.track(() => this.submit(text, "steering"));
789
- } else { this.updateCompletion(); this.paint(); }
790
- }
791
- return true;
792
- }
793
- }
794
- if (bound(b, "app.palette", key)) { this.openPalette(); return true; }
795
- if (bound(b, "app.clear", key)) {
796
- if (this.inflight) this.interrupt();
797
- else if (composer.text || this.pendingImages.length) { composer.setText(""); }
798
- else if (Date.now() - this.lastInterrupt < 1000) { this.requestShutdown(); return true; }
799
- else this.toast("press ctrl+c again to quit");
800
- this.lastInterrupt = Date.now(); this.paint(); return true;
801
- }
802
- if (bound(b, "app.interrupt", key)) {
803
- if (this.inflight) this.interrupt();
804
- else if (!this.screen.messages.atBottom) this.screen.messages.scrollToBottom();
805
- else if (!composer.text && Date.now() - this.lastEscape < 500) {
806
- const action = this.app.ui?.settings.doubleEscapeAction ?? "tree";
807
- if (action === "tree") this.track(() => this.openTree()); else if (action === "fork") this.track(() => this.openFork());
808
- }
809
- this.lastEscape = Date.now(); this.paint(); return true;
810
- }
811
- if (bound(b, "app.toggleTools", key)) { this.toolsExpanded = !this.toolsExpanded; this.paint(); return true; }
812
- if (bound(b, "app.toggleThinking", key)) { this.track(() => this.hooks.commitSetting(this.app, "hideThinkingBlock", this.thinkingExpanded ? "on" : "off", this)); return true; }
813
- if (bound(b, "app.toggleSidebar", key)) { this.screen.toggleSidebar(); return true; }
814
- if (bound(b, "app.scroll.pageUp", key)) { this.screen.messages.page(-1); return true; }
815
- if (bound(b, "app.scroll.pageDown", key)) { this.screen.messages.page(1); return true; }
816
- if (bound(b, "app.scroll.bottom", key)) { this.screen.messages.scrollToBottom(); return true; }
817
- if (bound(b, "app.scroll.top", key)) { this.screen.messages.scrollToTop(); return true; }
818
- if (bound(b, "app.externalEditor", key)) { this.track(() => this.externalEditor()); return true; }
819
- if (bound(b, "app.clipboard.pasteImage", key)) { this.track(() => this.pasteClipboard()); return true; }
820
- if (bound(b, "app.message.copy", key)) { this.track(() => this.copySelectionOrReply()); return true; }
821
- if (bound(b, "app.message.dequeue", key)) { this.returnQueueToEditor(); this.paint(); return true; }
822
- if (!this.inflight) {
823
- if (bound(b, "app.model", key)) { this.openModel(); return true; }
824
- if (bound(b, "app.cycleModel", key) || bound(b, "app.cycleModelReverse", key)) { this.track(async () => { await this.hooks.cycleScopedModel(this.app, bound(b, "app.cycleModel", key) ? 1 : -1); this.paint(); }); return true; }
825
- if (bound(b, "app.cycleThinking", key)) { this.track(() => this.hooks.cycleThinking(this.app, this)); return true; }
826
- for (const [action, run] of [["app.session.new", () => this.hooks.openNewSession(this.app, this)], ["app.session.tree", () => this.openTree()],
827
- ["app.session.fork", () => this.openFork()], ["app.session.resume", () => this.openResume()]] as const)
828
- if (bound(b, action, key)) { this.track(run); return true; }
829
- }
830
- if (bound(b, "app.message.followUp", key)) { const text = composer.text; this.track(() => this.submit(text, "followup")); return true; }
831
- if (bound(b, "editor.newLine", key)) { composer.insert("\n"); return true; }
832
- if (bound(b, "editor.submit", key)) { const text = composer.text; this.track(() => this.submit(text, "steering")); return true; }
833
- if (bound(b, "editor.historyPrevious", key)) return composer.historyStep(-1);
834
- if (bound(b, "editor.historyNext", key)) return composer.historyStep(1);
835
- for (const [chord, shortcut] of this.app.extensions?.shortcuts ?? []) if (chord === key.chord) {
836
- this.runExtensionAction(() => shortcut.handler(this.hooks.extensionContext(this.app, this))); return true;
837
- }
838
- return false;
839
- }
840
-
841
- /** Track async work started by a key so settle() and tests can await it. */
842
- private track(action: () => unknown): void {
843
- const job = Promise.resolve().then(action).then(() => undefined, (error: unknown) => this.notice(describeError(error), "error"))
844
- .finally(() => { this.work.delete(job); this.paint(); });
845
- this.work.add(job);
846
- }
847
- /**
848
- * Everything a key started has finished, or is waiting for the user to
849
- * answer an open dialog (that work cannot finish without them). A running
850
- * turn is not waited for; see whenIdle.
851
- */
852
- async idle(): Promise<void> {
853
- for (let round = 0; round < 400; round++) {
854
- // A pending status refresh is started work too (it becomes tracked once its window opens).
855
- if (!this.work.size && !this.screen.dialogs.busy && !this.derivedTimer) return;
856
- if (this.screen.dialogs.open && !this.screen.dialogs.busy && this.work.size) {
857
- // Give started work a moment to reach its dialog; then it is the user's turn.
858
- await new Promise(resolve => setTimeout(resolve, 5));
859
- if (this.screen.dialogs.open && !this.screen.dialogs.busy) return;
860
- }
861
- // With only the status timer pending there is nothing to await yet; wait for its window to open.
862
- await Promise.race([Promise.all([...this.work, this.screen.dialogs.idle(), ...(this.work.size ? [] : [new Promise(resolve => setTimeout(resolve, 5))])]), new Promise(resolve => setTimeout(resolve, 5))]);
863
- }
864
- }
865
- /** The running turn and everything it queued have finished, without interrupting anything. */
866
- async whenIdle(): Promise<void> {
867
- for (let round = 0; round < 50; round++) {
868
- const turn = this.turn, work = this.work.size;
869
- await turn; await this.control?.whenIdle().catch(() => {}); await this.idle(); await this.app.session.whenIdle(); await this.app.extensions?.settle();
870
- if (turn === this.turn && !this.inflight && work === this.work.size && !this.work.size) return;
871
- }
872
- }
873
-
874
- private updateCompletion(): void {
875
- const composer = this.screen.composer;
876
- if (this.screen.dialogs.open) { composer.setCompletion(undefined); return; }
877
- const text = composer.text;
878
- const cursor = composer.area.cursorOffset;
879
- composer.setCompletion(completionAt(text, cursor, completionSources(this.app, this.fileIndex)));
880
- if (text.includes("@") && !this.fileIndex.length) void this.refreshFileIndex();
881
- }
882
- private async refreshFileIndex(): Promise<void> {
883
- try { this.fileIndex = (await this.hooks.fileIndexFor(this.app)).paths; } catch { /* completion only */ }
884
- }
885
-
886
- // ------------------------------------------------------------------ submit
887
- private async submit(text: string, kind: QueueKind): Promise<void> {
888
- const input = this.imageDraft.input(text);
889
- const line = input.text.trim();
890
- const composer = this.screen.composer;
891
- if (this.sessionTransition) { this.toast("wait for the session import to finish", "warning"); return; }
892
- if (!line && !input.images.length) return;
893
- composer.remember(text);
894
- composer.setText("");
895
- composer.setCompletion(undefined);
896
- this.screen.messages.scrollToBottom();
897
- const command = this.hooks.parseLine(this.app, line);
898
- if (command.kind === "prompt" && command.skill && this.inflight) { this.notice("This skill needs an idle session. Press Esc to interrupt the current turn first.", "warning"); composer.setText(text); return; }
899
- if (command.kind !== "prompt") {
900
- if (this.inflight && commandDefinition(line)?.busy === "idle") { this.notice("This command needs an idle session. Press Esc to interrupt the current turn first.", "warning"); composer.setText(text); return; }
901
- this.imageDraft.insertImages(input.images);
902
- const result = await this.hooks.handleLine(this.app, line, this);
903
- await this.syncTranscript();
904
- if (result === "quit") this.requestShutdown();
905
- return;
906
- }
907
- const shell = command.text.startsWith("!");
908
- const images = shell ? [] : input.images;
909
- if (shell) this.imageDraft.insertImages(input.images);
910
- if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
911
- if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
912
- this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
913
- this.paint();
914
- }
915
-
916
- /**
917
- * S.2 / S.5. The draft runs as an ordinary, visible and cancellable turn;
918
- * its checklist opens once the reply is in. Only the checked criteria
919
- * become the goal, and the goal's own request starts from the objective.
920
- */
921
- startGoal(objective: string): void {
922
- this.track(async () => {
923
- // The draft request's own text is an instruction to the model, not the goal; the sidebar shows the objective.
924
- this.drafting = objective;
925
- const pending = await draftGoal(this.app, objective, async (prompt, options) => {
926
- await this.runPromptTurn(prompt, [], undefined, options);
927
- // A cancelled draft is not a draft to accept, nor one to send back.
928
- return this.lastOutcome?.status !== "cancelled";
929
- }).finally(() => { this.drafting = undefined; this.paint(); });
930
- if (!pending) return;
931
- this.push({ kind: "list", title: `Accept the checks for: ${objective}`, multi: true, checked: pending.criteria.map(item => item.id), search: false,
932
- items: pending.criteria.map(item => ({ value: item.id, label: item.id, description: criterionSummary(item) })),
933
- onSubmitChecked: async ids => {
934
- if (ids.length === 0) { this.notice("No criteria accepted; the goal was not set.", "warning"); return; }
935
- await this.app.session.setGoal({ ...pending, accepted: ids });
936
- this.notice(`Goal set with ${ids.length} accepted ${ids.length === 1 ? "check" : "checks"}.`, "success");
937
- this.turn = this.runPromptTurn(objective).catch(error => this.notice(describeError(error), "error"));
938
- } });
939
- });
940
- }
941
-
942
- private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill, options?: RequestOptions): Promise<void> {
943
- this.abort = new AbortController();
944
- this.inflight = true;
945
- this.lastOutcome = undefined;
946
- // Speed is per turn: only this turn's model requests count (feat-sidebar-goal-and-speed).
947
- this.turnUsageStart = this.app.session.state.usage.length;
948
- this.programResult = false;
949
- this.program?.update(programStatus({ running: true }));
950
- this.executionKind = text.startsWith("!") ? "bash" : "model";
951
- this.paint();
952
- try {
953
- const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill, options);
954
- if (!sent && !this.app.credential.ok) {
955
- this.programResult = true; this.program?.update(programStatus({ running: false, waiting: "auth" }));
956
- }
957
- if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
958
- } catch (error) {
959
- this.programResult = true;
960
- this.lastOutcome = executionOutcome("execution_error");
961
- this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
962
- throw error;
963
- } finally {
964
- if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
965
- if (!this.programResult) this.program?.update(programStatus({ running: false }));
966
- await this.syncTranscript();
967
- this.abort = null;
968
- this.hooks.dropFileIndex(this.app.workspace);
969
- this.inflight = false;
970
- this.executionKind = "idle";
971
- void this.refreshGit();
972
- this.clearStream();
973
- this.requestDerived();
974
- this.paint();
975
- }
976
- }
977
-
978
- /** send() calls this at after_tools. Files named in a queued line are read now, not when it was typed. */
979
- async pullQueued(phase: Phase): Promise<UserInput | null> {
980
- const batch = this.nextBatch(phase);
981
- if (!batch.length) return null;
982
- const expanded = await Promise.all(batch.map(item => this.expandQueued(item)));
983
- this.queue = this.queue.filter(item => !batch.includes(item));
984
- this.paint();
985
- const present = expanded.filter((input): input is UserInput => input !== null).map(input => userInputData(input));
986
- const text = present.map(input => input.text).join("\n\n"), images = present.flatMap(input => input.images ?? []);
987
- return images.length ? { text, images } : text || null;
988
- }
989
- private async expandQueued(item: QueuedMessage): Promise<UserInput | null> {
990
- const index = fileReferences(item.text).length > 0 ? await this.hooks.fileIndexFor(this.app) : { paths: [], truncated: false };
991
- const expanded = await expandInput(item.text, { workspace: this.app.workspace, index, say: line => this.notice(line),
992
- attachImage: path => attachImageFile(path, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true) });
993
- const images = [...item.images ?? [], ...expanded.images ?? []];
994
- return images.length ? { text: expanded.message, images } : expanded.send ? expanded.message : null;
995
- }
996
- private async drainFollowUps(): Promise<void> {
997
- while (this.abort !== null && !this.abort.signal.aborted) {
998
- const batch = this.nextBatch("turn_end");
999
- if (!batch.length) break;
1000
- this.queue = this.queue.filter(item => !batch.includes(item));
1001
- this.paint();
1002
- if (!await this.hooks.run(this.app, batch.map(item => item.text).join("\n\n"), this.abort.signal, this, batch.flatMap(item => item.images ?? []))) break;
1003
- }
1004
- }
1005
- private nextBatch(phase: Phase): readonly QueuedMessage[] {
1006
- const settings = this.app.ui?.settings;
1007
- return deliveryBatch(this.queue, phase, { steering: settings?.steeringMode ?? "one-at-a-time", followup: settings?.followUpMode ?? "one-at-a-time" });
1008
- }
1009
- private returnQueueToEditor(): void {
1010
- if (!this.queue.length) return;
1011
- for (const item of [...this.queue].reverse()) this.restoreDraft(item.text, item.images ?? []);
1012
- this.queue = [];
1013
- }
1014
- restoreDraft(text: string, images: readonly ImageRef[] = []): void {
1015
- const composer = this.screen.composer, current = composer.text;
1016
- composer.setText(text); this.imageDraft.insertImages(images);
1017
- composer.setText([composer.text, current].filter(Boolean).join("\n"));
1018
- this.paint();
1019
- }
1020
-
1021
- // --------------------------------------------------------------- clipboard
1022
- async pasteClipboard(content?: ClipboardContent): Promise<void> {
1023
- const composer = this.screen.composer, revision = composer.revision, text = composer.text, cursor = composer.cursor;
1024
- const session = this.app.session, blobDir = this.app.blobDir;
1025
- const current = () => !this.disposed && !this.screen.dialogs.open && this.app.session === session &&
1026
- this.app.blobDir === blobDir && composer.revision === revision && composer.text === text && composer.cursor === cursor;
1027
- if (!current()) return;
1028
- try {
1029
- const value = content === undefined ? await this.clipboardReader() : content;
1030
- if (!value || !current()) return;
1031
- if ("files" in value) {
1032
- const text: string[] = [], images: ImageRef[] = [];
1033
- for (const path of value.files) {
1034
- if (/[\x00-\x1f]/.test(path)) throw new Error("Clipboard file path contains control characters");
1035
- const absolute = path.startsWith("file://") ? fileURLToPath(path) : resolve(this.app.workspace, path);
1036
- const image = await attachImageFile(absolute, blobDir, this.app.ui?.settings.images?.autoResize ?? true);
1037
- if (image) images.push(image); else text.push("@" + JSON.stringify(path));
1038
- }
1039
- if (!current()) return;
1040
- this.imageDraft.insertImages(images);
1041
- if (text.length) this.screen.composer.insert(text.join(" "));
1042
- } else if ("image" in value) {
1043
- const image = await attachImageBytes(value.image, "clipboard.png", blobDir, this.app.ui?.settings.images?.autoResize ?? true);
1044
- if (current()) this.imageDraft.insertImages([image]);
1045
- }
1046
- else this.screen.composer.insert(value.text);
1047
- } catch (error) { this.notice(`Paste failed: ${describeError(error)}`, "error"); }
1048
- this.paint();
1049
- }
1050
- private async copySelectionOrReply(): Promise<void> {
1051
- const selected = this.renderer.getSelection()?.getSelectedText();
1052
- if (selected?.trim()) { await this.copyText(selected); return; }
1053
- const reply = lastAssistantText(this.app.session.state.events);
1054
- if (!reply) { this.toast("nothing to copy yet"); return; }
1055
- await this.copyText(reply, "the last reply");
1056
- }
1057
-
1058
- /**
1059
- * Local sessions write through the platform clipboard first and fall back
1060
- * to OSC 52; over SSH the platform clipboard is the remote host's, so OSC 52
1061
- * (the terminal's own clipboard) goes first (feat-tui-copy-selection DoD 3).
1062
- */
1063
- private async copyText(text: string, what?: string): Promise<void> {
1064
- const remote = Boolean(process.env["SSH_CONNECTION"] || process.env["SSH_TTY"]);
1065
- const osc52 = () => { try { return this.renderer.copyToClipboardOSC52(text); } catch { return false; } };
1066
- let ok = remote && osc52();
1067
- if (!ok) ok = await this.clipboardWriter(text);
1068
- if (!ok && !remote) ok = osc52();
1069
- this.toast(ok ? `copied ${what ?? `${[...text].length} chars`}` : "clipboard unavailable", ok ? "success" : "warning");
1070
- }
1071
-
1072
- private async externalEditor(): Promise<void> {
1073
- this.renderer.suspend();
1074
- try {
1075
- const result = await editInExternalEditor({ content: this.screen.composer.text, command: this.app.ui?.settings.externalEditor ?? resolveEditorCommand() });
1076
- if (result.ok) this.screen.composer.setText(result.content); else this.notice(result.message, "warning");
1077
- } finally { this.renderer.resume(); this.paint(); }
1078
- }
1079
-
1080
- private suspend(): void {
1081
- if (process.platform === "win32") return;
1082
- this.renderer.suspend();
1083
- process.once("SIGCONT", () => { this.renderer.resume(); this.paint(true); });
1084
- process.kill(process.pid, "SIGTSTP");
1085
- }
1086
-
1087
- // -------------------------------------------------------------- extensions
1088
- bindExtensionRegistry(): void { this.extensions.bind(this.app.extensions); this.clearViewCache(); }
1089
- runExtensionAction(action: () => void | Promise<void>): void {
1090
- const job = Promise.resolve().then(action).catch(error => this.notice(describeError(error), "error"))
1091
- .finally(() => { this.work.delete(job); void this.syncTranscript(); });
1092
- this.work.add(job);
1093
- }
1094
- runExtensionCommand(name: string, args: string): void {
1095
- this.runExtensionAction(async () => { await this.hooks.bindExtensions(this.app, this); await this.app.extensions?.command(name, args); });
1096
- }
1097
- runSessionTransition(action: () => Promise<void>): void {
1098
- if (this.sessionTransition) return;
1099
- this.sessionTransition = true;
1100
- this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
1101
- }
1102
- deliverExtension(text: string, kind: QueueKind): void {
1103
- if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
1104
- else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
1105
- }
1106
-
1107
- /** Renderers or Markdown transforms changed (extension generation): cached views no longer hold. */
1108
- private clearViewCache(): void { this.viewCache.clear(); this.textCache.clear(); }
1109
-
1110
- /** After /reload, /trust or a session switch: the new resource generation owns the extension UI. */
1111
- async refreshResources(): Promise<void> {
1112
- this.extensions.reset();
1113
- this.clearViewCache();
1114
- await this.hooks.bindExtensions(this.app, this);
1115
- this.applyConfiguration();
1116
- this.followTheme();
1117
- this.historyLength = -1;
1118
- await this.syncTranscript();
1119
- }
1120
- applyConfiguration(): void {
1121
- this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
1122
- this.screen.setColors(this.colors());
1123
- this.screen.setAnimations(this.app.ui?.settings.animations !== false && process.env["XENO_REDUCED_MOTION"] !== "1" && process.env["TERM"] !== "dumb");
1124
- // An explicit setting wins; the default keeps the transcript free of a scrollbar column.
1125
- const scrollbar = this.app.ui?.settings.fullscreenScrollbar;
1126
- this.screen.messages.setScrollbar(scrollbar === "always" || scrollbar === "auto" ? scrollbar : "hidden");
1127
- this.applyPictures();
1128
- this.paint();
1129
- }
1130
-
1131
- /**
1132
- * How pictures are drawn now (feat-tui-inline-images DoD 3). `auto` uses the
1133
- * terminal's answer to OpenTUI's graphics probe; when that leaves only block
1134
- * characters (tmux, unknown terminals) pictures are named instead, because
1135
- * block-character pictures read as noise at thumbnail size.
1136
- */
1137
- private pictureSource(): PictureSource {
1138
- const terminal = this.app.ui?.settings.terminal;
1139
- const requested = terminal?.imageProtocol ?? "auto";
1140
- const resolution = this.renderer.resolution;
1141
- const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
1142
- const show = terminal?.showImages !== false && effective !== "blocks";
1143
- const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
1144
- const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
1145
- ? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
1146
- return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
1147
- ...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha), open: sha => this.openImage(sha) };
1148
- }
1149
- /** A click on a picture (feat-tui-image-full-size): the system viewer shows what the thumbnail cannot. */
1150
- private openImage(sha256: string): void {
1151
- void this.hooks.openImage(this.app, sha256)
1152
- .then(reason => { if (reason) this.notice(reason, "warning"); })
1153
- .catch(error => this.notice(describeError(error), "warning"));
1154
- }
1155
- private applyPictures(): void {
1156
- if (this.disposed) return;
1157
- this.screen.setPictures(this.pictureSource());
1158
- }
1159
- followTheme(): void {
1160
- this.stopThemeWatch?.();
1161
- this.stopThemeWatch = undefined;
1162
- const name = this.app.ui?.settings.theme ?? "dark";
1163
- const spec = this.app.resources ? selectedThemePath(this.app.resources, name, this.app.args.noThemes) : name;
1164
- if (spec === "system" || spec === "auto") {
1165
- // The terminal reports its own appearance; follow it instead of guessing.
1166
- const mode = this.renderer.themeMode;
1167
- this.applyTheme(builtinTheme(mode === "light" ? "light" : "dark"));
1168
- return;
1169
- }
1170
- this.stopThemeWatch = watchTheme(spec, theme => this.applyTheme(theme), message => this.notice(message, "warning"));
1171
- }
1172
-
1173
- private applyTheme(theme: ReturnType<typeof builtinTheme>): void {
1174
- if (this.disposed) return;
1175
- if (this.app.ui) { this.app.ui.theme = theme; this.app.ui.style = createStyle(styleTokens(theme, this.palette)); }
1176
- this.screen.setColors(this.colors()); this.paint();
1177
- }
1178
-
1179
- // ---------------------------------------------------------------- approval
1180
- ask(request: PermissionRequest): Promise<ApprovalChoice> {
1181
- this.program?.update(programStatus({ running: this.inflight, waiting: "permission" }));
1182
- // While the dialog is open the pending call shows ◌: it is waiting on the person, not running.
1183
- this.screen.messages.setWaiting(true);
1184
- const present = (_id: string, resolve: (choice: ApprovalChoice) => void) => {
1185
- const spec: DialogSpec = { kind: "list", title: "Tool approval", search: false, tone: "warning", intro: approvalHeading(request).split("\n"),
1186
- items: approvalItems(request), onPick: item => resolve(choiceFromValue(item.value)), onCancel: () => resolve("deny") };
1187
- this.screen.dialogs.push(spec);
1188
- return () => { this.screen.messages.setWaiting(false); this.screen.dialogs.remove(spec); this.program?.update(programStatus({ running: this.inflight })); };
1189
- };
1190
- // Isolated skills and user shell commands retain their existing local approval lifecycle.
1191
- const control = sessionControl(this.app.session);
1192
- if (control.activeTurnId) return control.ask(request, present);
1193
- return new Promise(resolve => {
1194
- let close: () => void;
1195
- close = present("local", choice => { close(); resolve(choice); });
1196
- });
1197
- }
1198
-
1199
- private bindControl(): void {
1200
- const control = sessionControl(this.app.session);
1201
- if (this.control === control) return;
1202
- this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
1203
- this.attention = null; this.lastOutcome = undefined;
1204
- this.screen.setControlModel(undefined);
1205
- const detachInput = control.attachInput(phase => this.pullQueued(phase));
1206
- const unsubscribe = control.subscribe(notice => {
1207
- if (this.disposed) return;
1208
- if (notice.method === "turn/started") { this.lastOutcome = undefined; this.turnUsageStart = this.app.session.state.usage.length; this.program?.update(programStatus({ running: true })); }
1209
- if (notice.method === "turn/error") {
1210
- this.lastOutcome = executionOutcome("execution_error");
1211
- this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
1212
- this.track(() => this.syncTranscript());
1213
- }
1214
- if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
1215
- if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
1216
- this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
1217
- this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
1218
- }
1219
- if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
1220
- // A remote client drives the same Session; local turns already have the shell's stream adapter.
1221
- if (!this.abort) {
1222
- if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
1223
- if (notice.method === "turn/completed") {
1224
- this.inflight = false; this.executionKind = "idle"; this.clearStream();
1225
- // feat-external-effects-summary: a remote-driven turn shows the same line a local one does.
1226
- const outside = formatExternalEffects((notice.params["externalEffects"] ?? []) as ExternalEffect[], hostExternalOptions());
1227
- if (outside) this.notice(outside, "warning");
1228
- const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
1229
- this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
1230
- }
1231
- if (notice.method === "turn/event") {
1232
- const event = notice.params["event"] as import("../../runtime/loop.ts").LoopEvent;
1233
- this.observeEvent(event);
1234
- if (event.kind === "text_delta" || event.kind === "thinking_delta") this.pushDelta(event.kind === "text_delta" ? "text" : "thinking", event.text ?? "");
1235
- else if (event.kind === "discard") this.discardStream();
1236
- else if (event.kind === "progress") { if (event.progress) this.pushProgress(event.progress); }
1237
- else this.track(() => this.syncTranscript());
1238
- }
1239
- }
1240
- if (notice.method !== "turn/event") { this.derivedKey = ""; this.requestDerived(); this.paint(); }
1241
- });
1242
- this.stopControl = () => { detachInput(); unsubscribe(); };
1243
- }
1244
-
1245
- private async continueRemoteTurn(control: SessionControl, outcome?: import("../../core/outcome.ts").ExecutionOutcome): Promise<void> {
1246
- // Completion is announced before the iterator releases execution. Follow-ups
1247
- // remain editor-owned until that release, preserving the existing queue contract.
1248
- await control.whenIdle().catch(() => {});
1249
- if (this.disposed || this.shutdownRequested || this.control !== control || this.inflight) return;
1250
- if (outcome?.status !== "completed") { this.returnQueueToEditor(); return; }
1251
- const batch = this.nextBatch("turn_end");
1252
- if (!batch.length) return;
1253
- this.queue = this.queue.filter(item => !batch.includes(item));
1254
- await this.runPromptTurn(batch.map(item => item.text).join("\n\n"), batch.flatMap(item => item.images ?? []));
1255
- }
1256
-
1257
- observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
1258
- if (event.outcome) this.lastOutcome = event.outcome;
1259
- if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
1260
- if (event.kind === "done") this.program?.update(programStatus({ running: false,
1261
- ...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
1262
- if (event.kind === "stop_and_ask") this.program?.update(programStatus({ running: false, waiting: "question" }));
1263
- }
1264
-
1265
- async openControl(tab: ControlTab = "goal"): Promise<void> {
1266
- this.bindControl();
1267
- if (!await this.refreshControl()) return;
1268
- this.screen.showControl(controlModel(this.controlSnapshot!), tab);
1269
- this.requestDerived();
1270
- }
1271
-
1272
- private async refreshControl(): Promise<boolean> {
1273
- const control = sessionControl(this.app.session);
1274
- try {
1275
- const snapshot = await control.snapshot();
1276
- if (this.disposed || control !== sessionControl(this.app.session)) return false;
1277
- this.controlSnapshot = snapshot;
1278
- this.screen.setControlModel(controlModel(snapshot));
1279
- return true;
1280
- } catch (error) {
1281
- if (!this.disposed && control === sessionControl(this.app.session)) {
1282
- this.controlSnapshot = undefined;
1283
- this.screen.setControlModel(undefined);
1284
- }
1285
- throw error;
1286
- }
1287
- }
1288
-
1289
- private acceptControlCriterion(): void {
1290
- const control = sessionControl(this.app.session), goal = this.controlSnapshot?.goal;
1291
- const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
1292
- if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return; }
1293
- this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
1294
- items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
1295
- onPick: async item => {
1296
- if (control !== sessionControl(this.app.session)) throw Error("Session changed; refresh the control panel");
1297
- await control.accept(goal.state.request.event, item.value);
1298
- await this.refreshControl();
1299
- } });
1300
- }
1301
-
1302
- // ----------------------------------------------------------------- palette
1303
- private paletteInput(): PaletteInput {
1304
- const current = modelKey(this.app.providerConfig.spec);
1305
- return {
1306
- bindings: this.keymap().byAction, busy: this.inflight, toolsExpanded: this.toolsExpanded, thinkingExpanded: this.thinkingExpanded, sidebar: this.screen.sidebarVisible,
1307
- extraCommands: commandEntries(this.app),
1308
- models: this.app.catalog.filter(spec => this.app.credentialOf(spec).ok).map(spec => ({ key: modelKey(spec), label: spec.name, description: `${spec.provider}/${spec.id}`, current: modelKey(spec) === current })),
1309
- };
1310
- }
1311
-
1312
- openPalette(): void {
1313
- this.screen.dialogs.push({ kind: "list", title: "Command palette", items: paletteItems(this.paletteInput()), placeholder: "search commands, actions and models", rows: 14,
1314
- onPick: item => this.runPaletteItem(item.value) });
1315
- }
1316
-
1317
- private async runPaletteItem(value: string): Promise<void> {
1318
- if (value.startsWith("command:")) {
1319
- const name = value.slice(8);
1320
- const definition = commandDefinition(`/${name}`);
1321
- // Commands with required arguments go to the composer instead of running empty.
1322
- if (definition && "argumentHint" in definition && definition.argumentHint?.startsWith("<")) { this.screen.composer.setText(`/${name} `); return; }
1323
- await this.submit(`/${name}`, "steering");
1324
- return;
1325
- }
1326
- if (value.startsWith("model:")) {
1327
- if (this.inflight) { this.toast("Models can be changed after this turn. Esc interrupts it.", "warning"); return; }
1328
- const spec = this.app.catalog.find(model => modelKey(model) === value.slice(6));
1329
- if (spec) await this.hooks.switchToModel(this.app, spec);
1330
- return;
1331
- }
1332
- switch (value) {
1333
- case "action:toggleTools": this.toolsExpanded = !this.toolsExpanded; break;
1334
- case "action:toggleThinking": await this.hooks.commitSetting(this.app, "hideThinkingBlock", this.thinkingExpanded ? "on" : "off", this); break;
1335
- case "action:toggleSidebar": this.screen.toggleSidebar(); break;
1336
- case "action:appearance": this.openSettingValues("theme"); break;
1337
- case "action:animations": this.openSettingValues("animations"); break;
1338
- case "action:bottom": this.screen.messages.scrollToBottom(); break;
1339
- case "action:top": this.screen.messages.scrollToTop(); break;
1340
- case "action:editor": await this.externalEditor(); break;
1341
- case "action:pasteImage": await this.pasteClipboard(); break;
1342
- case "action:cycleThinking": await this.hooks.cycleThinking(this.app, this); break;
1343
- case "action:interrupt": this.interrupt(); break;
1344
- case "action:quit": this.requestShutdown(); break;
1345
- }
1346
- this.paint();
1347
- }
1348
-
1349
- // ----------------------------------------------------------------- dialogs
1350
- private push(spec: DialogSpec): void { this.screen.dialogs.push(spec); }
1351
-
1352
- openModel(): void {
1353
- if (this.inflight) { this.toast("Models can be changed after this turn. Esc interrupts it.", "warning"); return; }
1354
- const current = modelKey(this.app.providerConfig.spec);
1355
- const rows = pickerRows(this.app.catalog.map(spec => ({
1356
- key: modelKey(spec), provider: spec.provider, providerName: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider,
1357
- id: spec.id, name: spec.name, ready: this.app.credentialOf(spec).ok,
1358
- })), { current, recent: this.app.ui?.settings.recentModels ?? [] });
1359
- const items: ListItem[] = rows.map(row => ({ value: row.key, label: row.model.id, section: row.group,
1360
- description: [row.model.name !== row.model.id ? row.model.name : "", row.model.ready ? "" : "login required"].filter(Boolean).join(" · "),
1361
- keywords: `${row.model.provider} ${row.model.providerName}`, ...(row.current ? { hint: "● current" } : {}) }));
1362
- items.push({ value: "login", label: "+ Connect a provider…", section: "More", description: "subscription, API key, gateway or local server" });
1363
- this.push({ kind: "list", title: `Model · ${rows.filter(row => row.model.ready).length} ready`, items, selected: current,
1364
- onPick: async item => {
1365
- if (item.value === "login") { this.openLogin(); return; }
1366
- const spec = this.app.catalog.find(model => modelKey(model) === item.value);
1367
- if (spec) await this.hooks.switchToModel(this.app, spec);
1368
- } });
1369
- }
1370
- openThinking(): void {
1371
- const spec = this.app.providerConfig.spec, current = this.app.providerConfig.effort;
1372
- this.push({ kind: "list", title: "Thinking", search: false, selected: current,
1373
- items: THINKING_LEVELS.map(level => ({ value: level, label: level, disabled: !this.hooks.effortSupported(spec, level),
1374
- ...(level === current ? { hint: "● current" } : !this.hooks.effortSupported(spec, level) ? { description: "not supported by this model" } : {}) })),
1375
- onPick: async item => { const effort = asThinkingLevel(item.value) as Effort | undefined; if (effort) await this.hooks.applyThinking(this.app, effort, this); } });
1376
- }
1377
-
1378
- openLogin(method?: "oauth" | "api_key"): void {
1379
- const providers = [...new Set(this.app.catalog.map(model => model.provider))].filter(provider => !subscriptionById(provider));
1380
- const status = (provider: string): string => {
1381
- const spec = this.app.catalog.find(model => model.provider === provider);
1382
- const resolved = spec ? this.app.credentialOf(spec) : undefined;
1383
- const stored = this.app.auth[provider];
1384
- return resolved?.ok ? `✓ ${stored?.type === "oauth" && resolved.source === "auth.json" ? "account" : "API key"} · ${resolved.source}` : stored ? "stored credential unavailable" : "";
1385
- };
1386
- const items: ListItem[] = [];
1387
- if (method !== "api_key") for (const option of SUBSCRIPTION_OPTIONS) items.push({ value: `oauth:${option.id}`, label: option.label, section: "Sign in with an account", description: status(option.provider) || "browser or device code" });
1388
- if (!method) {
1389
- items.push({ value: "connect:custom", label: "+ Custom endpoint…", section: "Endpoints", description: "OpenAI / Anthropic / Gemini compatible URL, Ollama, LM Studio" });
1390
- items.push({ value: "connect:manage", label: "⚙ Manage connected…", section: "Endpoints", description: "choose models, replace a key, disconnect" });
1391
- }
1392
- if (method !== "oauth") for (const provider of providers) items.push({ value: `key:${provider}`, label: SOURCE_PROVIDERS[provider]?.name ?? provider, section: "API key",
1393
- description: status(provider) || provider, keywords: provider });
1394
- this.push({ kind: "list", title: "Connect a provider", items, placeholder: "search providers", rows: 14,
1395
- onPick: async item => {
1396
- const [kind, id] = [item.value.slice(0, item.value.indexOf(":")), item.value.slice(item.value.indexOf(":") + 1)];
1397
- if (kind === "oauth") { await this.hooks.startSubscriptionLogin(this.app, id, this); return; }
1398
- if (kind === "connect") { this.openConnect(id === "custom" ? "custom" : "manage"); return; }
1399
- const spec = this.app.catalog.find(model => model.provider === id);
1400
- const resolved = spec ? this.app.credentialOf(spec) : undefined;
1401
- const intro = resolved?.ok && resolved.source !== "auth.json" ? ` · current key from ${resolved.source}` : "";
1402
- const key = await this.askSecret(`API key · ${SOURCE_PROVIDERS[id]?.name ?? id}`, "Key", `paste the key${intro} · $VAR reads the environment · enter save · esc back`);
1403
- if (key) await this.hooks.login(this.app, id, key);
1404
- } });
1405
- }
1406
-
1407
- private openConnect(entry: "custom" | "manage"): void {
1408
- startConnect({ app: this.app, wizard: this.screen.dialogs, close: () => this.screen.dialogs.clear(),
1409
- switchTo: spec => this.hooks.switchToModel(this.app, spec), ...(this.connectFetch ? { fetch: this.connectFetch } : {}) }, entry);
1410
- }
1411
-
1412
- openLogout(): void {
1413
- const stored = Object.keys(this.app.auth).filter(provider => this.app.auth[provider] !== undefined);
1414
- this.push({ kind: "list", title: "Remove a stored credential", items: stored.map(provider => ({ value: provider, label: SOURCE_PROVIDERS[provider]?.name ?? provider, description: provider })),
1415
- empty: "No stored credentials", onPick: async item => { await this.hooks.logout(this.app, item.value); } });
1416
- }
1417
-
1418
- openScopedModels(): void {
1419
- const ready = this.app.catalog.filter(spec => this.app.credentialOf(spec).ok);
1420
- const configured = this.app.ui?.settings.scopedModels;
1421
- const checked = configured ? ready.filter(spec => configured.some(pattern => pattern === modelKey(spec) || pattern === spec.id)).map(modelKey) : ready.map(modelKey);
1422
- this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /provider to add a credential.",
1423
- items: ready.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider, section: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider })),
1424
- onSubmitChecked: async values => {
1425
- const saved = await commitUserSettings(this.app, { scopedModels: values });
1426
- this.app.args.models = ""; this.app.args.modelsExplicit = false;
1427
- if (saved) this.toast(`saved ${values.length} scoped models`, "success");
1428
- } });
1429
- }
1430
-
1431
- openSettings(field?: string): void {
1432
- if (field !== undefined) { this.openSettingValues(field); return; }
1433
- const settings = this.app.ui?.settings ?? {};
1434
- const entries: [string, string, string][] = [
1435
- ["theme", "Theme", settings.theme ?? "dark"],
1436
- ["thinking", "Thinking", this.app.providerConfig.effort],
1437
- ["defaultModel", "Model", modelKey(this.app.providerConfig.spec)],
1438
- ["modelThinkingLevels", "Model thinking defaults", `${Object.keys(settings.modelThinkingLevels ?? {}).length} configured`],
1439
- ["steeringMode", "Steering delivery", settings.steeringMode ?? "one-at-a-time"],
1440
- ["followUpMode", "Follow-up delivery", settings.followUpMode ?? "one-at-a-time"],
1441
- ...DISPLAY_SETTINGS.map(item => [item.id, item.label, settingValue(settings, item.id, item.fallback)] as [string, string, string]),
1442
- ];
1443
- this.push({ kind: "list", title: "Settings", rows: 14, items: entries.map(([id, label, value]) => ({ value: id, label, hint: value, keywords: id })),
1444
- keep: true, onPick: item => this.openSettingValues(item.value) });
1445
- }
1446
-
1447
- private openSettingValues(field: string): void {
1448
- const key = field.toLowerCase();
1449
- if (key === "defaultmodel") { this.openModel(); return; }
1450
- if (key === "modelthinkinglevels") { this.openModelThinkingDefaults(); return; }
1451
- const values = key === "thinking" ? [...supportedThinkingLevels(this.app.providerConfig.spec)] : key === "theme" ? ["dark", "light", "system"] : settingValues(field);
1452
- if (!values.length) { this.notice(`unknown setting: ${field}`, "warning"); return; }
1453
- const current = key === "thinking" ? this.app.providerConfig.effort : settingValue(this.app.ui?.settings ?? {}, field, "");
1454
- const spec: DialogSpec = { kind: "list", title: `Setting · ${field}`, search: values.length > 8, selected: current,
1455
- items: values.map(value => ({ value, label: value,
1456
- ...(key === "theme" ? { description: value === "dark" ? "Terminal colours · assume a dark background" : value === "light" ? "Terminal colours · assume a light background" : value === "system" ? "Terminal colours · follow its light/dark switch" : "Theme file" } : {}),
1457
- ...(value === current ? { hint: "● current" } : {}) })),
1458
- onPick: async item => { await this.hooks.commitSetting(this.app, field, item.value, this); this.reopenSettings(); } };
1459
- this.push(spec);
1460
- if (key === "theme") void discoverThemes(this.app.resources).then(names => {
1461
- if (this.screen.dialogs.top === spec) this.screen.dialogs.list?.setItems(names.map(name => spec.items.find(item => item.value === name) ?? ({ value: name, label: name, ...(name === current ? { hint: "● current" } : {}) })), current);
1462
- });
1463
- }
1464
- /** After a change, show the settings list with the new values (it stayed under the value list). */
1465
- private reopenSettings(): void { if (this.screen.dialogs.top?.title === "Settings") { this.screen.dialogs.pop(); this.openSettings(); } }
1466
-
1467
- private openModelThinkingDefaults(): void {
1468
- const ui = this.app.ui;
1469
- if (!ui) return;
1470
- const keys = [...new Set([...this.app.catalog.filter(spec => this.app.credentialOf(spec).ok).map(modelKey), ...Object.keys(ui.settings.modelThinkingLevels ?? {})])];
1471
- this.push({ kind: "list", title: "Thinking default per model", keep: true, items: keys.map(key => ({ value: key, label: key, hint: ui.settings.modelThinkingLevels?.[key] ?? "inherited" })),
1472
- onPick: item => {
1473
- const model = this.app.catalog.find(spec => modelKey(spec) === item.value);
1474
- this.push({ kind: "list", title: `Thinking default · ${item.value}`, search: false, selected: ui.settings.modelThinkingLevels?.[item.value] ?? "clear",
1475
- items: [{ value: "clear", label: "Clear user override", description: "use the project or global default" },
1476
- ...(model ? supportedThinkingLevels(model).map(level => ({ value: level, label: level })) : [])],
1477
- onPick: async choice => {
1478
- if (choice.value === "clear") {
1479
- await removeModelThinkingOverride(item.value);
1480
- const loaded = await loadUiResources({ ...this.app.args, workspace: this.app.workspace, trusted: this.app.projectTrusted ?? this.app.args.approve ?? false });
1481
- ui.settings = { ...ui.settings, modelThinkingLevels: loaded.settings.modelThinkingLevels ?? {} };
1482
- ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
1483
- } else {
1484
- const patch = { modelThinkingLevels: { [item.value]: choice.value as Effort } };
1485
- await commitUserSettings(this.app, patch);
1486
- }
1487
- this.screen.dialogs.pop(); this.openModelThinkingDefaults();
1488
- } });
1489
- } });
1490
- }
1491
-
1492
- async openResume(): Promise<void> {
1493
- type Row = Extract<Awaited<ReturnType<typeof collect>>[number], { ok: true }>;
1494
- let scope: "current" | "all" = "current", sort: SessionSort = "threaded", rows: Row[] = [];
1495
- const root = { path: dirname(this.app.logDir), workspace: this.app.workspace };
1496
- const scan = new AbortController();
1497
- const items = (query = ""): ListItem[] => selectSessions(rows, query, sort, false, sessionFuzzy).map(row => ({
1498
- value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
1499
- description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.forked_at ? `forked ${row.forked_at.slice(0, 16).replace("T", " ")}` : "",
1500
- row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · "),
1501
- ...(row.path === this.app.logDir ? { hint: "● current" } : {}),
1502
- }));
1503
- const load = async () => {
1504
- rows = [];
1505
- const roots = scope === "current" ? [root] : await sessionRoots(root);
1506
- for await (const batch of scanSessionRoots(roots, scan.signal)) {
1507
- // Empty sessions are hidden, except the one in use (fix-skip-empty-sessions).
1508
- rows.push(...batch.sessions.filter((row): row is Row => row.ok && (row.conversation !== false || row.path === this.app.logDir)));
1509
- if (this.screen.dialogs.top === spec) this.screen.dialogs.list?.setItems(items(this.screen.dialogs.list.query));
1510
- }
1511
- };
1512
- const actions: ListAction[] = [
1513
- { key: "tab", label: "scope", run: async () => { scope = scope === "current" ? "all" : "current"; spec.title = `Resume · ${scope === "current" ? "this folder" : "all workspaces"}`; await load(); } },
1514
- { key: "ctrl+s", label: "sort", run: list => { sort = sort === "threaded" ? "recent" : sort === "recent" ? "relevance" : "threaded"; this.screen.dialogs.list?.setItems(items(this.screen.dialogs.list.query)); void list; } },
1515
- { key: "ctrl+r", label: "rename", run: item => { if (!item) return; this.push({ kind: "form", title: "Rename session", label: "Name", value: rows.find(row => row.path === item.value)?.name ?? "",
1516
- onSubmit: async name => { await renameSession(item.value, name); if (item.value === this.app.logDir) this.sessionName = name || null; await load(); } }); } },
1517
- { key: "ctrl+d", label: "remove", run: item => {
1518
- if (!item) return;
1519
- if (item.value === this.app.logDir) { this.toast("cannot remove the active session", "warning"); return; }
1520
- this.push({ kind: "confirm", title: "Remove session", message: "Remove it from this list? The log is kept in .trash and can be restored.", onAnswer: async yes => {
1521
- if (!yes) return;
1522
- const kept = await archiveSession(item.value, this.app.logDir);
1523
- this.notice(`Session kept at ${kept}`);
1524
- await load();
1525
- } });
1526
- } },
1527
- ];
1528
- const spec: DialogSpec & { kind: "list" } = { kind: "list", title: "Resume · this folder", items: [], placeholder: "search sessions · re:<regex> · \"phrase\"", rows: 14, empty: "Loading sessions…", actions,
1529
- onPick: async item => { scan.abort(); await this.hooks.resumeSession(this.app, item.value, this); }, onCancel: () => scan.abort() };
1530
- this.push(spec);
1531
- await load();
1532
- async function collect() { return [] as Awaited<ReturnType<typeof scanSessionRoots> extends AsyncGenerator<infer S> ? S : never>["sessions"]; }
1533
- }
1534
-
1535
- async openFork(): Promise<void> {
1536
- const points = await this.app.session.userMessages();
1537
- this.push({ kind: "list", title: "Fork · edit a previous message", empty: "No user messages yet",
1538
- items: points.map((point, i) => ({ value: String(i + 1), label: firstLine(point.text, 110), description: `node ${point.node}` })).reverse(),
1539
- onPick: async item => { await this.hooks.forkSession(this.app, item.value, this); } });
1540
- }
1541
-
1542
- async openTree(): Promise<void> {
1543
- const nodes = await this.hooks.treeNodesFor(this.app);
1544
- const filters: TreeFilter[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
1545
- let filter: TreeFilter = this.app.ui?.settings.treeFilterMode ?? "default";
1546
- const items = (): ListItem[] => flattenTree(nodes, filter).map(row => ({ value: row.node.id, label: `${row.branch}${row.node.current ? "● " : row.node.onPath ? "" : "↩ "}${row.node.label ? `[${row.node.label}] ` : ""}${firstLine(row.node.prompt, 90)}`,
1547
- keywords: row.node.content ?? "", ...(row.node.current ? { hint: "here" } : {}), ...(row.node.target === undefined ? { disabled: true } : {}) }));
1548
- const byId = new Map<string, TreeNode>();
1549
- const index = (list: readonly TreeNode[]) => { const stack = [...list]; while (stack.length) { const node = stack.pop()!; byId.set(node.id, node); stack.push(...node.children); } };
1550
- index(nodes);
1551
- const current = flattenTree(nodes, filter).find(row => row.node.current)?.node.id;
1552
- const spec: DialogSpec & { kind: "list" } = { kind: "list", title: `Session tree · ${filter}`, items: items(), rows: 16, empty: "nothing to go back to — no exchange has completed yet",
1553
- ...(current ? { selected: current } : {}),
1554
- actions: [
1555
- { key: "tab", label: "filter", run: () => { filter = filters[(filters.indexOf(filter) + 1) % filters.length]!; spec.title = `Session tree · ${filter}`; this.screen.dialogs.list?.setItems(items()); } },
1556
- { key: "ctrl+l", label: "label", run: item => { const node = item && byId.get(item.value); if (!node?.target) return;
1557
- this.push({ kind: "form", title: "Label this point", label: "Label", value: node.label ?? "", onSubmit: async text => { await setSessionLabel(this.app.logDir, node.target!, text, this.app.session.clock.now()); } }); } },
1558
- { key: "ctrl+y", label: "copy", run: async item => { const node = item && byId.get(item.value); if (!node) return; const ok = await this.clipboardWriter(node.content ?? node.prompt); this.toast(ok ? "copied" : "clipboard unavailable"); } },
1559
- ],
1560
- onPick: async item => {
1561
- const node = byId.get(item.value);
1562
- if (!node?.target) return;
1563
- if (this.inflight) { await this.settle(); this.returnQueueToEditor(); }
1564
- const done = await this.app.session.navigateTo(node.target);
1565
- await this.syncTranscript();
1566
- if (done.editorText !== undefined) this.restoreDraft(done.editorText, done.images ?? []);
1567
- this.notice(`conversation at node ${this.app.session.state.node}`, "success");
1568
- } };
1569
- this.push(spec);
1570
- }
1571
-
1572
- async openResources(): Promise<void> {
1573
- const trusted = (await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve)).trusted;
1574
- const options = { ...this.app.args, workspace: this.app.workspace, trusted };
1575
- let index = await discoverResources(options);
1576
- let scope: "user" | "project" = "user";
1577
- const items = (): ListItem[] => index.entries.map(entry => ({ value: `${entry.kind}:${entry.path}`, label: `${entry.enabled ? "◉" : "○"} ${entry.name}`, section: entry.kind,
1578
- description: `${entry.scope}${entry.trusted ? "" : " · untrusted"}${scope === "project" ? ` · ${entry.override}` : ""}`, keywords: entry.path }));
1579
- const spec: DialogSpec & { kind: "list" } = { kind: "list", title: "Resources · global", items: items(), rows: 14, empty: "No resources found", hint: "space toggle · tab scope · esc close",
1580
- actions: [
1581
- { key: "tab", label: "scope", run: () => { scope = scope === "user" ? "project" : "user"; spec.title = `Resources · ${scope === "user" ? "global" : "project"}`; this.screen.dialogs.list?.setItems(items()); } },
1582
- { key: "space", label: "toggle", run: async item => {
1583
- const entry = index.entries.find(row => `${row.kind}:${row.path}` === item?.value);
1584
- if (!entry) return;
1585
- await setResourceOverride(index, entry, scope, scope === "project" ? nextResourceOverride(entry.override) : entry.inherited ? "skip" : "load");
1586
- index = await discoverResources(options);
1587
- await reloadAppResources(this.app); await this.refreshResources();
1588
- this.screen.dialogs.list?.setItems(items(), item?.value);
1589
- this.toast(entry.trusted ? "saved and reloaded" : "saved; project resources stay blocked until the project is trusted");
1590
- } },
1591
- ] };
1592
- this.push(spec);
1593
- }
1594
-
1595
- async openTrust(): Promise<void> {
1596
- const trust = await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve);
1597
- this.push({ kind: "list", title: "Project trust", search: false,
1598
- intro: [this.app.workspace, `Saved: ${trust.saved ? `${trust.saved.trusted ? "trusted" : "untrusted"} (${trust.saved.path})` : "none"} · this session: ${trust.trusted ? "trusted" : "untrusted"} · tool permissions are separate`],
1599
- items: trustItems(this.app.workspace),
1600
- onPick: async item => { await this.hooks.trustProject(this.app, item.value as TrustChoice, this); } });
1601
- }
1602
-
1603
- openAttachments(): void {
1604
- this.push({ kind: "list", title: "Draft attachments · enter removes", search: false, empty: "No attachments on the draft",
1605
- items: this.pendingImages.map((image, i) => ({ value: String(i), label: image.name, description: `${image.width}×${image.height}` })),
1606
- onPick: item => { this.imageDraft.removeImage(Number(item.value)); this.paint(); } });
1607
- }
1608
- }
1609
-
1610
- export function trustItems(workspace: string): ListItem[] {
1611
- return [
1612
- { value: "once", label: "Trust for this session", description: "load project resources without saving" },
1613
- { value: "here", label: "Trust this directory", description: workspace },
1614
- { value: "parent", label: "Trust the parent directory", description: dirname(workspace) },
1615
- { value: "never", label: "Do not trust", description: "skip project configuration and executable extensions" },
1616
- ];
1617
- }
1618
-
1619
- function emptyLive() { return { thinking: "", text: "", tools: new Map<string, { name: string; arguments: string }>(), output: "", bash: null as string | null, retryUntil: 0, retry: null as RetryFacts | null }; }
1620
-
1621
- function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notice & { lines: string[]; tone: Tone } {
1622
- const prefix = tone === "error" ? "\x1b[31m" : tone === "warning" ? "\x1b[33m" : tone === "success" ? "\x1b[32m" : "";
1623
- return { id, after, lines, tone, item: { kind: "lines", id, rev: String(lines.length), render: () => lines.map(line => prefix ? `${prefix}${line}\x1b[39m` : line) } };
1624
- }
1625
-
1626
- /**
1627
- * Process entry: own the terminal for the session, then give it back with
1628
- * the conversation printed (fullscreen leaves the alternate screen, so the
1629
- * transcript would otherwise vanish with it).
1630
- */
1631
- export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: () => void, renderer?: CliRenderer): Promise<void> {
1632
- const fullscreen = (app.ui?.settings.tuiMode ?? "fullscreen") !== "regular";
1633
- releaseEarlyTty?.();
1634
- // Capture the real writer before OpenTUI owns stdout, so OSC never becomes transcript text.
1635
- const terminalWrite = process.stdout.write.bind(process.stdout);
1636
- const r = renderer ?? await createCliRenderer({ screenMode: fullscreen ? "alternate-screen" : "main-screen", exitOnCtrlC: false, exitSignals: [],
1637
- // Performance (refactor-tui-dock-layout DoD 9): no console capture, no stats, no hover tracking; bursts coalesce at 60 fps.
1638
- consoleMode: "disabled", openConsoleOnError: false, useMouse: true, enableMouseMovement: false, gatherStats: false, memorySnapshotInterval: 0, targetFps: 60, maxFps: 60 });
1639
- const program = new ProgramStatusReporter({ write: text => { terminalWrite(text); }, id: `xeno-${process.pid}`,
1640
- tty: !renderer && process.stdout.isTTY === true, mode: process.env["XENO_PROGRAM_STATUS"] ?? "auto",
1641
- multiplexed: Boolean(process.env["TMUX"] || process.env["STY"]) });
1642
- const ui = new InteractiveApp(app, hooks, r, program);
1643
- const previousSay = app.io.say;
1644
- app.io.say = line => ui.notice(line);
1645
- const stop = (code: number) => { process.exitCode = code; ui.requestShutdown(); };
1646
- // After a hangup the tty is gone: the final frame and terminal reset fail with EIO. Nobody can read them.
1647
- const ignoreDeadTty = (error: NodeJS.ErrnoException) => { if (error.code !== "EIO" && error.code !== "EPIPE") throw error; };
1648
- const terminate = () => stop(143);
1649
- const hangup = () => { process.stdout.on("error", ignoreDeadTty); process.stderr.on("error", ignoreDeadTty); stop(129); };
1650
- const crash = (error: unknown) => {
1651
- if (process.exitCode === 129 && typeof error === "object" && error !== null && ["EIO", "EPIPE"].includes(String((error as { code?: unknown }).code))) return;
1652
- stop(1); ui.notice(describeError(error), "error");
1653
- };
1654
- process.on("SIGTERM", terminate); process.on("SIGHUP", hangup); process.on("uncaughtException", crash); process.on("unhandledRejection", crash);
1655
- const lastResort = () => { try { r.destroy(); } catch { /* already restored */ } };
1656
- process.on("exit", lastResort);
1657
- try {
1658
- await ui.start();
1659
- await ui.closed;
1660
- await ui.settle();
1661
- } finally {
1662
- try { await closeAppSession(app); await hooks.cancelPendingOAuth(app); }
1663
- finally {
1664
- ui.dispose();
1665
- if (!renderer) r.destroy();
1666
- app.io.say = previousSay;
1667
- process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
1668
- }
1669
- if (!renderer && fullscreen) await printExit(app);
1670
- }
1671
- }
1672
-
1673
- /** Restore the shell with a bounded status and resume entry; full replay is explicit. */
1674
- async function printExit(app: App): Promise<void> {
1675
- const history = await readNodeHistory(app.logDir, app.session.state.node);
1676
- const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
1677
- const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
1678
- if (mode === "transcript") {
1679
- for (const item of projectTranscript(events)) {
1680
- if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
1681
- else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
1682
- else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
1683
- }
1684
- }
1685
- process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1686
- const path = resolve(app.logDir);
1687
- process.stdout.write(`\n resume with: ${resumeCommand({ id: basename(path), directory: dirname(path), path }, resolve(sessionsDir(process.cwd())))}\n`);
1688
- }
1689
-
1690
- /** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
1691
- function projectWithStart(items: ReturnType<typeof projectItems>, clock: string | undefined, forked = false): ReturnType<typeof projectItems> {
1692
- // The label is part of rev: a view keyed on rev alone would keep "session" after switching into a fork.
1693
- const label = `${forked ? "forked" : "session"} · ${clock}`;
1694
- return clock ? [{ kind: "segment", id: "session-start", rev: label, label }, ...items] : items;
1695
- }
1696
-
1697
- /** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
1698
- /** The turn's speed when one can be measured; nothing otherwise, so the row never shows a made-up figure. */
1699
- function speedOf(samples: readonly import("../../runtime/usage.ts").UsageSample[]): { speed?: number } {
1700
- const speed = generationSpeed(samples);
1701
- return speed === undefined ? {} : { speed };
1702
- }
1703
-
1704
- export function shortPath(path: string): string {
1705
- const parts = path.split("/");
1706
- return parts.map((part, i) => i === parts.length - 1 || part === "~" || part === "" ? part : part.startsWith(".") ? part.slice(0, 2) : part.slice(0, 1)).join("/");
1707
- }
1708
-
1709
- /** core/session-browser wants (query, text) → match; our fuzzy is (text, query) → score | null. */
1710
- function sessionFuzzy(query: string, text: string): { matches: boolean; score: number } {
1711
- const score = fuzzyMatch(text, query);
1712
- return { matches: score !== null, score: score ?? Number.POSITIVE_INFINITY };
1713
- }