@visual-z/xeno 0.2.1 → 0.4.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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
package/src/apps/shell.ts CHANGED
@@ -1,42 +1,43 @@
1
1
  /**
2
2
  * Interactive loop and one-shot runner.
3
3
  *
4
- * The live UI is pi's TuiMainScreen (D19): the transcript sits in the
5
- * activity area and a collapse may clear terminal scrollback. A pipe or
6
- * CI run never constructs it — `-p` output is parsed by scripts.
4
+ * The live UI is OpenTUI (D20), driven through the same business hooks as
5
+ * line mode. A pipe or CI run never constructs it: `-p` output is parsed
6
+ * by scripts, while the interactive app projects the durable transcript.
7
7
  */
8
8
 
9
9
  import { basename, dirname, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { homedir, tmpdir } from "node:os";
10
+ import { tmpdir } from "node:os";
12
11
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
13
12
  import { assertNever } from "../core/exhaustive.ts";
14
13
  import type { LogEvent } from "../core/events.ts";
14
+ import type { Skill } from "../core/skills.ts";
15
+ import { workReadiness } from "../core/work-state.ts";
15
16
  import { userInputData, type ImageRef, type UserInput } from "../core/images.ts";
16
- import { droppedImagePaths } from "../core/attachment-input.ts";
17
- import { attachImageFile, attachImageBytes, loadDisplayImage } from "../runtime/images.ts";
18
- import { readClipboard, writeClipboard, type ClipboardContent } from "../runtime/clipboard.ts";
17
+ import { attachImageFile } from "../runtime/images.ts";
18
+ import { writeClipboard } from "../runtime/clipboard.ts";
19
19
  import type { ExtensionContext, ExtensionRegistry } from "../runtime/extensions.ts";
20
20
  import { headlessUI } from "../runtime/extension-ui.ts";
21
- import { ExtensionUiController } from "./extension-ui.ts";
22
- import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../runtime/rich-text.ts";
23
- import { projectTranscript, type TranscriptItem } from "../core/transcript.ts";
24
21
  import { fileReferences } from "../core/file-references.ts";
25
22
  import { readNodeHistory } from "../store/restore.ts";
26
- import { Transcript } from "../tui/transcript.ts";
27
23
  import type { Handoff } from "../core/handoff.ts";
28
24
  import { projectContext, type FrozenPrefix } from "../core/project.ts";
29
- import { shouldCut, DEFAULT_BOUNDARY, type BoundaryConfig, type BoundaryDecision } from "../core/boundary.ts";
25
+ import { contextDetail } from "../core/context-detail.ts";
26
+ import type { BoundaryConfig } from "../core/boundary.ts";
27
+ import {
28
+ contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, gaugeLevel, nextBoundary,
29
+ type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
30
+ } from "../core/context-policy.ts";
30
31
  import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
31
- import type { RestorePoint, RewindMode, LoopEvent } from "../runtime/loop.ts";
32
- import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
32
+ import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
33
+ import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
33
34
  import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
34
35
  import { scopedModels } from "../runtime/model-scope.ts";
35
36
  import { equivalentCost, reportedCost } from "../runtime/usage.ts";
36
37
  import type { ProviderConfig } from "../runtime/provider.ts";
37
38
  import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
38
39
  import {
39
- SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, isSubscriptionLogin, subscriptionById,
40
+ SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
40
41
  loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
41
42
  startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
42
43
  ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
@@ -46,81 +47,58 @@ import {
46
47
  import { writeAuthFile } from "../store/auth-store.ts";
47
48
  import { authPath, configDir } from "../store/config.ts";
48
49
  import { catalogCachePath, readModelSources, writeCatalogCache } from "../store/models.ts";
49
- import {
50
- buildFileIndex, indexTruncationNote, type FileIndex,
51
- } from "../store/file-index.ts";
52
- import { currentBranch } from "../store/git-branch.ts";
50
+ import { buildFileIndex, indexTruncationNote, type FileIndex } from "../store/file-index.ts";
53
51
  import { projectTrust, trustPath } from "../store/trust.ts";
54
52
  import { discoverResources, themeResourcePath } from "../store/resource-index.ts";
55
- import { ResourceBrowser } from "./resource-browser.ts";
56
- import { TrustSelector, applyTrustChoice, type TrustChoice } from "./project-trust.ts";
53
+ import { applyTrustChoice, type TrustChoice } from "./project-trust.ts";
57
54
  import { reloadAppResources } from "./resources.ts";
58
55
  import { publishModelCatalog } from "./model-catalog.ts";
59
- import type { PermissionRequest } from "../core/permissions.ts";
60
- import type { ApprovalChoice } from "../runtime/tools.ts";
61
- import { approvalHeading, approvalItems, choiceFromValue } from "./approve.ts";
62
56
  import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
63
57
  import { registerSessionRoot } from "../store/session-browser.ts";
64
- import {
65
- createSession, findSessionByPrefix, listSessions, truncationNote,
66
- sessionsDir,
67
- } from "../store/sessions.ts";
58
+ import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
68
59
  import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
69
- import { Editor } from "../tui/editor.ts";
70
- import type { Key } from "../tui/keys.ts";
71
- import { editorBindings, installBindings, encodeKey, keyMatches } from "../tui/input.ts";
72
- import { interactiveAutocomplete } from "./completions.ts";
73
- import { Container, PaddedDocument, createInteractiveTui, replaceInteractiveTui, Lines, PiText, MarkdownBlock, thinkingBorderColor } from "../tui/host.ts";
74
- import type { InteractiveScreen, MarkdownFeatures } from "../tui/host.ts";
75
- import { createChatViewport, type ChatViewport } from "../tui/chat-viewport.ts";
76
- import { generateSystemThemeColors } from "../tui/system-theme.ts";
77
- import { Settings } from "../tui/settings.ts";
78
- import { SelectList, type SelectItem } from "../tui/select.ts";
79
- import { TreeList, renderTree, treeFromPoints, type TreeNode, type NodeParent, type TreePoint } from "../tui/tree.ts";
80
- import { renderStatus, type StatusState } from "../tui/statusline.ts";
81
- import { deliveryBatch, type Phase, type QueuedMessage, type QueueKind } from "../core/queue.ts";
60
+ import { resolveTuiMode } from "../core/tui-mode.ts";
61
+ import { recordRecent } from "../core/model-picker.ts";
62
+ import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
82
63
  import { cycle } from "../core/cycle.ts";
83
64
  import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
84
- import { changeModelScope, scopeReference, type ScopeChange } from "../core/model-scope.ts";
85
65
  import { modelKey } from "../core/session-position.ts";
86
- import { usableColumns, type Terminal } from "../tui/terminal.ts";
87
- import { ANSI, IDENTITY, PLAIN_ATTRS, createStyle, paint, type Style } from "../tui/style.ts";
88
- import { defaultKeymap, listBindings, type ActionId } from "../core/keymap.ts";
89
- import { builtinTheme, parseTheme, THEME_TOKENS, type Theme } from "../core/theme.ts";
90
- import { DISPLAY_SETTINGS, settingValues, settingValue, settingPatch, mergeSettings } from "../core/settings.ts";
91
- import { writeUserSettings, removeModelThinkingOverride, loadUiResources, resolveTheme, watchTheme, discoverThemes, selectedThemePath } from "../store/resources.ts";
66
+ import { defaultKeymap, listBindings } from "../core/keymap.ts";
67
+ import { settingPatch, mergeSettings } from "../core/settings.ts";
68
+ import { writeUserSettings, resolveTheme } from "../store/resources.ts";
92
69
  import { colorEnabled } from "./render.ts";
93
- import { renderToolView } from "../tui/toolview.ts";
70
+ import { createStyle, IDENTITY } from "../tui/style.ts";
94
71
  import { lastAssistantText, toHtml } from "../core/export.ts";
95
- import { visibleWidth } from "../vendor/pi-tui/utils.ts";
96
72
  import { toJsonEvent } from "../core/json-event.ts";
97
- import { COMMANDS, commandDefinition, parseCommand, type Command } from "./commands.ts";
98
- import {
99
- expandInput,
100
- } from "./input.ts";
73
+ import { sessionControl } from "../runtime/session-control.ts";
74
+ import { COMMANDS, parseCommand, type Command } from "./commands.ts";
75
+ import { expandInput } from "./input.ts";
101
76
  import {
102
77
  describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
103
78
  } from "./render.ts";
104
79
  import {
105
- openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, visibleSchemas, type App,
80
+ openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
106
81
  } from "./startup.ts";
107
- import { SessionBrowser } from "./session-browser.ts";
82
+ import type { TurnSurface, Ui } from "./ui/surface.ts";
108
83
 
109
- const HINT = `type a prompt, or ${COMMANDS.map((command) => `/${command.name}`).join(" ")}`;
84
+ /**
85
+ * One line. Listing all ~40 commands here wrapped into a two-line wall that
86
+ * nobody reads; typing `/` already opens the searchable command menu.
87
+ */
88
+ const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
110
89
  const OAUTH_PASTE = "\0oauth";
111
- const OAUTH_DEVICE = "\0oauth-device";
112
90
 
113
91
  const initializedExtensions = new WeakSet<ExtensionRegistry>();
114
92
  const extensionHistories = new WeakMap<App, LogEvent[]>();
115
- function extensionContext(app: App, tui?: Tui): ExtensionContext {
93
+ function extensionContext(app: App, tui?: Ui): ExtensionContext {
116
94
  const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
117
95
  return {
118
- ui: tui?.extensionUi.context ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
96
+ ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
119
97
  cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
120
98
  model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
121
99
  isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
122
- hasPendingMessages: () => !!tui?.queue.length,
123
- abort: () => { tui?.abort?.abort(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
100
+ hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
101
+ abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
124
102
  getSystemPrompt: () => app.session.config.frozen.system,
125
103
  getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
126
104
  waitForIdle: () => app.session.whenIdle(),
@@ -133,8 +111,8 @@ function extensionContext(app: App, tui?: Tui): ExtensionContext {
133
111
  };
134
112
  }
135
113
 
136
- async function bindExtensions(app: App, tui?: Tui): Promise<void> {
137
- await tui?.extensionUi.bind(app.extensions, app.resources);
114
+ async function bindExtensions(app: App, tui?: Ui): Promise<void> {
115
+ tui?.bindExtensionRegistry();
138
116
  const registry = app.extensions; if (!registry) return;
139
117
  const history = await readNodeHistory(app.logDir, app.session.state.node);
140
118
  if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
@@ -148,29 +126,28 @@ async function bindExtensions(app: App, tui?: Tui): Promise<void> {
148
126
  user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
149
127
  setName: name => nameSession(app, name, tui),
150
128
  setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
151
- getTools: () => visibleSchemas(app.args, registry).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema })),
129
+ getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
130
+ "name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
131
+ ? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
132
+ getAllTools: () => {
133
+ const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
134
+ return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
135
+ .map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
136
+ },
152
137
  setTools: async names => {
153
138
  if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
154
139
  const args = { ...app.args, tools: names, noTools: false };
155
- const tools = visibleSchemas(args, registry);
140
+ const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
156
141
  if (names.some(name => !tools.some(tool => tool.name === name))) throw new Error("Unknown active tool");
157
- await app.session.reloadResources({ ...app.session.config.frozen, tools }); app.args = args;
142
+ await app.session.reloadResources({ ...app.session.config.frozen, tools });
143
+ app.session.config.tools.deferredTools = deferred; app.args = args;
158
144
  },
159
145
  setModel: async model => {
160
146
  if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
161
147
  await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
162
148
  },
163
149
  setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
164
- exec: async (command, args, options) => {
165
- const process = Bun.spawn([command, ...args], { cwd: options?.cwd ?? app.workspace, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
166
- let killed = false;
167
- const kill = () => { killed = true; process.kill(); };
168
- options?.signal?.addEventListener("abort", kill, { once: true });
169
- if (options?.signal?.aborted) kill();
170
- const timer = options?.timeout ? setTimeout(kill, options.timeout) : undefined;
171
- try { const [stdout, stderr, code] = await Promise.all([new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited]); return { stdout, stderr, code, killed }; }
172
- finally { clearTimeout(timer); options?.signal?.removeEventListener("abort", kill); }
173
- },
150
+ exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
174
151
  });
175
152
  if (!initializedExtensions.has(registry)) {
176
153
  initializedExtensions.add(registry);
@@ -187,31 +164,40 @@ function parseLine(app: App, text: string): Command {
187
164
 
188
165
  /**
189
166
  * Inputs maybeCut reads when it decides whether to cut. The gauge must use
190
- * this same set -- a second estimate would disagree with the actual cut.
167
+ * this same set -- a second estimate would disagree with the actual cut. Policy,
168
+ * window and reserve have no defaults: a default is how the footer came to
169
+ * measure against a boundary the active policy never consults.
191
170
  */
192
171
  export interface BudgetInput {
172
+ readonly logHint?: { manifestPath: string; segmentPath: string };
193
173
  readonly frozen: FrozenPrefix;
194
174
  readonly handoff: Handoff | null;
195
175
  readonly events: readonly LogEvent[];
196
176
  readonly anchor: Anchor | null;
197
177
  readonly counter?: TokenCounter;
198
178
  readonly boundary?: Partial<BoundaryConfig>;
199
- /** maybeCut only runs at a turn boundary; the prompt is that boundary. */
200
- readonly atTurnBoundary?: boolean;
179
+ readonly policy: ContextPolicy;
180
+ readonly contextWindow: number | undefined;
181
+ readonly outputReserve: number;
201
182
  }
202
183
 
203
184
  export interface ContextBudget {
204
185
  readonly used: number;
205
- readonly trigger: number;
186
+ /** Nothing the provider reported backs the count: before the first response, after a cut. */
187
+ readonly estimated: boolean;
188
+ /** Where the active policy next acts; null when it names no boundary. */
189
+ readonly trigger: number | null;
206
190
  readonly inherited: number;
207
- readonly decision: BoundaryDecision;
191
+ readonly limits: ContextLimits;
192
+ readonly decision: ContextDecision;
193
+ readonly level: GaugeLevel;
208
194
  }
209
195
 
210
196
  /**
211
197
  * The numbers the status gauge shows, computed the same way maybeCut does:
212
198
  * anchoredCount for the current projection, inheritedTokens from the empty
213
- * node, shouldCut for whether a cut is actually possible. The TUI must not
214
- * recompute the work floor.
199
+ * node, contextLimits and decideContext for what the next turn boundary does.
200
+ * The TUI must not recompute the work floor.
215
201
  */
216
202
  export function contextBudget(input: BudgetInput): ContextBudget {
217
203
  const counter = input.counter ?? estimateTokens;
@@ -219,8 +205,9 @@ export function contextBudget(input: BudgetInput): ContextBudget {
219
205
  frozen: input.frozen,
220
206
  handoff: input.handoff,
221
207
  events: input.events,
208
+ ...(input.logHint ? { logHint: input.logHint } : {}),
222
209
  });
223
- const { tokens } = anchoredCount(
210
+ const { tokens, exactUpTo } = anchoredCount(
224
211
  projected.messages,
225
212
  projected.system,
226
213
  input.anchor,
@@ -231,16 +218,38 @@ export function contextBudget(input: BudgetInput): ContextBudget {
231
218
  frozen: input.frozen,
232
219
  handoff: input.handoff,
233
220
  events: [],
221
+ ...(input.logHint ? { logHint: input.logHint } : {}),
234
222
  });
235
223
  const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
236
- const cfg = { ...DEFAULT_BOUNDARY, ...input.boundary };
237
- const decision = shouldCut({
224
+ const limits = contextLimits({
225
+ policy: input.policy,
226
+ contextWindow: input.contextWindow,
227
+ outputReserve: input.outputReserve,
228
+ ...(input.boundary ? { boundary: input.boundary } : {}),
229
+ });
230
+ // The footer asks what the next turn boundary does, even mid-turn. Readiness
231
+ // and price are not known here, so adaptive reads as deferrable at its soft
232
+ // trigger and certain only at the hard limit.
233
+ const decision = decideContext({
234
+ policy: input.policy,
235
+ continuationReady: false,
236
+ cost: null,
238
237
  tokens,
239
238
  inheritedTokens: inherited,
240
- atTurnBoundary: input.atTurnBoundary ?? true,
239
+ atTurnBoundary: true,
240
+ ...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
241
+ outputReserve: input.outputReserve,
241
242
  ...(input.boundary ? { config: input.boundary } : {}),
242
243
  });
243
- return { used: tokens, trigger: cfg.triggerTokens, inherited, decision };
244
+ return {
245
+ used: tokens,
246
+ estimated: exactUpTo === 0,
247
+ trigger: nextBoundary(limits),
248
+ inherited,
249
+ limits,
250
+ decision,
251
+ level: gaugeLevel(tokens, limits, decision),
252
+ };
244
253
  }
245
254
 
246
255
  export function resolveEditorCommand(
@@ -315,9 +324,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
315
324
 
316
325
  export async function runPrompt(app: App, prompt: string): Promise<void> {
317
326
  await bindExtensions(app);
318
- if (app.shutdownRequested) { await app.extensions?.dispose(); return; }
319
- const ok = await run(app, prompt);
320
- await app.extensions?.dispose();
327
+ if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
328
+ let ok: boolean;
329
+ try { ok = await run(app, prompt); }
330
+ finally { await app.session.dispose(); await app.extensions?.dispose(); }
321
331
  printUsage(app);
322
332
  // One-shot mode is scriptable, so a failed turn has to be a failed exit.
323
333
  if (!ok) process.exit(1);
@@ -335,7 +345,7 @@ async function runLines(app: App): Promise<void> {
335
345
  await bindExtensions(app);
336
346
  const C = app.io.palette;
337
347
  if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
338
- /** Set by `/login <provider>`; the next line typed is the key, not a prompt. */
348
+ /** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
339
349
  let awaitingKeyFor: string | null = null;
340
350
  for await (const line of console) {
341
351
  const text = line.trim();
@@ -366,72 +376,54 @@ async function runLines(app: App): Promise<void> {
366
376
  if (typeof outcome === "object") awaitingKeyFor = outcome.login;
367
377
  }
368
378
  await cancelPendingOAuth(app);
379
+ await app.session.dispose();
369
380
  await app.extensions?.dispose();
370
381
  printUsage(app);
371
382
  }
372
383
 
373
384
  async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
374
- const C = app.io.palette;
375
385
  if (!app.args.noSession) await registerSessionRoot({ path: dirname(app.logDir), workspace: app.workspace }).catch(error => app.io.say(String(error)));
376
- if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
377
- const tui = new Tui(app);
378
- const previousSay = app.io.say;
379
- app.io.say = (line) => tui.writeLine(line);
380
- await tui.syncTranscript();
381
- // Transfer ownership synchronously: Pi must remember the shell's cooked mode, not startup's temporary raw mode.
382
- releaseEarlyTty?.();
383
- tui.attach();
384
- await bindExtensions(app, tui);
385
- app.askSlot.current = (request) => tui.ask(request);
386
- tui.paint();
387
- let shuttingDown = false;
388
- const stop = (code: number) => {
389
- if (shuttingDown) return;
390
- shuttingDown = true;
391
- process.exitCode = code;
392
- tui.requestShutdown();
393
- };
394
- const terminate = () => stop(143);
395
- const hangup = () => stop(129);
396
- const crash = (error: unknown) => { stop(1); try { app.io.err(describeError(error)); } catch { /* dead terminal */ } };
397
- const lastResort = () => tui.detach();
398
- process.on("SIGTERM", terminate);
399
- process.on("SIGHUP", hangup);
400
- process.on("uncaughtException", crash);
401
- process.on("unhandledRejection", crash);
402
- process.on("exit", lastResort);
403
- try {
404
- while (true) {
405
- const data = await tui.nextInput();
406
- if (shuttingDown || tui.shutdownRequested) { await tui.settle(); break; }
407
- const result = await tui.applyInput(data, true).catch((error: unknown) => {
408
- tui.writeLine(app.io.palette.red(describeError(error)));
409
- return "continue" as const;
410
- });
411
- if (result === "quit") { await tui.settle(); break; }
412
- }
413
- } finally {
414
- try {
415
- await app.extensions?.dispose();
416
- await cancelPendingOAuth(app);
417
- await tui.screen.terminal.drainInput();
418
- } finally {
419
- tui.detach();
420
- delete app.askSlot.current;
421
- app.io.say = previousSay;
422
- }
423
- process.off("SIGTERM", terminate);
424
- process.off("SIGHUP", hangup);
425
- process.off("uncaughtException", crash);
426
- process.off("unhandledRejection", crash);
427
- process.off("exit", lastResort);
428
- printUsage(app);
429
- }
386
+ // Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
387
+ if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
388
+ const { runInteractive } = await import("./ui/app.ts");
389
+ await runInteractive(app, interactiveHooks, releaseEarlyTty);
390
+ printUsage(app);
430
391
  }
431
392
 
393
+ /**
394
+ * The business operations the interactive controller drives. Exported as one
395
+ * object so apps/ui/app.ts depends on shell.ts in one direction only and the
396
+ * set of things the UI can do is visible in one place.
397
+ */
398
+ export const interactiveHooks = {
399
+ handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
400
+ run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
401
+ parseLine: (app: App, text: string) => parseLine(app, text),
402
+ bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
403
+ extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
404
+ cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
405
+ switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
406
+ applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
407
+ cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
408
+ cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
409
+ commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
410
+ startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
411
+ login: (app: App, provider: string, key: string) => login(app, provider, key),
412
+ logout: (app: App, provider: string) => logout(app, provider),
413
+ resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
414
+ openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
415
+ forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
416
+ trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
417
+ treeNodesFor: (app: App) => treeNodesFor(app),
418
+ copyLastReply: (app: App) => copyLastReply(app),
419
+ effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
420
+ fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
421
+ dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
422
+ };
423
+
432
424
  type Outcome = "continue" | "quit" | { login: string };
433
425
 
434
- async function execute(app: App, command: Command, tui: Tui | null): Promise<Outcome> {
426
+ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
435
427
  switch (command.kind) {
436
428
  case "extension":
437
429
  if (tui) tui.runExtensionCommand(command.name, command.args);
@@ -447,7 +439,40 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
447
439
  printUsage(app);
448
440
  return "continue";
449
441
  case "state":
450
- printState(app);
442
+ await printState(app);
443
+ return "continue";
444
+ case "control":
445
+ if (tui) await tui.openControl();
446
+ else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
447
+ return "continue";
448
+ case "context":
449
+ printContext(app);
450
+ return "continue";
451
+ case "acceptance":
452
+ await printAcceptance(app);
453
+ return "continue";
454
+ case "tasks": {
455
+ const items = await app.session.workItems();
456
+ app.io.say(`\nwork tasks: ${items.length}`);
457
+ for (const item of items) {
458
+ const ready = workReadiness(items, item.id);
459
+ app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
460
+ if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
461
+ }
462
+ return "continue";
463
+ }
464
+ case "branches": {
465
+ if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
466
+ for (const item of await app.session.branchStatuses()) app.io.say(` branch ${item.node}: ${item.status} · ${item.purpose}${item.receipt_ref ? ` · receipt ${item.receipt_ref}` : ""}`);
467
+ return "continue";
468
+ }
469
+ case "processes": {
470
+ for (const item of await app.session.processStatuses()) app.io.say(` process ${item.session}: ${item.status} · last observation=${item.observed}${item.exit !== undefined ? ` · exit=${item.exit}` : ""}${item.output ? ` · output ${item.output.sha256}` : ""}`);
471
+ return "continue";
472
+ }
473
+ case "accept":
474
+ await app.session.acceptCriterion(command.criterion);
475
+ await printAcceptance(app);
451
476
  return "continue";
452
477
  case "help":
453
478
  printHelp(app);
@@ -464,7 +489,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
464
489
  return "continue";
465
490
  }
466
491
  case "config":
467
- if (tui) await tui.openResourceSelect();
492
+ if (tui) await tui.openResources();
468
493
  else {
469
494
  const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
470
495
  for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
@@ -479,7 +504,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
479
504
  return "continue";
480
505
  }
481
506
  if (tui) {
482
- tui.openModelSelect();
507
+ tui.openModel();
483
508
  return "continue";
484
509
  }
485
510
  printModels(app);
@@ -492,10 +517,10 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
492
517
  return applyThinkingCommand(app, command.level, tui);
493
518
  case "login": {
494
519
  if (command.provider === undefined) {
495
- if (tui) { tui.openLoginSelect(); return "continue"; }
520
+ if (tui) { tui.openLogin(); return "continue"; }
496
521
  const C = app.io.palette;
497
- const providers = [...new Set(app.catalog.map((m) => m.provider))];
498
- app.io.say(C.bold("\nlogin"));
522
+ const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
523
+ app.io.say(C.bold("\nproviders"));
499
524
  app.io.say(C.bold(" API keys"));
500
525
  for (const p of providers) {
501
526
  app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
@@ -504,23 +529,31 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
504
529
  for (const s of SUBSCRIPTION_OPTIONS) {
505
530
  app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
506
531
  }
507
- app.io.say(C.dim("\n /login <provider> then paste the key. Empty line cancels."));
508
- app.io.say(C.dim(" /login claude-pro|codex|github-copilot starts a subscription login.\n"));
532
+ app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
533
+ app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
509
534
  return "continue";
510
535
  }
511
- if (isSubscriptionLogin(command.provider)) {
512
- return startSubscriptionLogin(app, command.provider, tui);
536
+ const subscription = subscriptionById(command.provider);
537
+ if (subscription) {
538
+ return startSubscriptionLogin(app, subscription.id, tui);
539
+ }
540
+ if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
541
+ if (tui) {
542
+ const provider = command.provider;
543
+ const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
544
+ if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
545
+ return "continue";
513
546
  }
514
547
  const C = app.io.palette;
515
- // The editor paints a mask; this sentence has to match that, because
516
- // "it will be visible here" is how a secret ends up in a screenshot.
548
+ // Line mode echoes nothing for this line; the sentence must say so,
549
+ // because "it will be visible here" is how a secret ends up in a screenshot.
517
550
  app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
518
551
  app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
519
552
  app.io.say(C.dim(" empty line cancels."));
520
553
  return { login: command.provider };
521
554
  }
522
555
  case "logout": {
523
- if (tui && command.provider === undefined) { tui.openLogoutSelect(); return "continue"; }
556
+ if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
524
557
  await logout(app, command.provider);
525
558
  return "continue";
526
559
  }
@@ -539,7 +572,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
539
572
  }
540
573
  case "tree":
541
574
  if (tui) {
542
- await tui.openTreeSelect();
575
+ await tui.openTree();
543
576
  return "continue";
544
577
  }
545
578
  await printTree(app);
@@ -553,7 +586,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
553
586
  return "continue";
554
587
  }
555
588
  if (tui) {
556
- void tui.openResumeSelect();
589
+ void tui.openResume();
557
590
  return "continue";
558
591
  }
559
592
  await printSessionList(app);
@@ -567,7 +600,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
567
600
  case "import": {
568
601
  if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
569
602
  const importing = async () => {
570
- if (tui && !await tui.extensionUi.context.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
603
+ if (tui && !await tui.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
571
604
  try {
572
605
  const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
573
606
  if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
@@ -578,25 +611,25 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
578
611
  }
579
612
  case "debug": {
580
613
  const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
581
- const width = tui?.screen.terminal.columns ?? 80, height = tui?.screen.terminal.rows ?? 0;
582
- const lines = tui?.screen.render(width) ?? [];
614
+ const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
615
+ const { width, height, lines } = frame;
583
616
  const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
584
617
  const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
585
- ...lines.map((line, i) => `[${i}] (w=${visibleWidth(line)}) ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
618
+ ...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
586
619
  try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
587
620
  catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
588
621
  return "continue";
589
622
  }
590
623
  case "changelog": {
591
624
  const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
592
- if (tui) tui.showDocument(text); else app.io.say(text);
625
+ if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
593
626
  return "continue";
594
627
  }
595
628
  case "copy":
596
629
  await copyLastReply(app, tui ?? undefined);
597
630
  return "continue";
598
631
  case "fork":
599
- if (tui && command.arg === undefined) { await tui.openForkSelect(); return "continue"; }
632
+ if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
600
633
  await forkSession(app, command.arg, tui ?? undefined);
601
634
  return "continue";
602
635
  case "clone":
@@ -605,7 +638,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
605
638
  return "continue";
606
639
  case "trust":
607
640
  if (command.target) await trustProject(app, command.target, tui ?? undefined);
608
- else if (tui) await tui.openTrustSelect();
641
+ else if (tui) await tui.openTrust();
609
642
  else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
610
643
  return "continue";
611
644
  case "models-refresh":
@@ -615,7 +648,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
615
648
  app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
616
649
  return "continue";
617
650
  case "prompt":
618
- await run(app, command.text, tui?.signal, tui ?? undefined);
651
+ await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
619
652
  app.io.say("");
620
653
  return "continue";
621
654
  default:
@@ -623,33 +656,8 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
623
656
  }
624
657
  }
625
658
 
626
- async function handleLine(app: App, text: string, tui: Tui): Promise<Outcome> {
627
- if (tui.awaitingKeyFor !== null) {
628
- const provider = tui.awaitingKeyFor;
629
- if (provider === OAUTH_DEVICE) {
630
- if (!text) await cancelPendingOAuth(app);
631
- else app.io.say(" Waiting for account login. Escape cancels.");
632
- return "continue";
633
- }
634
- if (text.length === 0) {
635
- if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
636
- tui.endCredentialInput();
637
- app.io.say(app.io.palette.dim(" cancelled\n"));
638
- return "continue";
639
- }
640
- if (provider === OAUTH_PASTE) {
641
- tui.editor.applyExternal("");
642
- tui.runExtensionAction(() => completeOAuthPaste(app, text));
643
- return "continue";
644
- }
645
- try { await login(app, provider, text); } finally { tui.endCredentialInput(); }
646
- return "continue";
647
- }
648
- const outcome = await execute(app, parseLine(app, text), tui);
649
- if (typeof outcome === "object") {
650
- tui.beginCredentialInput(outcome.login);
651
- }
652
- return outcome;
659
+ async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
660
+ return execute(app, parseLine(app, text), tui);
653
661
  }
654
662
 
655
663
  function printUsage(app: App): void {
@@ -702,12 +710,21 @@ function printUsage(app: App): void {
702
710
  app.io.say("");
703
711
  }
704
712
 
705
- function printState(app: App): void {
713
+ async function printState(app: App): Promise<void> {
706
714
  const C = app.io.palette;
707
715
  const s = app.session.state;
708
716
  app.io.say(C.bold("\nstate"));
709
717
  app.io.say(` node ${s.node} (depth ${s.depth})`);
710
718
  app.io.say(` events in node ${s.events.length}`);
719
+ const request = await app.session.requestState();
720
+ if (request) {
721
+ app.io.say(` request ${request.request.event} · ${request.status}`);
722
+ app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
723
+ app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
724
+ app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
725
+ app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
726
+ if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
727
+ }
711
728
  if (s.handoff) {
712
729
  const h = s.handoff;
713
730
  app.io.say(` completed ${h.state.completed.length}`);
@@ -724,6 +741,43 @@ function printState(app: App): void {
724
741
  app.io.say("");
725
742
  }
726
743
 
744
+ function printContext(app: App): void {
745
+ const session = app.session;
746
+ const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
747
+ policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
748
+ contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
749
+ ...(session.config.counter ? { counter: session.config.counter } : {}),
750
+ ...(session.config.boundary ? { boundary: session.config.boundary } : {}),
751
+ });
752
+ app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
753
+ for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
754
+ app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
755
+ app.io.say(` limits ${JSON.stringify(detail.limits)}`);
756
+ const sources = detail.sources.flat();
757
+ app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
758
+ if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
759
+ for (const section of detail.sections.filter(s => s.omitted > 0)) {
760
+ app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
761
+ }
762
+ app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
763
+ if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
764
+ app.io.say("");
765
+ }
766
+
767
+ async function printAcceptance(app: App): Promise<void> {
768
+ const state = await app.session.acceptanceState();
769
+ app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
770
+ for (const target of state?.targets ?? []) {
771
+ app.io.say(` target ${target.id}: ${target.status} · entry ${target.entry ?? "unobserved"} · pid ${target.pid ?? "unknown"} · session ${target.session ?? "none"} · port ${target.port ?? "not declared"}`);
772
+ app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
773
+ }
774
+ for (const item of state?.criteria ?? []) {
775
+ app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
776
+ if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
777
+ else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
778
+ }
779
+ }
780
+
727
781
  function printHotkeys(app: App): void {
728
782
  const C = app.io.palette;
729
783
  const map = app.ui?.keymap ?? defaultKeymap();
@@ -740,7 +794,7 @@ async function applySettingsCommand(
740
794
  app: App,
741
795
  field: string | undefined,
742
796
  value: string | undefined,
743
- tui: Tui | null,
797
+ tui: Ui | null,
744
798
  ): Promise<Outcome> {
745
799
  if (field !== undefined && value !== undefined) {
746
800
  await commitSetting(app, field, value, tui);
@@ -759,7 +813,7 @@ async function applySettingsCommand(
759
813
  return "continue";
760
814
  }
761
815
 
762
- async function commitSetting(app: App, field: string, value: string, tui: Tui | null): Promise<void> {
816
+ async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
763
817
  const C = app.io.palette;
764
818
  const key = field.toLowerCase();
765
819
  if (app.ui === undefined) {
@@ -783,10 +837,11 @@ async function commitSetting(app: App, field: string, value: string, tui: Tui |
783
837
  app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
784
838
  app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
785
839
  if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
786
- app.io.say(C.dim(` ${field} ${value}`));
840
+ if (tui) tui.toast(`${field} · ${value}`, "success");
841
+ else app.io.say(C.dim(` ${field} ${value}`));
787
842
  tui?.applyConfiguration();
788
843
  if (key === "theme") tui?.followTheme();
789
- tui?.paint();
844
+ tui?.redraw();
790
845
  }
791
846
 
792
847
  const EFFORTS = THINKING_LEVELS;
@@ -801,7 +856,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
801
856
  * must not cut: a cut drops the prefix cache, and thinking blocks stay
802
857
  * valid because the model did not change.
803
858
  */
804
- async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<boolean> {
859
+ async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
805
860
  const C = app.io.palette;
806
861
  if (!effortSupported(app.providerConfig.spec, level)) {
807
862
  app.io.say(
@@ -820,11 +875,11 @@ async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<
820
875
  app.ui.settings = { ...app.ui.settings, thinking: level };
821
876
  }
822
877
  app.io.say(C.dim(` thinking ${level}`));
823
- tui?.paint();
878
+ tui?.redraw();
824
879
  return true;
825
880
  }
826
881
 
827
- async function applyThinkingCommand(app: App, level: string | undefined, tui: Tui | null): Promise<Outcome> {
882
+ async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
828
883
  if (level !== undefined) {
829
884
  const effort = asEffort(level);
830
885
  if (effort === undefined) {
@@ -835,7 +890,7 @@ async function applyThinkingCommand(app: App, level: string | undefined, tui: Tu
835
890
  return "continue";
836
891
  }
837
892
  if (tui) {
838
- tui.openThinkingSelect();
893
+ tui.openThinking();
839
894
  return "continue";
840
895
  }
841
896
  printThinking(app);
@@ -856,7 +911,7 @@ function printThinking(app: App): void {
856
911
  app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
857
912
  }
858
913
 
859
- async function cycleThinking(app: App, tui: Tui | null): Promise<void> {
914
+ async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
860
915
  const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
861
916
  const next = cycle(supported, app.providerConfig.effort, 1);
862
917
  if (next === undefined) return;
@@ -977,10 +1032,11 @@ async function printBack(app: App): Promise<void> {
977
1032
  * Jumping still needs the TUI; this is so a pipe can see the structure.
978
1033
  */
979
1034
  async function printTree(app: App): Promise<void> {
980
- const nodes = await treeNodesFor(app);
981
- const style = app.ui?.style ?? IDENTITY;
982
- for (const line of renderTree(nodes, { index: 0, filter: "all" }, 80, style)) {
983
- app.io.say(line);
1035
+ const rows = flattenTree(await treeNodesFor(app));
1036
+ if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
1037
+ for (const row of rows) {
1038
+ const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
1039
+ app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
984
1040
  }
985
1041
  }
986
1042
 
@@ -1045,7 +1101,7 @@ async function printRewind(
1045
1101
  ): Promise<void> {
1046
1102
  const C = app.io.palette;
1047
1103
  const label = firstLine(point.prompt, 60);
1048
- const plan = await app.session.previewRewind(point);
1104
+ const plan = await app.session.previewRewind(point, mode);
1049
1105
  if (!plan.ok) {
1050
1106
  app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
1051
1107
  return;
@@ -1066,7 +1122,7 @@ async function printRewind(
1066
1122
  for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
1067
1123
  if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
1068
1124
  } else {
1069
- for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` keep ${p}`));
1125
+ for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
1070
1126
  if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
1071
1127
  for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
1072
1128
  if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
@@ -1204,7 +1260,7 @@ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefin
1204
1260
  }
1205
1261
 
1206
1262
  function makeProvider(app: App, config: ProviderConfig, key: string) {
1207
- return createProvider(config, key, retryHandler(app.io), { ...providerOAuthOpts(app, config.spec), effects: app.effects });
1263
+ return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
1208
1264
  }
1209
1265
 
1210
1266
  interface PendingOAuth {
@@ -1216,13 +1272,15 @@ interface PendingOAuth {
1216
1272
  exchange: "anthropic" | "openai";
1217
1273
  server: CallbackServer;
1218
1274
  done: boolean;
1219
- tui: Tui | null;
1275
+ tui: Ui | null;
1220
1276
  abort: AbortController;
1277
+ /** Closes the UI's waiting dialog, if any. */
1278
+ close?: () => void;
1221
1279
  }
1222
1280
 
1223
1281
  // Authentication is scoped to its application, independent of the model/session log.
1224
1282
  const pendingLogins = new WeakMap<App, PendingOAuth>();
1225
- const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Tui | null }>();
1283
+ const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
1226
1284
  const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
1227
1285
  openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
1228
1286
 
@@ -1230,8 +1288,7 @@ async function cancelPendingOAuth(app: App): Promise<void> {
1230
1288
  const pending = pendingLogins.get(app), device = pendingDevices.get(app);
1231
1289
  pendingLogins.delete(app); pendingDevices.delete(app);
1232
1290
  device?.abort.abort(); pending?.abort.abort();
1233
- const tui = pending?.tui ?? device?.tui;
1234
- tui?.endCredentialInput();
1291
+ pending?.close?.(); device?.close?.();
1235
1292
  if (pending) { pending.done = true; await pending.server.close(); }
1236
1293
  }
1237
1294
 
@@ -1282,7 +1339,7 @@ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): P
1282
1339
  } finally {
1283
1340
  if (pendingLogins.get(app) === pending) {
1284
1341
  pendingLogins.delete(app);
1285
- pending.tui?.endCredentialInput();
1342
+ pending.close?.();
1286
1343
  }
1287
1344
  await pending.server.close();
1288
1345
  }
@@ -1313,22 +1370,27 @@ function tryOpenBrowser(url: string): void {
1313
1370
  }
1314
1371
  }
1315
1372
 
1316
- async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Promise<Outcome> {
1373
+ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
1317
1374
  const C = app.io.palette;
1318
1375
  const sub = subscriptionById(id);
1319
1376
  if (sub === undefined) return "continue";
1320
1377
  await cancelPendingOAuth(app);
1321
1378
  const effects = tui?.oauthEffects ?? loginEffects;
1322
1379
  app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
1323
- if (sub.id === "github-copilot") {
1324
- const pending = { abort: new AbortController(), tui };
1380
+ if (sub.id === "github-copilot" || sub.id === "grok") {
1381
+ const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
1325
1382
  pendingDevices.set(app, pending);
1326
1383
  const work = async () => { try {
1327
- const credential = await effects.device({
1328
- notify: (message) => { if (pendingDevices.get(app) === pending) app.io.say(` ${message}`); },
1384
+ const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
1385
+ notify: (message) => {
1386
+ if (pendingDevices.get(app) !== pending) return;
1387
+ if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
1388
+ else app.io.say(` ${message}`);
1389
+ },
1329
1390
  prompt: async () => "",
1330
1391
  fetch: effects.fetch,
1331
1392
  signal: pending.abort.signal,
1393
+ openBrowser: effects.openBrowser,
1332
1394
  });
1333
1395
  if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
1334
1396
  await storeOAuth(app, sub.provider, credential);
@@ -1339,10 +1401,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1339
1401
  } finally {
1340
1402
  if (pendingDevices.get(app) === pending) {
1341
1403
  pendingDevices.delete(app);
1342
- tui?.endCredentialInput();
1404
+ pending.close?.();
1343
1405
  }
1344
1406
  } };
1345
- if (tui) { tui.beginCredentialInput(OAUTH_DEVICE); tui.runExtensionAction(work); return "continue"; }
1407
+ if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
1346
1408
  await work();
1347
1409
  return "continue";
1348
1410
  }
@@ -1372,8 +1434,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1372
1434
  close: async () => {},
1373
1435
  };
1374
1436
  }
1375
- app.io.say(` ${url}`);
1376
- app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
1437
+ if (!tui) {
1438
+ app.io.say(` ${url}`);
1439
+ app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
1440
+ }
1377
1441
  effects.openBrowser(url);
1378
1442
  const pending: PendingOAuth = {
1379
1443
  id: sub.id,
@@ -1392,6 +1456,12 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1392
1456
  if (!got.ok || pending.done) return;
1393
1457
  await finishOAuthCode(app, pending, got.code);
1394
1458
  });
1459
+ if (tui) {
1460
+ // The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
1461
+ pending.close = tui.waiting(sub.label, ["Finish signing in in your browser.", "", url, "", "Or paste the redirect URL here if the browser cannot reach this machine."],
1462
+ () => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
1463
+ return "continue";
1464
+ }
1395
1465
  return { login: OAUTH_PASTE };
1396
1466
  }
1397
1467
 
@@ -1452,7 +1522,7 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
1452
1522
  await switchToModel(app, selected.model, selected.thinking);
1453
1523
  }
1454
1524
 
1455
- async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
1525
+ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
1456
1526
  const C = app.io.palette;
1457
1527
  if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
1458
1528
  if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
@@ -1462,7 +1532,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
1462
1532
  const got = app.credentialOf(spec);
1463
1533
  if (!got.ok) {
1464
1534
  app.io.say(C.red(`\n ${got.reason}\n`));
1465
- if (!got.reason.includes("/login")) {
1535
+ if (!got.reason.includes("/provider")) {
1466
1536
  app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
1467
1537
  }
1468
1538
  return;
@@ -1474,8 +1544,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
1474
1544
  thinkingDisplay: app.providerConfig.thinkingDisplay,
1475
1545
  };
1476
1546
  const next = makeProvider(app, nextConfig, got.key);
1477
- app.io.say(C.yellow(" switching cuts this node so the next model starts from a handoff,"));
1478
- app.io.say(C.yellow(" not from this model's thinking blocks."));
1547
+ app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
1479
1548
  try {
1480
1549
  await app.session.switchModel(next);
1481
1550
  } catch (err) {
@@ -1485,13 +1554,22 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
1485
1554
  app.providerConfig = nextConfig;
1486
1555
  app.credential = got;
1487
1556
  app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1557
+ await rememberRecentModel(app, spec);
1558
+ }
1559
+
1560
+ /** Recency feeds the picker's first section; a write failure only costs ordering. */
1561
+ async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
1562
+ if (!app.ui) return;
1563
+ const recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
1564
+ app.ui.settings = { ...app.ui.settings, recentModels };
1565
+ try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
1488
1566
  }
1489
1567
 
1490
1568
  function sessionDirOf(app: App): string | undefined {
1491
1569
  return sessionDirFor(app.args, app.workspace);
1492
1570
  }
1493
1571
 
1494
- async function openNewSession(app: App, tui?: Tui): Promise<void> {
1572
+ async function openNewSession(app: App, tui?: Ui): Promise<void> {
1495
1573
  const C = app.io.palette;
1496
1574
  const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1497
1575
  const switched = await adoptLogDir(app, created.path);
@@ -1503,7 +1581,7 @@ async function openNewSession(app: App, tui?: Tui): Promise<void> {
1503
1581
  app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
1504
1582
  }
1505
1583
 
1506
- async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<void> {
1584
+ async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
1507
1585
  const C = app.io.palette;
1508
1586
  const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
1509
1587
  if (!found.ok) {
@@ -1525,7 +1603,10 @@ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<voi
1525
1603
 
1526
1604
  async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
1527
1605
  try {
1606
+ app.session.cancel(); await app.session.whenIdle();
1607
+ if (app.extensions && !await app.extensions.releaseIntegrations()) throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before switching sessions.");
1528
1608
  const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
1609
+ await app.session.dispose();
1529
1610
  await app.extensions?.dispose();
1530
1611
  app.session = opened.session;
1531
1612
  app.providerConfig = opened.providerConfig;
@@ -1574,7 +1655,7 @@ async function exportSession(app: App, file?: string): Promise<void> {
1574
1655
  app.io.say(C.green(` exported ${abs}`));
1575
1656
  }
1576
1657
 
1577
- async function copyLastReply(app: App, tui?: Tui): Promise<void> {
1658
+ async function copyLastReply(app: App, tui?: Ui): Promise<void> {
1578
1659
  const C = app.io.palette;
1579
1660
  const text = lastAssistantText(await transcriptEvents(app));
1580
1661
  if (text === null) {
@@ -1590,7 +1671,7 @@ async function copyLastReply(app: App, tui?: Tui): Promise<void> {
1590
1671
  app.io.say(text);
1591
1672
  }
1592
1673
 
1593
- async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
1674
+ async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
1594
1675
  const C = app.io.palette;
1595
1676
  const points = await app.session.userMessages();
1596
1677
  if (arg === undefined) {
@@ -1616,7 +1697,7 @@ async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
1616
1697
  if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
1617
1698
  await tui?.syncTranscript();
1618
1699
  tui?.restoreDraft(point.text, point.images ?? []);
1619
- tui?.paint();
1700
+ tui?.redraw();
1620
1701
  }
1621
1702
  }
1622
1703
 
@@ -1639,7 +1720,7 @@ async function spawnForkedSession(app: App, untilEvent: string | undefined, labe
1639
1720
  return true;
1640
1721
  }
1641
1722
 
1642
- async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<void> {
1723
+ async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
1643
1724
  const C = app.io.palette;
1644
1725
  const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
1645
1726
  const trusted = await applyTrustChoice(app.workspace, target);
@@ -1655,7 +1736,7 @@ async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<v
1655
1736
  await tui?.refreshResources();
1656
1737
  }
1657
1738
 
1658
- async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
1739
+ async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
1659
1740
  const C = app.io.palette;
1660
1741
  if (name.length === 0) {
1661
1742
  app.io.say(C.dim(" /name <name>"));
@@ -1675,7 +1756,7 @@ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
1675
1756
  }
1676
1757
 
1677
1758
  /** Runs a prompt. Returns false if the turn failed, having already said why. */
1678
- async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, images: readonly ImageRef[] = []): Promise<boolean> {
1759
+ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
1679
1760
  if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
1680
1761
  for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
1681
1762
  if (result["action"] === "handled") return true;
@@ -1686,6 +1767,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1686
1767
  if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
1687
1768
  const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
1688
1769
  tui ? chunk => tui.pushBash(chunk) : undefined);
1770
+ tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
1689
1771
  if (tui) { await tui.syncTranscript(); tui.commitStream(); }
1690
1772
  else app.io.say(`$ ${result.command}\n${result.content}`);
1691
1773
  return true;
@@ -1696,7 +1778,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1696
1778
  const C = app.io.palette;
1697
1779
  app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
1698
1780
  app.io.say(C.dim(` ${app.credential.reason}`));
1699
- app.io.say(C.dim(" export an API key, or /login <provider>.\n"));
1781
+ app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
1700
1782
  tui?.restoreDraft(prompt, images);
1701
1783
  return false;
1702
1784
  }
@@ -1754,7 +1836,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1754
1836
  return false;
1755
1837
  }
1756
1838
  try {
1757
- await runTurn(app, input, signal, tui);
1839
+ await runTurn(app, input, signal, tui, skill);
1758
1840
  return true;
1759
1841
  } catch (error) {
1760
1842
  tui?.discardStream();
@@ -1768,28 +1850,14 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1768
1850
  }
1769
1851
  }
1770
1852
 
1771
- export interface TurnSurface {
1772
- /** Interactive UI projects committed log events; stream-only consumers omit this. */
1773
- syncTranscript?(): Promise<void>;
1774
- pushDelta(kind: "text" | "thinking", text: string): void;
1775
- commitStream(): void;
1776
- discardStream(): void;
1777
- /** Tool/thinking events that stay in the live region until commit. */
1778
- pushEvent?(e: LoopEvent): void;
1779
- pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
1780
- /**
1781
- * Called by send() at a pairable after_tools moment. Returns already
1782
- * expanded text, or null. Expansion happens here, not at enqueue —
1783
- * the model is editing files while the line waits.
1784
- */
1785
- pullQueued?(phase: Phase): Promise<UserInput | null>;
1786
- }
1853
+ export type { TurnSurface };
1787
1854
 
1788
1855
  export async function runTurn(
1789
1856
  app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
1790
1857
  prompt: UserInput,
1791
1858
  signal?: AbortSignal,
1792
1859
  tui?: TurnSurface,
1860
+ skill?: Skill,
1793
1861
  ): Promise<void> {
1794
1862
  // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1795
1863
  // deltas and discard are ignored, and the final block prints once.
@@ -1805,7 +1873,8 @@ export async function runTurn(
1805
1873
  }
1806
1874
  await registry?.emit("agent_start");
1807
1875
  await registry?.emit("turn_start", { turnIndex: 0 });
1808
- try { for await (const e of app.session.send(prompt, signal, pull)) {
1876
+ try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
1877
+ tui?.observeEvent?.(e);
1809
1878
  if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
1810
1879
  if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
1811
1880
  if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
@@ -1825,7 +1894,7 @@ export async function runTurn(
1825
1894
  await tui.syncTranscript();
1826
1895
  if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
1827
1896
  if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
1828
- for (const line of formatEvent(e, app.io.palette, usableColumns(process.stdout.columns), {}, app.ui?.style)) app.io.say(line);
1897
+ for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
1829
1898
  }
1830
1899
  }
1831
1900
  continue;
@@ -1853,14 +1922,6 @@ export async function runTurn(
1853
1922
  }
1854
1923
  if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
1855
1924
  if (skipFinal && e.kind !== "usage") skipFinal = false;
1856
- if (
1857
- tui !== undefined &&
1858
- tui.pushEvent !== undefined &&
1859
- (e.kind === "tool_call" || e.kind === "tool_result" || e.kind === "thinking")
1860
- ) {
1861
- tui.pushEvent(e);
1862
- continue;
1863
- }
1864
1925
  if (e.kind === "assistant_text" && app.io.answer !== undefined) {
1865
1926
  app.io.answer(e.text ?? "");
1866
1927
  continue;
@@ -1868,7 +1929,7 @@ export async function runTurn(
1868
1929
  for (const line of formatEvent(
1869
1930
  e,
1870
1931
  app.io.palette,
1871
- usableColumns(process.stdout.columns),
1932
+ Math.max(20, (process.stdout.columns ?? 80) - 1),
1872
1933
  {},
1873
1934
  app.ui?.style,
1874
1935
  )) {
@@ -1880,1273 +1941,3 @@ export async function runTurn(
1880
1941
  // ---------------------------------------------------------------------------
1881
1942
  // TUI
1882
1943
  // ---------------------------------------------------------------------------
1883
-
1884
- export class Tui {
1885
- readonly term: Terminal;
1886
- editor: Editor;
1887
- readonly extensionUi: ExtensionUiController;
1888
- /** Not yet delivered. Retracting a line must leave no trace in `.xeno/`. */
1889
- queue: QueuedMessage[] = [];
1890
- pendingImages: ImageRef[] = [];
1891
- clipboardReader = readClipboard;
1892
- oauthEffects = { ...loginEffects };
1893
- clipboardWriter: (text: string) => boolean | Promise<boolean> = writeClipboard;
1894
- private pasteBuffer = "";
1895
- private readonly displayImages = new Map<string, string>();
1896
- private readonly unavailableImages = new Set<string>();
1897
- private readonly markdownFeatures: MarkdownFeatures = {
1898
- highlight: (code, lang, style) => highlightCode(code, lang, (token, text) => style.token?.(token, text) ?? text),
1899
- transform: (text, width, streaming, style, messageType = "assistant") => transformMermaid(this.extensionUi.transformMarkdown(text, width, streaming, messageType), width, this.app.ui?.settings.markdown?.mermaid ?? "streaming", streaming,
1900
- (token, content) => style.token?.(token, content) ?? content),
1901
- };
1902
- select: SelectList | null = null;
1903
- settings: Settings | null = null;
1904
- tree: TreeList | null = null;
1905
- screen: InteractiveScreen;
1906
- private readonly document: PaddedDocument;
1907
- private readonly viewport: ChatViewport;
1908
- private stopThemeWatch: (() => void) | undefined;
1909
- private stopColorWatch: (() => void) | undefined;
1910
- private themeGeneration = 0;
1911
- private previewTheme: Theme | undefined;
1912
- private readonly chat: Container;
1913
- private noticeDir: string;
1914
- private readonly transcript = new Transcript();
1915
- private transcriptItems: TranscriptItem[] = [];
1916
- private historyNode = -1;
1917
- private historyDir = "";
1918
- private ancestors: LogEvent[] = [];
1919
- private historyLength = -1;
1920
- private liveMarkdown: MarkdownBlock | null = null;
1921
- private liveStyle: Style | null = null;
1922
- private readonly live: Container;
1923
- private readonly editorBox: Container;
1924
- private readonly footerBox: Container;
1925
- liveRows = 0;
1926
- abort: AbortController | null = null;
1927
- inflight = false;
1928
- private sessionTransition = false;
1929
- executionKind: "idle" | "model" | "bash" | "shutdown" = "idle";
1930
- shutdownRequested = false;
1931
- private bashOutput: string | null = null;
1932
- private bashDecoder = new TextDecoder();
1933
- private readonly toolArguments = new Map<string, { name: string; arguments: string }>();
1934
- private toolOutput = "";
1935
- private retryUntil = 0;
1936
- private retryAttempt = 0;
1937
- private retryTimer: ReturnType<typeof setInterval> | undefined;
1938
- awaitingKeyFor: string | null = null;
1939
- private credentialDraft: string | undefined;
1940
- streamThinking = "";
1941
- streamText = "";
1942
- flushed = 0;
1943
- toolsExpanded = false;
1944
- thinkingExpanded = false;
1945
- sessionName: string | null;
1946
- private onPick: ((item: SelectItem) => Promise<void>) | null = null;
1947
- private onSelectCancel: (() => void) | undefined;
1948
- private onTreePick: ((node: TreeNode) => Promise<void>) | null = null;
1949
- /** Which selector Ctrl+S should persist. Other lists ignore Ctrl+S. */
1950
- private selectKind: "model" | "scoped-models" | "thinking" | "other" = "other";
1951
- private approvalResolve: ((choice: ApprovalChoice) => void) | null = null;
1952
- private inputs: string[] = [];
1953
- private wake: (() => void) | null = null;
1954
- private attached = false;
1955
- private progressActive = false;
1956
- private resumeSuspended: (() => void) | undefined;
1957
- private lastInterrupt = 0;
1958
- private lastEscape = 0;
1959
- private pasting = false;
1960
- private turn: Promise<void> | null = null;
1961
- private readonly extensionWork = new Set<Promise<void>>();
1962
- readonly app: App;
1963
- /** Read once: a git spawn on every keystroke would stall the live region. */
1964
- private branch: string | null;
1965
-
1966
- constructor(app: App, term?: Terminal) {
1967
- this.app = app;
1968
- this.historyDir = app.logDir;
1969
- this.noticeDir = app.logDir;
1970
- this.screen = createInteractiveTui(term, app.ui?.settings.showHardwareCursor ?? true, { ...app.ui?.settings, copySelection: async text => this.clipboardWriter(text) });
1971
- this.term = this.screen.terminal;
1972
- this.editor = new Editor({ tui: this.screen });
1973
- this.editor.bindings = editorBindings((app.ui?.keymap ?? defaultKeymap()).byAction);
1974
- this.editor.onInput = (data) => { this.inputs.push(data); this.wake?.(); };
1975
- this.chat = new Container();
1976
- this.live = new Container();
1977
- this.editorBox = new Container();
1978
- this.footerBox = new Container();
1979
- this.extensionUi = new ExtensionUiController({
1980
- screen: () => this.screen, editor: this.editor, theme: () => this.app.ui?.theme ?? builtinTheme("dark"), colored: () => this.style !== IDENTITY,
1981
- bindings: () => this.editor.bindings!, branch: () => this.branch,
1982
- redraw: () => this.paint(), refreshTranscript: () => this.configureTranscript(), input: data => this.editor.onInput?.(data),
1983
- notify: (text, type) => this.app.io.say(type === "error" ? this.app.io.palette.red(text) : text),
1984
- setTheme: theme => this.setTheme(theme), expanded: () => this.toolsExpanded, expand: value => { this.toolsExpanded = value; },
1985
- cwd: () => this.app.workspace, showImages: () => this.app.ui?.settings.terminal?.showImages !== false, outputPad: () => this.app.ui?.settings.outputPad ?? 1,
1986
- saveTheme: name => {
1987
- if (this.app.ui) this.app.ui.settings = { ...this.app.ui.settings, theme: name };
1988
- this.runExtensionAction(() => writeUserSettings({ theme: name }));
1989
- },
1990
- });
1991
- this.extensionUi.setCompletion(interactiveAutocomplete(app, async refresh => (await fileIndexFor(app, refresh)).paths));
1992
- this.document = new PaddedDocument();
1993
- this.document.addChild(this.extensionUi.header);
1994
- this.document.addChild(this.transcript);
1995
- this.document.addChild(this.chat);
1996
- this.transcriptItems = projectTranscript(app.session.state.events, new Set(app.extensions?.entryRenderers.keys()));
1997
- this.document.addChild(this.live);
1998
- this.viewport = createChatViewport({ document: this.document, pendingMessages: new Container(), status: new Container(), editor: this.editorBox, footer: this.footerBox,
1999
- widgetsAbove: this.extensionUi.above, widgetsBelow: this.extensionUi.below,
2000
- scrollbarTrackStyle: text => this.style.token?.("muted", text) ?? text, scrollbarThumbStyle: text => this.style.token?.("text", text) ?? text });
2001
- this.mountScreen();
2002
- this.applyConfiguration();
2003
- this.sessionName = app.args.sessionName || null;
2004
- // Before attach: a hung git must not freeze an already-captured tty.
2005
- this.branch = currentBranch(app.workspace);
2006
- void warmSyntaxLanguages().then(() => { this.configureTranscript(); this.liveMarkdown?.invalidate(); this.paint(); });
2007
- }
2008
-
2009
- private mountScreen(): void {
2010
- this.screen.addChild(this.document);
2011
- this.screen.addChild(this.extensionUi.above);
2012
- this.screen.addChild(this.editorBox);
2013
- this.screen.addChild(this.extensionUi.below);
2014
- this.screen.addChild(this.footerBox);
2015
- if (this.screen.mode === "fullscreen") this.screen.setLayoutRoot(this.viewport.root);
2016
- }
2017
-
2018
- applyConfiguration(): void {
2019
- const settings = this.app.ui?.settings ?? {};
2020
- // Preserve xeno's collapsed default; explicit settings and Ctrl+T share one source of truth.
2021
- this.thinkingExpanded = settings.hideThinkingBlock === false;
2022
- const mode = settings.tuiMode ?? "regular";
2023
- if (this.screen.mode !== mode) {
2024
- if (this.attached) this.screen.stop({ preserveScreen: true });
2025
- this.screen = replaceInteractiveTui(this.screen, { ...settings, copySelection: async text => this.clipboardWriter(text) });
2026
- this.editor.rebind(this.screen);
2027
- this.mountScreen();
2028
- this.extensionUi.rebind();
2029
- if (this.attached) { this.screen.start(); this.screen.requestRender(true); this.followTheme(); }
2030
- }
2031
- this.screen.setShowHardwareCursor(settings.showHardwareCursor ?? true);
2032
- this.screen.setClearOnShrink(settings.terminal?.clearOnShrink ?? false);
2033
- if (this.screen.mode === "fullscreen") {
2034
- this.screen.setCopyOnSelect(settings.fullscreenCopyOnSelect ?? true);
2035
- this.screen.setWheelScrollLines(settings.fullscreenWheelScrollLines ?? "auto");
2036
- }
2037
- this.viewport.transcript.setScrollbar(settings.fullscreenScrollbar ?? "auto");
2038
- this.editor.configure(settings.editorPaddingX ?? 0, settings.autocompleteMaxVisible ?? 5);
2039
- this.transcript.setPadding(0);
2040
- this.document.padding = settings.outputPad ?? 1;
2041
- this.editor.bindings = installBindings((this.app.ui?.keymap ?? defaultKeymap()).byAction);
2042
- this.app.session.config.imageOptions = settings.images ?? {};
2043
- this.configureTranscript();
2044
- }
2045
-
2046
- async refreshResources(): Promise<void> {
2047
- await bindExtensions(this.app, this);
2048
- this.historyLength = -1;
2049
- this.applyConfiguration(); this.followTheme();
2050
- fileIndexes.delete(this.app.workspace); indexNotes.delete(this.app.workspace);
2051
- this.extensionUi.setCompletion(interactiveAutocomplete(this.app, async refresh => (await fileIndexFor(this.app, refresh)).paths));
2052
- this.branch = currentBranch(this.app.workspace);
2053
- this.extensionUi.branchChanged();
2054
- await this.syncTranscript(); this.paint();
2055
- }
2056
-
2057
- private configureTranscript(): void {
2058
- const settings = this.app.ui?.settings;
2059
- this.transcript.configure(this.markdownFeatures, this.displayImages, settings?.terminal?.showImages ?? true, settings?.terminal?.imageWidthCells ?? 60);
2060
- this.transcript.setExtensions({
2061
- message: (type, text, data, expanded, fallback, metadata) => this.extensionUi.renderMessage(type, text, data, expanded, fallback, metadata),
2062
- tool: (name, input, result, expanded, call, output, id) => this.extensionUi.renderTool(name, input, result, expanded, call, output, id),
2063
- hiddenThinkingLabel: this.extensionUi.hiddenThinkingLabel,
2064
- });
2065
- }
2066
-
2067
- private setTheme(theme: Theme): void {
2068
- if (!this.app.ui) return;
2069
- this.app.ui.theme = theme;
2070
- this.app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
2071
- this.extensionUi.rebind(false); this.configureTranscript();
2072
- this.paint();
2073
- }
2074
-
2075
- followTheme(): void {
2076
- this.stopThemeWatch?.(); this.stopColorWatch?.();
2077
- const generation = ++this.themeGeneration;
2078
- const name = this.app.ui?.settings.theme ?? "dark";
2079
- const spec = this.app.resources ? selectedThemePath(this.app.resources, name, this.app.args.noThemes) : name;
2080
- this.stopThemeWatch = watchTheme(spec, theme => this.setTheme(theme), message => this.writeLine(message));
2081
- const automatic = spec === "system" || spec === "auto";
2082
- this.screen.setTerminalColorSchemeNotifications(automatic);
2083
- const update = async (appearance?: "dark" | "light") => {
2084
- const apply = (colors: Awaited<ReturnType<InteractiveScreen["queryTerminalColors"]>>) => {
2085
- if (generation !== this.themeGeneration || this.previewTheme) return;
2086
- const generated = generateSystemThemeColors({ ...colors, ...(appearance ? { appearanceHint: appearance } : {}) });
2087
- if (spec === "auto") { this.setTheme(builtinTheme(generated.appearance ?? "dark")); return; }
2088
- const mapped = Object.fromEntries(THEME_TOKENS.map(token => [token, generated.colors[token === "status" ? "dim" : token === "prompt" ? "text" : token]]));
2089
- this.setTheme(parseTheme({ name: "system", colors: mapped }).theme);
2090
- };
2091
- apply(await this.screen.queryTerminalColors({ timeoutMs: 150, onLateReply: apply }));
2092
- };
2093
- if (automatic && this.attached) {
2094
- this.stopColorWatch = this.screen.onTerminalColorSchemeChange(scheme => { void update(scheme); });
2095
- void update();
2096
- }
2097
- }
2098
-
2099
- get style(): Style {
2100
- return this.app.ui?.style ?? ANSI;
2101
- }
2102
-
2103
- get signal(): AbortSignal | undefined {
2104
- return this.abort?.signal;
2105
- }
2106
-
2107
- writeLine(line: string): void {
2108
- if (this.noticeDir !== this.app.logDir) { this.chat.clear(); this.noticeDir = this.app.logDir; }
2109
- this.chat.addChild(new PiText(line, 0, 0));
2110
- this.paint();
2111
- }
2112
-
2113
- dropLive(): void {
2114
- this.live.clear();
2115
- this.liveRows = 0;
2116
- }
2117
-
2118
- private markdownStyle(): Style {
2119
- return this.app.io.palette.bold("\0") === "\0" ? IDENTITY : this.style;
2120
- }
2121
-
2122
- private clearStream(): void {
2123
- this.extensionUi.clearPartials();
2124
- this.streamThinking = "";
2125
- this.streamText = "";
2126
- this.flushed = 0;
2127
- this.bashOutput = null;
2128
- this.toolArguments.clear();
2129
- this.toolOutput = "";
2130
- this.stopRetryTimer();
2131
- }
2132
-
2133
- pushDelta(kind: "text" | "thinking", text: string): void {
2134
- this.stopRetryTimer();
2135
- if (kind === "thinking") this.streamThinking += text;
2136
- else this.streamText += text;
2137
- this.paintStream();
2138
- }
2139
-
2140
- pushProgress(progress: NonNullable<LoopEvent["progress"]>): void {
2141
- if (progress.kind === "retry") {
2142
- this.stopRetryTimer();
2143
- this.retryUntil = Date.now() + progress.delayMs;
2144
- this.retryAttempt = progress.attempt;
2145
- this.retryTimer = setInterval(() => this.paint(), 200);
2146
- } else {
2147
- this.stopRetryTimer();
2148
- if (progress.kind === "tool") this.toolArguments.set(progress.id, { name: progress.name, arguments: progress.arguments });
2149
- else this.toolOutput = `${this.toolOutput}${progress.text}`.slice(-64 * 1024);
2150
- }
2151
- this.paint();
2152
- }
2153
-
2154
- private stopRetryTimer(): void {
2155
- clearInterval(this.retryTimer);
2156
- this.retryTimer = undefined;
2157
- this.retryUntil = 0;
2158
- }
2159
-
2160
- discardStream(): void {
2161
- this.clearStream();
2162
- this.dropLive();
2163
- if (this.inflight) this.paintStream();
2164
- else this.paint();
2165
- }
2166
-
2167
- commitStream(): void {
2168
- // A stream is never durable history. syncTranscript replaces it from the log.
2169
- this.clearStream();
2170
- this.paint();
2171
- }
2172
-
2173
- async syncTranscript(): Promise<void> {
2174
- if (this.attached && this.app.extensions && !initializedExtensions.has(this.app.extensions)) await bindExtensions(this.app, this);
2175
- const session = this.app.session;
2176
- const dir = this.app.logDir;
2177
- const node = session.state.node;
2178
- if (dir !== this.historyDir || node !== this.historyNode) {
2179
- const history = await readNodeHistory(dir, node);
2180
- if (!history.ok) {
2181
- this.chat.clear();
2182
- this.chat.addChild(new PiText("Cannot load complete session history: " + history.error, 0, 0));
2183
- this.paint();
2184
- return;
2185
- }
2186
- if (this.app.session !== session || this.app.logDir !== dir) return;
2187
- if (this.historyDir !== dir) {
2188
- this.applyConfiguration();
2189
- this.followTheme();
2190
- if (this.noticeDir !== dir) this.chat.clear();
2191
- this.noticeDir = dir;
2192
- this.queue = [];
2193
- this.pendingImages = [];
2194
- this.displayImages.clear(); this.unavailableImages.clear();
2195
- this.clearStream();
2196
- this.editor.applyExternal("");
2197
- this.editor.setAutocompleteProvider(interactiveAutocomplete(this.app, async (refresh) => (await fileIndexFor(this.app, refresh)).paths));
2198
- this.branch = currentBranch(this.app.workspace);
2199
- this.sessionName = (await readCursor(dir))?.name ?? null;
2200
- }
2201
- this.ancestors = history.nodes.filter((entry) => entry.node !== node).flatMap((entry) => entry.events);
2202
- this.historyDir = dir;
2203
- this.historyNode = node;
2204
- this.historyLength = -1;
2205
- }
2206
- const physical = await readNode(dir, node);
2207
- if (this.app.session !== session || session.state.node !== node) return;
2208
- if (physical.corrupt.length > 0) throw new Error("Cannot render corrupt session history");
2209
- if (this.historyLength !== physical.events.length) {
2210
- this.transcriptItems = projectTranscript([...this.ancestors, ...physical.events], new Set(this.app.extensions?.entryRenderers.keys()));
2211
- this.historyLength = physical.events.length;
2212
- const images = this.transcriptItems.flatMap(item => item.kind === "user" ? item.images ?? [] : item.kind === "tool" ? item.result?.images ?? [] : []);
2213
- for (const image of images) {
2214
- if (this.displayImages.has(image.sha256) || this.unavailableImages.has(image.sha256)) continue;
2215
- try { this.displayImages.set(image.sha256, await loadDisplayImage(image, this.app.blobDir)); }
2216
- catch { this.unavailableImages.add(image.sha256); }
2217
- }
2218
- this.configureTranscript();
2219
- }
2220
- this.paint();
2221
- }
2222
-
2223
- paintStream(): void {
2224
- this.rebuildLive();
2225
- this.rebuildChrome();
2226
- this.renderScreen();
2227
- }
2228
-
2229
- paint(): void {
2230
- const colored = this.markdownStyle() !== IDENTITY;
2231
- this.editor.borderColor = thinkingBorderColor(this.app.providerConfig.effort, colored, this.style);
2232
- this.rebuildLive();
2233
- this.rebuildChrome();
2234
- this.renderScreen();
2235
- }
2236
-
2237
- private renderScreen(): void {
2238
- const progress = this.inflight && this.app.ui?.settings.terminal?.showTerminalProgress !== false;
2239
- if (this.attached && this.progressActive !== progress) {
2240
- this.screen.terminal.setProgress(progress);
2241
- this.progressActive = progress;
2242
- }
2243
- this.transcript.update(this.transcriptItems, this.markdownStyle(), this.toolsExpanded, this.thinkingExpanded);
2244
- if (this.attached) this.screen.requestRender();
2245
- else this.screen.renderNow();
2246
- }
2247
-
2248
- private rebuildLive(): void {
2249
- this.live.clear();
2250
- const style = this.markdownStyle();
2251
- if (this.bashOutput !== null) this.live.addChild(new PiText(this.bashOutput, 0, 0));
2252
- for (const tool of this.toolArguments.values()) this.live.addChild(new PiText(`Preparing ${tool.name}\n${tool.arguments}`, 0, 0));
2253
- if (this.extensionUi.partials.children.length) this.live.addChild(this.extensionUi.partials);
2254
- else if (this.toolOutput) this.live.addChild(new PiText(this.toolOutput, 0, 0));
2255
- if (this.retryUntil) this.live.addChild(new PiText(`Retry ${this.retryAttempt} in ${Math.max(0, (this.retryUntil - Date.now()) / 1000).toFixed(1)}s · Esc to cancel`, 0, 0));
2256
- if (this.streamThinking.length > 0) {
2257
- this.live.addChild(
2258
- !this.thinkingExpanded && this.extensionUi.hiddenThinkingLabel !== undefined ? new PiText(this.extensionUi.hiddenThinkingLabel, 0, 0) : new Lines((width) =>
2259
- renderToolView({ kind: "thinking", text: this.extensionUi.transformMarkdown(this.streamThinking, width, true, "assistant-thinking") }, this.thinkingExpanded, width, style),
2260
- ),
2261
- );
2262
- }
2263
- if (this.streamText.length > 0) {
2264
- if (this.liveStyle !== style || this.liveMarkdown === null) {
2265
- this.liveStyle = style;
2266
- this.liveMarkdown = new MarkdownBlock(this.streamText, style, this.markdownFeatures, true);
2267
- }
2268
- this.liveMarkdown.setText(this.streamText);
2269
- this.live.addChild(this.liveMarkdown);
2270
- }
2271
- }
2272
-
2273
- private rebuildChrome(): void {
2274
- const inner = this.tree ?? this.select ?? this.settings ?? this.editor;
2275
- inner.setStyle(this.style);
2276
- inner.onInput = this.editor.onInput;
2277
- this.editorBox.clear();
2278
- this.editorBox.addChild(this.extensionUi.modal ?? inner);
2279
- if (!this.extensionUi.overlayFocused) this.screen.setFocus(this.extensionUi.modal ?? inner);
2280
- this.footerBox.clear();
2281
- for (const item of this.queue) this.footerBox.addChild(new PiText(`${item.kind === "steering" ? "Steering" : "Follow-up"}: ${firstLine(item.text, 160)}${item.images?.length ? ` · ${item.images.length} image(s)` : ""}`, 0, 0));
2282
- if (this.pendingImages.length) this.footerBox.addChild(new PiText(`Images: ${this.pendingImages.map(i => i.name).join(", ")} · /attachments to remove`, 0, 0));
2283
- const working = this.extensionUi.working(this.inflight, this.executionKind === "bash" ? "Running command" : "Working");
2284
- if (working) this.footerBox.addChild(new PiText(working, 0, 0));
2285
- if (this.extensionUi.customFooter) this.footerBox.addChild(this.extensionUi.customFooter);
2286
- else {
2287
- this.footerBox.addChild(new Lines((width) => this.statusLines(width)));
2288
- if (this.extensionUi.status) this.footerBox.addChild(new PiText(this.extensionUi.status, 0, 0));
2289
- }
2290
- }
2291
-
2292
- private statusLines(width: number): string[] {
2293
- if (this.app.ui?.settings.statusBar === false) return [];
2294
- const attrs = { ...PLAIN_ATTRS, status: true };
2295
- return renderStatus(this.statusState(), width).map((line) => paint(line, attrs, this.style));
2296
- }
2297
-
2298
- private statusState(): StatusState {
2299
- const s = this.app.session;
2300
- const budget = contextBudget({
2301
- frozen: s.config.frozen,
2302
- handoff: s.state.handoff,
2303
- events: s.state.events,
2304
- anchor: s.state.anchor,
2305
- atTurnBoundary: !this.inflight,
2306
- ...(s.config.counter ? { counter: s.config.counter } : {}),
2307
- ...(s.config.boundary ? { boundary: s.config.boundary } : {}),
2308
- });
2309
- const t = s.totals;
2310
- const cost = reportedCost(t, this.app.providerConfig.spec.price);
2311
- return {
2312
- cwd: this.app.workspace,
2313
- branch: this.branch,
2314
- tokens: budget.used,
2315
- triggerTokens: budget.trigger,
2316
- model: this.app.providerConfig.spec.id,
2317
- thinking: this.app.providerConfig.effort,
2318
- cut: budget.decision.cut,
2319
- reason: budget.decision.reason,
2320
- ...(this.sessionName ? { sessionName: this.sessionName } : {}),
2321
- home: homedir(),
2322
- inputTokens: t.input_tokens,
2323
- outputTokens: t.output_tokens,
2324
- cacheRead: t.cache_read_input_tokens,
2325
- cacheWrite: t.cache_creation_input_tokens,
2326
- ...(t.prompt_tokens > 0 ? { cacheHitRate: t.cache_hit_rate * 100 } : {}),
2327
- ...(cost.kind === "usd" ? { cost: cost.amount } : cost.kind === "partial" ? { cost: cost.known } : {}),
2328
- ...(this.queue.length > 0 ? { queued: this.queue.length } : {}),
2329
- };
2330
- }
2331
-
2332
- openSettings(field?: string): void {
2333
- if (field !== undefined) { this.openSettingValues(field); return; }
2334
- const settings = this.app.ui?.settings;
2335
- this.settings = new Settings([
2336
- { id: "theme", label: "Theme", currentValue: settings?.theme ?? "dark", values: ["dark", "light"], description: "Apply the palette to the editor, menus and complete history." },
2337
- { id: "thinking", label: "Thinking", currentValue: this.app.providerConfig.effort, values: EFFORTS.filter((effort) => effortSupported(this.app.providerConfig.spec, effort)), description: "Reasoning effort for the next turn." },
2338
- { id: "statusBar", label: "Status bar", currentValue: settings?.statusBar === false ? "off" : "on", values: ["on", "off"], description: "Workspace, usage and distance to the next context boundary." },
2339
- { id: "steeringMode", label: "Steering mode", currentValue: settings?.steeringMode ?? "one-at-a-time", values: ["one-at-a-time", "all"], description: "Deliver queued messages after tool results." },
2340
- { id: "followUpMode", label: "Follow-up mode", currentValue: settings?.followUpMode ?? "one-at-a-time", values: ["one-at-a-time", "all"], description: "Deliver queued messages after the current turn." },
2341
- { id: "defaultModel", label: "Default model", currentValue: settings?.defaultModel ?? "automatic", values: ["configure"], description: "Choose the provider and model for new sessions." },
2342
- { id: "modelThinkingLevels", label: "Model thinking defaults", currentValue: `${Object.keys(settings?.modelThinkingLevels ?? {}).length} configured`, values: ["configure"], description: "Set or clear a thinking default by provider/model." },
2343
- ...DISPLAY_SETTINGS.map(item => ({ ...item, currentValue: settingValue(settings ?? {}, item.id, item.fallback), values: settingValues(item.id) })),
2344
- ], this.style);
2345
- const menu = this.settings;
2346
- void discoverThemes(this.app.resources).then(names => { if (this.settings === menu) { menu.updateValues("theme", names); this.paint(); } }).catch(error => this.writeLine(String(error)));
2347
- this.paint();
2348
- }
2349
-
2350
- openLoginSelect(method?: "oauth" | "api_key"): void {
2351
- const providers = [...new Set(this.app.catalog.map((model) => model.provider))];
2352
- const status = (provider: string): string => {
2353
- const spec = this.app.catalog.find(model => model.provider === provider);
2354
- const resolved = spec ? this.app.credentialOf(spec) : undefined;
2355
- const stored = this.app.auth[provider];
2356
- return resolved?.ok ? `${stored?.type === "oauth" && resolved.source === "auth.json" ? "account" : "API key"} · ${resolved.source}` : stored ? "stored credential unavailable" : "login required";
2357
- };
2358
- this.select = new SelectList([
2359
- ...(method ? [] : [
2360
- { value: "auth:oauth", label: "Sign in with an account", description: "Choose a subscription provider" },
2361
- { value: "auth:api_key", label: "Sign in with an API key", description: "Choose a key provider" },
2362
- ]),
2363
- ...(method === "api_key" ? [] : SUBSCRIPTION_OPTIONS.map(option => ({ value: option.id, label: option.id, description: `${option.label} · ${status(option.provider)}` }))),
2364
- ...(method === "oauth" ? [] : providers.map(provider => ({ value: provider, label: provider, description: `API key · ${status(provider)}` }))),
2365
- ], { style: this.style, title: "Login · choose a provider" });
2366
- this.selectKind = "other";
2367
- this.onSelectCancel = method ? () => this.openLoginSelect() : undefined;
2368
- this.onPick = async item => {
2369
- if (item.value === "auth:oauth" || item.value === "auth:api_key") { this.openLoginSelect(item.value === "auth:oauth" ? "oauth" : "api_key"); return; }
2370
- const provider = subscriptionById(item.value)?.provider ?? item.value;
2371
- const spec = this.app.catalog.find(model => model.provider === provider);
2372
- const resolved = spec ? this.app.credentialOf(spec) : undefined;
2373
- if (resolved?.ok && resolved.source !== "auth.json") this.app.io.say(` Current credential source: ${resolved.source}. /logout only removes stored credentials. Environment: ${envLabel(provider)}.`);
2374
- await handleLine(this.app, "/login " + item.value, this);
2375
- };
2376
- this.paint();
2377
- }
2378
-
2379
- beginCredentialInput(provider: string): void {
2380
- this.credentialDraft ??= this.editor.state.text;
2381
- this.awaitingKeyFor = provider;
2382
- this.editor.setSecret(true);
2383
- this.editor.applyExternal("");
2384
- this.paint();
2385
- }
2386
-
2387
- endCredentialInput(): void {
2388
- if (this.credentialDraft === undefined && this.awaitingKeyFor === null) return;
2389
- const draft = this.credentialDraft ?? "";
2390
- this.credentialDraft = undefined;
2391
- this.awaitingKeyFor = null;
2392
- this.editor.setSecret(false);
2393
- this.editor.applyExternal(draft);
2394
- this.paint();
2395
- }
2396
-
2397
- openLogoutSelect(): void {
2398
- this.selectKind = "other";
2399
- this.select = new SelectList(Object.keys(this.app.auth).filter(provider => this.app.auth[provider] !== undefined)
2400
- .map(provider => ({ value: provider, label: provider, description: "Remove stored credential" })),
2401
- { style: this.style, title: "Logout · choose a provider", emptyMessage: "No stored credentials" });
2402
- this.onPick = async item => { await logout(this.app, item.value); };
2403
- this.paint();
2404
- }
2405
-
2406
- private scopeModels(): ModelSpec[] {
2407
- return this.app.catalog.filter(spec => this.app.credentialOf(spec).ok);
2408
- }
2409
-
2410
- private activeScope(): string[] {
2411
- return this.scopeEntries().map(entry => modelKey(entry.model));
2412
- }
2413
-
2414
- private scopeEntries() {
2415
- const fromFlag = splitModelPatterns(this.app.args.models);
2416
- const configured = this.app.args.modelsExplicit || fromFlag.length ? fromFlag : this.app.ui?.settings.scopedModels;
2417
- return scopedModels(configured ?? this.scopeModels().map(modelKey), this.scopeModels()).entries;
2418
- }
2419
-
2420
- private scopedItems(): SelectItem[] {
2421
- const enabled = this.activeScope();
2422
- return this.scopeModels().sort((a, b) => {
2423
- const ai = enabled.indexOf(modelKey(a)), bi = enabled.indexOf(modelKey(b));
2424
- return (ai < 0 ? Infinity : ai) - (bi < 0 ? Infinity : bi);
2425
- }).map(spec => ({ value: modelKey(spec), label: `${enabled.includes(modelKey(spec)) ? "[x]" : "[ ]"} ${spec.id}`,
2426
- description: `${spec.provider} · ${spec.name}` }));
2427
- }
2428
-
2429
- openScopedModels(): void {
2430
- this.selectKind = "scoped-models";
2431
- this.select = new SelectList(this.scopedItems(), { style: this.style, title: "Scoped models · session changes",
2432
- hint: "Enter toggle · Ctrl+A enable · Ctrl+X clear · Ctrl+P provider · Alt+↑↓ order · Ctrl+S save · Esc close",
2433
- emptyMessage: "No available models. Use /login to add a credential." });
2434
- this.onPick = null;
2435
- this.paint();
2436
- }
2437
-
2438
- private matchesAction(data: string, action: string): boolean {
2439
- return (this.app.ui?.keymap ?? defaultKeymap()).byAction[action]?.some(chord => keyMatches(data, chord)) ?? false;
2440
- }
2441
-
2442
- private async scopedInput(data: string): Promise<boolean> {
2443
- if (!this.select || this.selectKind !== "scoped-models") return false;
2444
- const state = this.select.state, selected = state.items[state.selected]?.value;
2445
- const ids = state.items.map(item => item.value);
2446
- if (this.matchesAction(data, "app.models.save")) {
2447
- const scopedModels = this.scopeEntries().map(scopeReference);
2448
- await writeUserSettings({ scopedModels });
2449
- if (this.app.ui) this.app.ui.userOverrides = { ...this.app.ui.userOverrides, scopedModels };
2450
- this.app.io.say(this.app.io.palette.green(` saved ${scopedModels.length} scoped models`));
2451
- return true;
2452
- }
2453
- let change: ScopeChange | undefined;
2454
- if (this.matchesAction(data, "app.models.enableAll")) change = { kind: "enable", ids };
2455
- else if (this.matchesAction(data, "app.models.clearAll")) change = { kind: "disable", ids };
2456
- else if (selected && this.matchesAction(data, "tui.select.confirm")) change = { kind: "toggle", ids: [selected] };
2457
- else if (selected && this.matchesAction(data, "app.models.toggleProvider")) {
2458
- const provider = this.app.catalog.find(spec => modelKey(spec) === selected)?.provider;
2459
- change = { kind: "toggle", ids: this.scopeModels().filter(spec => spec.provider === provider).map(modelKey) };
2460
- } else if (selected && this.matchesAction(data, "app.models.reorderUp")) change = { kind: "move", id: selected, delta: -1 };
2461
- else if (selected && this.matchesAction(data, "app.models.reorderDown")) change = { kind: "move", id: selected, delta: 1 };
2462
- if (!change) return false;
2463
- const before = new Map(this.scopeEntries().map(entry => [modelKey(entry.model), scopeReference(entry)]));
2464
- const scopedModels = changeModelScope(this.activeScope(), change).map(id => before.get(id) ?? id);
2465
- this.app.args.models = "";
2466
- this.app.args.modelsExplicit = false;
2467
- if (this.app.ui) this.app.ui.settings = { ...this.app.ui.settings, scopedModels };
2468
- this.select.setItems(this.scopedItems());
2469
- return true;
2470
- }
2471
-
2472
- async openForkSelect(): Promise<void> {
2473
- const points = await this.app.session.userMessages();
2474
- this.select = new SelectList(points.map((point, i) => ({ value: String(i + 1), label: firstLine(point.text, 120), description: "node " + point.node })),
2475
- { style: this.style, title: "Fork · edit a previous user message", emptyMessage: "No user messages yet" });
2476
- this.onPick = async (item) => { await forkSession(this.app, item.value, this); };
2477
- this.paint();
2478
- }
2479
-
2480
- private openSettingValues(field: string): void {
2481
- const key = field.toLowerCase();
2482
- if (key === "defaultmodel") { this.openDefaultModelSelect(); return; }
2483
- if (key === "modelthinkinglevels") { this.openModelThinkingDefaults(); return; }
2484
- let items: SelectItem[] = [];
2485
- if (key === "theme") {
2486
- items = [
2487
- { value: "dark", label: "dark" },
2488
- { value: "light", label: "light" },
2489
- { value: "system", label: "system" },
2490
- { value: "auto", label: "auto" },
2491
- ];
2492
- } else if (key === "thinking") {
2493
- items = supportedThinkingLevels(this.app.providerConfig.spec).map((v) => ({ value: v, label: v }));
2494
- } else if (key === "statusbar" || key === "status") {
2495
- items = [
2496
- { value: "on", label: "on" },
2497
- { value: "off", label: "off" },
2498
- ];
2499
- } else if (settingValues(field).length) {
2500
- items = settingValues(field).map(value => ({ value, label: value }));
2501
- } else {
2502
- this.app.io.say(this.app.io.palette.yellow(` unknown setting: ${field}`));
2503
- this.paint();
2504
- return;
2505
- }
2506
- this.select = new SelectList(items, { style: this.style, title: "Setting · " + field });
2507
- this.selectKind = "other";
2508
- if (key === "theme") {
2509
- this.previewTheme = this.app.ui?.theme;
2510
- const selector = this.select;
2511
- void discoverThemes(this.app.resources).then(names => { if (this.select === selector) { selector.setItems(names.map(name => ({ value: name, label: name }))); this.paint(); } }).catch(error => this.writeLine(String(error)));
2512
- }
2513
- this.onPick = async (item) => {
2514
- this.previewTheme = undefined;
2515
- await commitSetting(this.app, key === "status" ? "statusBar" : field, item.value, this);
2516
- };
2517
- this.paint();
2518
- }
2519
-
2520
- private openDefaultModelSelect(): void {
2521
- this.selectKind = "other";
2522
- const current = this.app.ui?.settings;
2523
- this.select = new SelectList(this.app.catalog.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider })),
2524
- { style: this.style, title: "Default model · new sessions", selectedValue: `${current?.defaultProvider}/${current?.defaultModel}` });
2525
- this.onPick = async item => {
2526
- const spec = this.app.catalog.find(model => modelKey(model) === item.value);
2527
- if (!spec || !this.app.ui) return;
2528
- const patch = { defaultProvider: spec.provider, defaultModel: spec.id };
2529
- await writeUserSettings(patch);
2530
- this.app.ui.settings = { ...this.app.ui.settings, ...patch };
2531
- this.app.ui.userOverrides = { ...this.app.ui.userOverrides, ...patch };
2532
- this.app.io.say(` saved default model ${item.value}`);
2533
- };
2534
- this.paint();
2535
- }
2536
-
2537
- private openModelThinkingDefaults(identity?: string): void {
2538
- const ui = this.app.ui;
2539
- if (!ui) return;
2540
- const models = new Map(this.app.catalog.map(spec => [modelKey(spec), spec]));
2541
- this.selectKind = "other";
2542
- if (!identity) {
2543
- const keys = [...new Set([...models.keys(), ...Object.keys(ui.settings.modelThinkingLevels ?? {})])];
2544
- this.select = new SelectList(keys.map(key => ({ value: key, label: key, description: ui.settings.modelThinkingLevels?.[key] ?? "inherited" })),
2545
- { style: this.style, title: "Model thinking defaults" });
2546
- this.onPick = async item => this.openModelThinkingDefaults(item.value);
2547
- } else {
2548
- const model = models.get(identity);
2549
- this.select = new SelectList([
2550
- { value: "clear", label: "Clear user override", description: "Use the project or global default" },
2551
- ...(model ? supportedThinkingLevels(model).map(level => ({ value: level, label: level })) : []),
2552
- ], { style: this.style, title: `Thinking default · ${identity}`, selectedValue: ui.settings.modelThinkingLevels?.[identity] ?? "clear" });
2553
- this.onPick = async item => {
2554
- const level = asEffort(item.value);
2555
- if (item.value === "clear") {
2556
- await removeModelThinkingOverride(identity);
2557
- const loaded = await loadUiResources({ ...this.app.args, workspace: this.app.workspace, trusted: this.app.projectTrusted ?? this.app.args.approve ?? false });
2558
- ui.settings = { ...ui.settings, modelThinkingLevels: loaded.settings.modelThinkingLevels ?? {} };
2559
- ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
2560
- } else if (level && model && effortSupported(model, level)) {
2561
- const patch = { modelThinkingLevels: { [identity]: level } };
2562
- await writeUserSettings(patch);
2563
- ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
2564
- ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
2565
- }
2566
- this.openModelThinkingDefaults();
2567
- };
2568
- }
2569
- this.paint();
2570
- }
2571
-
2572
- openModelSelect(): void {
2573
- const items: SelectItem[] = [];
2574
- for (const spec of this.app.catalog) {
2575
- const got = this.app.credentialOf(spec);
2576
- const here = modelKey(spec) === modelKey(this.app.providerConfig.spec);
2577
- items.push({
2578
- value: modelKey(spec),
2579
- label: spec.id,
2580
- description: `${spec.provider} · ${spec.name}${here ? " ✓ current" : got.ok ? " ready" : " login required"}`,
2581
- });
2582
- }
2583
- this.select = new SelectList(items, { style: this.style, title: "Model", selectedValue: modelKey(this.app.providerConfig.spec), hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
2584
- this.selectKind = "model";
2585
- this.onPick = async (item) => {
2586
- const spec = this.app.catalog.find((m) => modelKey(m) === item.value);
2587
- if (spec !== undefined) await switchToModel(this.app, spec);
2588
- };
2589
- this.app.io.say(this.app.io.palette.dim(" switching cuts this node so the next model starts from a handoff"));
2590
- this.paint();
2591
- }
2592
-
2593
- openThinkingSelect(): void {
2594
- const spec = this.app.providerConfig.spec;
2595
- const current = this.app.providerConfig.effort;
2596
- const items: SelectItem[] = EFFORTS.map((level) => {
2597
- const supported = effortSupported(spec, level);
2598
- const here = level === current;
2599
- return {
2600
- value: level,
2601
- label: level,
2602
- description: !supported ? "unsupported" : here ? "now" : "",
2603
- };
2604
- });
2605
- this.select = new SelectList(items, { style: this.style, title: "Thinking", selectedValue: current, hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
2606
- this.selectKind = "thinking";
2607
- this.onPick = async (item) => {
2608
- const effort = asEffort(item.value);
2609
- if (effort === undefined) return;
2610
- await applyThinking(this.app, effort, this);
2611
- };
2612
- this.paint();
2613
- }
2614
-
2615
- private async saveSelectDefault(): Promise<void> {
2616
- if (this.select === null) return;
2617
- const item = this.select.state.items[this.select.state.selected];
2618
- if (item === undefined) return;
2619
- const C = this.app.io.palette;
2620
- if (this.selectKind === "model") {
2621
- // Patch only this key: dumping merged settings would freeze today's defaults.
2622
- const spec = this.app.catalog.find(model => modelKey(model) === item.value);
2623
- if (!spec) return;
2624
- await writeUserSettings({ defaultModel: spec.id, defaultProvider: spec.provider });
2625
- if (this.app.ui !== undefined) {
2626
- this.app.ui.userOverrides = { ...this.app.ui.userOverrides, defaultModel: spec.id, defaultProvider: spec.provider };
2627
- this.app.ui.settings = { ...this.app.ui.settings, defaultModel: spec.id, defaultProvider: spec.provider };
2628
- }
2629
- this.app.io.say(C.green(` saved default model ${item.value}`));
2630
- return;
2631
- }
2632
- if (this.selectKind === "thinking") {
2633
- const effort = asEffort(item.value);
2634
- if (!effort || !effortSupported(this.app.providerConfig.spec, effort)) {
2635
- this.app.io.say(C.yellow(" Cannot save an unsupported thinking level."));
2636
- return;
2637
- }
2638
- const patch = { modelThinkingLevels: { [modelKey(this.app.providerConfig.spec)]: effort } };
2639
- await writeUserSettings(patch);
2640
- if (this.app.ui !== undefined) {
2641
- this.app.ui.userOverrides = mergeSettings({ builtin: this.app.ui.userOverrides, user: patch });
2642
- this.app.ui.settings = mergeSettings({ builtin: this.app.ui.settings, user: patch });
2643
- }
2644
- this.app.io.say(C.green(` saved default thinking ${item.value}`));
2645
- }
2646
- }
2647
-
2648
- async openResumeSelect(): Promise<void> {
2649
- if (this.select instanceof SessionBrowser) this.select.dispose();
2650
- const browser = new SessionBrowser({ root: { path: dirname(this.app.logDir), workspace: this.app.workspace },
2651
- style: this.style, active: this.app.logDir, redraw: () => this.paint(), bindings: () => this.app.ui?.keymap ?? defaultKeymap(),
2652
- renamed: (path, name) => { if (path === this.app.logDir) this.sessionName = name || null; } });
2653
- this.select = browser;
2654
- this.selectKind = "other";
2655
- this.onPick = async (item) => {
2656
- await resumeSession(this.app, item.value, this);
2657
- };
2658
- this.paint();
2659
- await browser.load();
2660
- }
2661
-
2662
- async openResourceSelect(): Promise<void> {
2663
- const trusted = (await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve)).trusted;
2664
- const resources = { ...this.app.args, workspace: this.app.workspace, trusted };
2665
- const index = await discoverResources(resources);
2666
- if (this.select instanceof SessionBrowser) this.select.dispose();
2667
- this.select = new ResourceBrowser({ resources, index, style: this.style, redraw: () => this.paint(), changed: async () => {
2668
- await reloadAppResources(this.app); await this.refreshResources();
2669
- } });
2670
- this.selectKind = "other"; this.onPick = null; this.paint();
2671
- }
2672
-
2673
- async openTrustSelect(): Promise<void> {
2674
- const trust = await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve);
2675
- if (this.select instanceof SessionBrowser) this.select.dispose();
2676
- this.select = new TrustSelector(this.app.workspace, trust, this.style);
2677
- this.selectKind = "other";
2678
- this.onPick = async item => { await trustProject(this.app, item.value as TrustChoice, this); };
2679
- this.paint();
2680
- }
2681
-
2682
- /**
2683
- * Selection resolves an exact log position through Session.navigateTo.
2684
- * User messages become drafts; other messages remain included. If a turn is in flight,
2685
- * the existing AbortSignal fires first — same path Escape already uses.
2686
- */
2687
- async openTreeSelect(): Promise<void> {
2688
- if (this.select instanceof SessionBrowser) this.select.dispose();
2689
- this.select = null;
2690
- this.onPick = null;
2691
- const nodes = await treeNodesFor(this.app);
2692
- this.tree = new TreeList(nodes, { style: this.style, filter: this.app.ui?.settings.treeFilterMode ?? "default", bindings: (this.app.ui?.keymap ?? defaultKeymap()).byAction });
2693
- this.onTreePick = async (node) => {
2694
- if (node.target === undefined) return;
2695
- if (this.inflight) { await this.settle(); this.returnQueueToEditor(); }
2696
- const done = await this.app.session.navigateTo(node.target);
2697
- await this.syncTranscript();
2698
- if (done.editorText !== undefined) this.restoreDraft(done.editorText, done.images ?? []);
2699
- const C = this.app.io.palette;
2700
- this.app.io.say(C.green(`\n conversation at node ${this.app.session.state.node}`));
2701
- this.app.io.say("");
2702
- };
2703
- this.paint();
2704
- }
2705
-
2706
- async nextInput(): Promise<string> {
2707
- while (this.inputs.length === 0) {
2708
- await new Promise<void>((resolve) => { this.wake = resolve; });
2709
- this.wake = null;
2710
- }
2711
- return this.inputs.shift()!;
2712
- }
2713
-
2714
- attach(): void {
2715
- this.attached = true;
2716
- this.rebuildChrome();
2717
- this.screen.start();
2718
- this.followTheme();
2719
- }
2720
-
2721
- detach(): void {
2722
- void cancelPendingOAuth(this.app);
2723
- this.endCredentialInput();
2724
- this.extensionUi.dispose();
2725
- if (this.select instanceof SessionBrowser) this.select.dispose();
2726
- this.stopRetryTimer();
2727
- this.stopThemeWatch?.(); this.stopColorWatch?.(); this.themeGeneration++;
2728
- this.progressActive = false;
2729
- this.attached = false;
2730
- const hint = this.screen.mode === "fullscreen" && this.app.ui?.settings.fullscreenExitOutput === "resume-hint";
2731
- if (this.screen.mode === "fullscreen") this.screen.setLayoutRoot(this.document);
2732
- this.screen.stop({ preserveScreen: hint });
2733
- if (hint) this.screen.terminal.write("\r\nxeno --session " + JSON.stringify(this.app.logDir) + "\r\n");
2734
- }
2735
-
2736
- async settle(): Promise<void> {
2737
- await cancelPendingOAuth(this.app);
2738
- this.abort?.abort();
2739
- this.approvalResolve?.("deny");
2740
- await this.turn;
2741
- await Promise.all([...this.extensionWork]);
2742
- await this.app.extensions?.settle();
2743
- }
2744
-
2745
- showDocument(text: string): void {
2746
- this.chat.addChild(new MarkdownBlock(text, this.style, this.markdownFeatures)); this.paint();
2747
- }
2748
-
2749
- runSessionTransition(action: () => Promise<void>): void {
2750
- if (this.sessionTransition) return;
2751
- this.sessionTransition = true;
2752
- this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
2753
- }
2754
-
2755
- runExtensionAction(action: () => void | Promise<void>): void {
2756
- const work = Promise.resolve().then(action).catch(error => this.app.io.say(this.app.io.palette.red(describeError(error))))
2757
- .finally(() => { this.extensionWork.delete(work); void this.syncTranscript().then(() => this.paint()); });
2758
- this.extensionWork.add(work);
2759
- }
2760
- runExtensionCommand(name: string, args: string): void {
2761
- this.runExtensionAction(async () => { await bindExtensions(this.app, this); await this.app.extensions?.command(name, args); });
2762
- }
2763
- deliverExtension(text: string, kind: QueueKind): void {
2764
- if (this.inflight) this.enqueue({ kind, text });
2765
- else this.turn = this.runPromptTurn(text).catch(error => this.app.io.say(describeError(error)));
2766
- }
2767
-
2768
- requestShutdown(): void {
2769
- void cancelPendingOAuth(this.app);
2770
- this.extensionUi.cancelDialogs();
2771
- this.shutdownRequested = true;
2772
- this.resumeSuspended?.();
2773
- this.executionKind = "shutdown";
2774
- this.abort?.abort();
2775
- this.approvalResolve?.("deny");
2776
- this.inputs.push("");
2777
- this.wake?.();
2778
- }
2779
-
2780
- async suspend(): Promise<void> {
2781
- if (process.platform === "win32" || !this.attached) return;
2782
- // Pi keeps a live handle while raw input is detached, otherwise fg can return to an exited process.
2783
- const keepAlive = setInterval(() => {}, 60_000);
2784
- const ignoreInterrupt = () => {};
2785
- process.on("SIGINT", ignoreInterrupt);
2786
- let resume!: () => void;
2787
- const continued = new Promise<void>(resolve => { resume = resolve; process.once("SIGCONT", resolve); });
2788
- this.resumeSuspended = resume;
2789
- try {
2790
- this.screen.stop({ preserveScreen: true });
2791
- process.kill(process.pid, "SIGTSTP");
2792
- await continued;
2793
- } finally {
2794
- clearInterval(keepAlive);
2795
- process.off("SIGINT", ignoreInterrupt);
2796
- process.off("SIGCONT", resume);
2797
- this.resumeSuspended = undefined;
2798
- this.screen.start();
2799
- this.screen.requestRender(true);
2800
- this.paint();
2801
- }
2802
- }
2803
-
2804
- ask(request: PermissionRequest): Promise<ApprovalChoice> {
2805
- return new Promise((resolve) => {
2806
- this.app.io.say(approvalHeading(request));
2807
- this.select = new SelectList(approvalItems(request), { style: this.style, title: "Tool approval", search: false });
2808
- this.approvalResolve = resolve;
2809
- this.onPick = async (item) => {
2810
- resolve(choiceFromValue(item.value));
2811
- };
2812
- this.paint();
2813
- });
2814
- }
2815
-
2816
- private boundAction(data: string): ActionId | undefined {
2817
- return Object.entries((this.app.ui?.keymap ?? defaultKeymap()).byKey)
2818
- .find(([chord]) => keyMatches(data, chord))?.[1];
2819
- }
2820
-
2821
- private async openExternalEditor(): Promise<void> {
2822
- const attached = this.attached;
2823
- if (attached) this.screen.stop({ preserveScreen: true });
2824
- if (attached && process.stdin.isTTY) process.stdin.setRawMode(false);
2825
- try {
2826
- const result = await editInExternalEditor({ content: this.editor.state.text, command: this.app.ui?.settings.externalEditor ?? resolveEditorCommand() });
2827
- if (result.ok) this.editor.applyExternal(result.content);
2828
- else this.app.io.say(result.message);
2829
- } finally {
2830
- if (attached) { this.screen.start(); this.screen.requestRender(true); }
2831
- this.paint();
2832
- }
2833
- }
2834
-
2835
- /** Programmatic compatibility; live stdin always enters as Pi raw input. */
2836
- async applyKey(key: Key): Promise<"continue" | "quit"> { return this.applyInput(encodeKey(key)); }
2837
-
2838
- restoreDraft(text: string, images: readonly ImageRef[] = []): void {
2839
- this.editor.applyExternal([text, this.editor.state.text].filter(Boolean).join("\n"));
2840
- this.pendingImages = [...images, ...this.pendingImages];
2841
- this.paint();
2842
- }
2843
-
2844
- async pasteClipboard(content?: ClipboardContent): Promise<void> {
2845
- try {
2846
- const value = content === undefined ? await this.clipboardReader() : content;
2847
- if (!value) return;
2848
- if ("files" in value) {
2849
- const images: ImageRef[] = [], text: string[] = [];
2850
- for (const path of value.files) {
2851
- if (/[\x00-\x1f]/.test(path)) throw new Error("Clipboard file path contains control characters");
2852
- const absolute = path.startsWith("file://") ? fileURLToPath(path) : resolve(this.app.workspace, path);
2853
- const image = await attachImageFile(absolute, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true);
2854
- if (image) images.push(image); else text.push("@" + JSON.stringify(path));
2855
- }
2856
- this.pendingImages.push(...images);
2857
- if (text.length) this.editor.acceptInput("\x1b[200~" + text.join(" ") + "\x1b[201~");
2858
- } else if ("image" in value) {
2859
- this.pendingImages.push(await attachImageBytes(value.image, "clipboard.png", this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true));
2860
- } else this.editor.acceptInput("\x1b[200~" + value.text + "\x1b[201~");
2861
- } catch (error) { this.app.io.say(" Paste failed: " + describeError(error)); }
2862
- this.paint();
2863
- }
2864
-
2865
- openAttachments(): void {
2866
- this.select = new SelectList(this.pendingImages.map((image, i) => ({ value: String(i), label: image.name,
2867
- description: `${image.width}x${image.height} · Enter to remove` })), { title: "Draft images · remove one", style: this.style, emptyMessage: "No draft images" });
2868
- this.onPick = async item => { this.pendingImages.splice(Number(item.value), 1); };
2869
- this.paint();
2870
- }
2871
-
2872
- async applyInput(data: string, fromTerminal = false): Promise<"continue" | "quit"> {
2873
- this.editor.bindings = installBindings((this.app.ui?.keymap ?? defaultKeymap()).byAction);
2874
- if (!fromTerminal) { const filtered = this.extensionUi.filterInput(data); if (filtered === undefined) return "continue"; data = filtered; }
2875
- const action = this.boundAction(data);
2876
- if (action === "app.suspend") { await this.suspend(); return "continue"; }
2877
- if (this.extensionUi.acceptInput(data)) return "continue";
2878
- // Modal ownership precedes application shortcuts, including during a turn.
2879
- if (this.settings) {
2880
- const menu = this.settings;
2881
- for (const change of menu.acceptInput(data)) {
2882
- if (change.id === "defaultModel" || change.id === "modelThinkingLevels") { this.settings = null; this.openSettings(change.id); }
2883
- else await commitSetting(this.app, change.id, change.value, this);
2884
- }
2885
- if (menu.cancelled && this.settings === menu) this.settings = null;
2886
- this.paint();
2887
- return "continue";
2888
- }
2889
- if (this.tree) {
2890
- const bindings = (this.app.ui?.keymap ?? defaultKeymap()).byAction;
2891
- const ownerAction = Object.entries(bindings).find(([id, chords]) =>
2892
- (id.startsWith("app.tree.") || id === "app.message.copy") && chords.some(chord => keyMatches(data, chord)))?.[0];
2893
- const state = this.tree.acceptInput(data, ownerAction);
2894
- if (this.tree.labelChange) {
2895
- const change = this.tree.labelChange;
2896
- await setSessionLabel(this.app.logDir, change.target, change.text, this.app.session.clock.now());
2897
- this.tree.setNodes(await treeNodesFor(this.app));
2898
- }
2899
- if (this.tree.copyText !== undefined) {
2900
- const copied = await this.clipboardWriter(this.tree.copyText);
2901
- this.app.io.say(copied ? " Copied selected message" : " Clipboard unavailable");
2902
- }
2903
- if (state.cancelled || state.confirmed) {
2904
- const pick = this.onTreePick;
2905
- this.tree = null;
2906
- this.onTreePick = null;
2907
- if (state.confirmed && pick) await pick(state.confirmed);
2908
- }
2909
- this.paint();
2910
- return "continue";
2911
- }
2912
- if (this.select) {
2913
- if (this.select instanceof ResourceBrowser && await this.select.handleAction(data)) { this.paint(); return "continue"; }
2914
- if (this.select instanceof SessionBrowser && await this.select.handleAction(data)) { this.paint(); return "continue"; }
2915
- if (await this.scopedInput(data)) { this.paint(); return "continue"; }
2916
- if (action === "app.saveDefault" && !this.approvalResolve) await this.saveSelectDefault();
2917
- else {
2918
- const state = this.select.acceptInput(data);
2919
- if (this.previewTheme && !state.confirmed && !state.cancelled) {
2920
- const item = state.items[state.selected];
2921
- if (item) { const diagnostics: string[] = []; const theme = await resolveTheme(this.app.resources ? themeResourcePath(this.app.resources, item.value) ?? item.value : item.value, diagnostics); if (!diagnostics.length) this.setTheme(theme); }
2922
- }
2923
- if (state.cancelled || state.confirmed) {
2924
- const pick = this.onPick, approval = this.approvalResolve, cancel = this.onSelectCancel;
2925
- if (this.select instanceof SessionBrowser) this.select.dispose();
2926
- this.select = null;
2927
- this.onPick = null;
2928
- this.onSelectCancel = undefined;
2929
- this.approvalResolve = null;
2930
- this.selectKind = "other";
2931
- if (state.confirmed && pick) { await pick(state.confirmed); await this.syncTranscript(); }
2932
- else { approval?.("deny"); cancel?.(); if (this.previewTheme) { this.setTheme(this.previewTheme); this.previewTheme = undefined; } }
2933
- }
2934
- }
2935
- this.paint();
2936
- return "continue";
2937
- }
2938
- // A secret editor owns every key; model, file and extension actions cannot consume its input.
2939
- if (this.awaitingKeyFor !== null) {
2940
- if (action === "app.interrupt" || action === "app.clear") {
2941
- await cancelPendingOAuth(this.app);
2942
- this.endCredentialInput();
2943
- return "continue";
2944
- }
2945
- if (action === "app.exit" && this.editor.state.text.length === 0) { await cancelPendingOAuth(this.app); return "quit"; }
2946
- const state = this.editor.acceptInput(data);
2947
- if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
2948
- this.paint(); return "continue";
2949
- }
2950
- for (const [key, shortcut] of this.app.extensions?.shortcuts ?? []) if (keyMatches(data, key)) {
2951
- this.runExtensionAction(() => shortcut.handler(extensionContext(this.app, this))); return "continue";
2952
- }
2953
- if (data.includes("\x1b[200~")) this.pasting = true;
2954
- if (this.pasting) {
2955
- this.pasteBuffer += data;
2956
- if (data.includes("\x1b[201~")) {
2957
- this.pasting = false;
2958
- const payload = this.pasteBuffer.replace(/^\x1b\[200~/, "").replace(/\x1b\[201~$/, "");
2959
- const paths = droppedImagePaths(payload);
2960
- if (paths) {
2961
- const before = this.pendingImages.length;
2962
- await this.pasteClipboard({ files: paths });
2963
- if (this.pendingImages.length === before) this.editor.acceptInput(this.pasteBuffer);
2964
- } else this.editor.acceptInput(this.pasteBuffer);
2965
- this.pasteBuffer = "";
2966
- }
2967
- this.paint();
2968
- return "continue";
2969
- }
2970
- if (this.editor.autocompleteVisible && (keyMatches(data, "escape") || keyMatches(data, "up") || keyMatches(data, "down") || keyMatches(data, "tab"))) {
2971
- this.editor.acceptInput(data);
2972
- this.paint();
2973
- return "continue";
2974
- }
2975
- if (action === "app.clear") {
2976
- if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
2977
- else if (this.editor.state.text.length > 0 || this.pendingImages.length) { this.editor.applyExternal(""); this.pendingImages = []; }
2978
- else if (Date.now() - this.lastInterrupt < 1000) return "quit";
2979
- else this.app.io.say(" Press Ctrl+C again to exit, or Ctrl+D on an empty prompt.");
2980
- this.lastInterrupt = Date.now();
2981
- this.paint();
2982
- return "continue";
2983
- }
2984
- if (action === "app.exit" && this.editor.state.text.length === 0 && this.pendingImages.length === 0) return "quit";
2985
- if (action === "app.interrupt") {
2986
- if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
2987
- else if (this.editor.state.text.length === 0 && Date.now() - this.lastEscape < 500) {
2988
- const action = this.app.ui?.settings.doubleEscapeAction ?? "tree";
2989
- if (action === "tree") await this.openTreeSelect(); else if (action === "fork") await this.openForkSelect();
2990
- }
2991
- this.lastEscape = Date.now();
2992
- this.paint();
2993
- return "continue";
2994
- }
2995
- if (action === "app.message.dequeue") { this.returnQueueToEditor(); this.paint(); return "continue"; }
2996
- if (action === "app.toggleTools") { this.toolsExpanded = !this.toolsExpanded; this.paint(); return "continue"; }
2997
- if (action === "app.toggleThinking") { await commitSetting(this.app, "hideThinkingBlock", this.thinkingExpanded ? "on" : "off", this); return "continue"; }
2998
- if (action === "app.externalEditor") { await this.openExternalEditor(); return "continue"; }
2999
- if (action === "app.clipboard.pasteImage") { await this.pasteClipboard(); return "continue"; }
3000
- if (action === "app.message.copy") {
3001
- if (this.screen.mode !== "fullscreen" || !await this.screen.copyActiveSelectionToClipboard()) await copyLastReply(this.app);
3002
- return "continue";
3003
- }
3004
- if (!this.inflight) {
3005
- if (action === "app.session.new") { await openNewSession(this.app, this); return "continue"; }
3006
- if (action === "app.session.tree") { await this.openTreeSelect(); return "continue"; }
3007
- if (action === "app.session.fork") { await this.openForkSelect(); return "continue"; }
3008
- if (action === "app.session.resume") { void this.openResumeSelect(); return "continue"; }
3009
- if (action === "app.model") { this.openModelSelect(); return "continue"; }
3010
- if (action === "app.cycleModel" || action === "app.cycleModelReverse") {
3011
- await cycleScopedModel(this.app, action === "app.cycleModel" ? 1 : -1); this.paint(); return "continue";
3012
- }
3013
- if (action === "app.cycleThinking") { await cycleThinking(this.app, this); return "continue"; }
3014
- }
3015
- if (action === "app.message.followUp") {
3016
- const text = this.editor.state.text;
3017
- this.editor.applyExternal("");
3018
- return this.submitLine(text, "followup");
3019
- }
3020
- if (keyMatches(data, "enter") && this.pendingImages.length && !this.editor.state.text) return this.submitLine("", "steering");
3021
- const state = this.editor.acceptInput(data);
3022
- if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
3023
- this.paint();
3024
- return "continue";
3025
- }
3026
-
3027
- private async submitLine(text: string, queueKind: QueueKind): Promise<"continue" | "quit"> {
3028
- const line = text.trim();
3029
- this.paint();
3030
- if (this.sessionTransition) { this.editor.applyExternal(text); this.app.io.say(" Wait for session import to finish."); return "continue"; }
3031
- if (this.awaitingKeyFor !== null) {
3032
- await handleLine(this.app, line, this);
3033
- return "continue";
3034
- }
3035
- if (!line && !this.pendingImages.length) return "continue";
3036
- const command = parseLine(this.app, line);
3037
- if (command.kind !== "prompt") {
3038
- if (this.inflight && commandDefinition(line)?.busy === "idle") {
3039
- this.app.io.say(" This command needs an idle session. Press Esc to interrupt the current turn first.");
3040
- this.editor.applyExternal(line);
3041
- return "continue";
3042
- }
3043
- const result = await handleLine(this.app, line, this);
3044
- await this.syncTranscript();
3045
- this.paint();
3046
- return result === "quit" ? "quit" : "continue";
3047
- }
3048
- const images = command.text.startsWith("!") ? [] : this.pendingImages;
3049
- if (!command.text.startsWith("!")) this.pendingImages = [];
3050
- if (this.inflight) this.enqueue({ kind: command.text.startsWith("!") ? "followup" : queueKind, text: command.text, ...(images.length ? { images } : {}) });
3051
- else this.turn = this.runPromptTurn(command.text, images).catch((error: unknown) => {
3052
- this.app.io.say(this.app.io.palette.red(describeError(error)));
3053
- });
3054
- this.paint();
3055
- return "continue";
3056
- }
3057
-
3058
- private async runPromptTurn(text: string, images: readonly ImageRef[] = []): Promise<void> {
3059
- this.abort = new AbortController();
3060
- this.inflight = true;
3061
- this.executionKind = text.startsWith("!") ? "bash" : "model";
3062
- try {
3063
- const sent = await run(this.app, text, this.abort.signal, this, images);
3064
- if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
3065
- this.app.io.say("");
3066
- } finally {
3067
- await this.syncTranscript();
3068
- this.commitStream();
3069
- this.abort = null;
3070
- fileIndexes.delete(this.app.workspace);
3071
- this.inflight = false;
3072
- this.executionKind = "idle";
3073
- this.bashOutput = null;
3074
- this.branch = currentBranch(this.app.workspace);
3075
- this.clearStream();
3076
- this.paint();
3077
- }
3078
- }
3079
-
3080
- /**
3081
- * send() calls this at after_tools. Expansion is here because the
3082
- * files the user named may have changed while the line waited.
3083
- */
3084
- async pullQueued(phase: Phase): Promise<UserInput | null> {
3085
- const batch = this.nextBatch(phase);
3086
- if (!batch.length) return null;
3087
- const expanded = await Promise.all(batch.map(item => this.expandQueued(item)));
3088
- this.queue = this.queue.filter((m) => !batch.includes(m));
3089
- this.paintStream();
3090
- const present = expanded.filter((input): input is UserInput => input !== null).map(input => userInputData(input));
3091
- const text = present.map(input => input.text).join("\n\n"), images = present.flatMap(input => input.images ?? []);
3092
- return images.length ? { text, images } : text || null;
3093
- }
3094
-
3095
- private async expandQueued(item: QueuedMessage): Promise<UserInput | null> {
3096
- const text = item.text;
3097
- const needsIndex = fileReferences(text).length > 0;
3098
- const index: FileIndex = needsIndex
3099
- ? await fileIndexFor(this.app)
3100
- : { paths: [], truncated: false };
3101
- const expanded = await expandInput(text, {
3102
- workspace: this.app.workspace,
3103
- index,
3104
- say: (line) => this.app.io.say(line),
3105
- attachImage: path => attachImageFile(path, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true),
3106
- });
3107
- const images = [...item.images ?? [], ...expanded.images ?? []];
3108
- return images.length ? { text: expanded.message, images } : expanded.send ? expanded.message : null;
3109
- }
3110
-
3111
- private async drainFollowUps(): Promise<void> {
3112
- while (this.abort !== null && !this.abort.signal.aborted) {
3113
- const batch = this.nextBatch("turn_end");
3114
- if (!batch.length) break;
3115
- this.queue = this.queue.filter((m) => !batch.includes(m));
3116
- this.paintStream();
3117
- if (!await run(this.app, batch.map(item => item.text).join("\n\n"), this.abort.signal, this, batch.flatMap(item => item.images ?? []))) break;
3118
- }
3119
- }
3120
-
3121
- private nextBatch(phase: Phase): readonly QueuedMessage[] {
3122
- const settings = this.app.ui?.settings;
3123
- return deliveryBatch(this.queue, phase, { steering: settings?.steeringMode ?? "one-at-a-time", followup: settings?.followUpMode ?? "one-at-a-time" });
3124
- }
3125
-
3126
- private enqueue(item: QueuedMessage): void {
3127
- this.queue.push(item);
3128
- }
3129
-
3130
- beginBash(command: string): void {
3131
- this.executionKind = "bash";
3132
- this.bashDecoder = new TextDecoder();
3133
- this.bashOutput = `$ ${command}\n`;
3134
- this.paint();
3135
- }
3136
-
3137
- pushBash(chunk: Uint8Array): void {
3138
- // Only the live preview is bounded; observeCommand archives every byte.
3139
- this.bashOutput = `${this.bashOutput ?? ""}${this.bashDecoder.decode(chunk, { stream: true })}`.slice(-64 * 1024);
3140
- this.paint();
3141
- }
3142
-
3143
- private returnQueueToEditor(): void {
3144
- if (this.queue.length === 0) return;
3145
- const draft = this.editor.state.text;
3146
- const parts = this.queue.map((m) => m.text);
3147
- this.pendingImages = [...this.queue.flatMap(m => m.images ?? []), ...this.pendingImages];
3148
- if (draft.trim().length > 0) parts.push(draft);
3149
- this.queue = [];
3150
- this.editor.applyExternal(parts.join("\n"));
3151
- }
3152
- }