@visual-z/xeno 0.2.1 → 0.3.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 (258) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +51 -13
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/commands.ts +38 -5
  8. package/src/apps/completions.ts +73 -19
  9. package/src/apps/image-draft.ts +46 -0
  10. package/src/apps/project-trust.ts +2 -42
  11. package/src/apps/provider-connect.ts +346 -0
  12. package/src/apps/render.ts +22 -47
  13. package/src/apps/resources.ts +5 -2
  14. package/src/apps/shell.ts +298 -1526
  15. package/src/apps/startup-input.ts +45 -9
  16. package/src/apps/startup.ts +128 -86
  17. package/src/apps/ui/app.ts +1383 -0
  18. package/src/apps/ui/extension-host.ts +194 -0
  19. package/src/apps/ui/image-cache.ts +78 -0
  20. package/src/apps/ui/palette.ts +88 -0
  21. package/src/apps/ui/project.ts +127 -0
  22. package/src/apps/ui/standalone.ts +146 -0
  23. package/src/apps/ui/status-model.ts +68 -0
  24. package/src/apps/ui/surface.ts +84 -0
  25. package/src/apps/ui/theme.ts +86 -0
  26. package/src/core/acceptance.ts +107 -0
  27. package/src/core/action-defaults.ts +47 -295
  28. package/src/core/benchmark.ts +2 -2
  29. package/src/core/branch-receipt.ts +24 -0
  30. package/src/core/checkpoints.ts +64 -0
  31. package/src/core/churn.ts +2 -0
  32. package/src/core/command-artifacts.ts +94 -0
  33. package/src/core/command-contract.ts +35 -0
  34. package/src/core/completion-review.ts +21 -5
  35. package/src/core/context-detail.ts +37 -0
  36. package/src/core/context-policy.ts +74 -1
  37. package/src/core/continuation.ts +17 -2
  38. package/src/core/derive.ts +29 -3
  39. package/src/core/draft-images.ts +56 -0
  40. package/src/core/environment.ts +145 -0
  41. package/src/core/evaluation-coverage.ts +74 -0
  42. package/src/core/event-validation.ts +80 -5
  43. package/src/core/events.ts +97 -3
  44. package/src/core/evidence-links.ts +7 -0
  45. package/src/core/evidence.ts +13 -3
  46. package/src/core/execution.ts +8 -2
  47. package/src/core/experiment.ts +133 -0
  48. package/src/core/export.ts +9 -0
  49. package/src/core/failure-recovery.ts +173 -0
  50. package/src/core/file-precondition.ts +72 -0
  51. package/src/core/handoff.ts +65 -4
  52. package/src/core/history-query.ts +38 -2
  53. package/src/core/history.ts +38 -1
  54. package/src/core/integration-health.ts +34 -0
  55. package/src/core/keymap.ts +8 -20
  56. package/src/core/model-picker.ts +64 -0
  57. package/src/core/native-eval.ts +25 -0
  58. package/src/core/note-guidance.ts +34 -0
  59. package/src/core/observation-view.ts +49 -8
  60. package/src/core/permissions.ts +24 -2
  61. package/src/core/process-receipt.ts +105 -0
  62. package/src/core/process-state.ts +37 -0
  63. package/src/core/project.ts +45 -18
  64. package/src/core/prompt-policy.ts +37 -0
  65. package/src/core/query-eval-contract.ts +14 -0
  66. package/src/core/read-observation.ts +82 -0
  67. package/src/core/recovery-corpus.ts +17 -0
  68. package/src/core/recovery-evaluation.ts +60 -0
  69. package/src/core/recovery-sources.ts +74 -0
  70. package/src/core/runtime-target.ts +94 -0
  71. package/src/core/search-observation.ts +97 -0
  72. package/src/core/session-tree.ts +147 -0
  73. package/src/core/settings.ts +26 -10
  74. package/src/core/skills.ts +44 -0
  75. package/src/core/sql-query.ts +211 -0
  76. package/src/core/theme.ts +36 -58
  77. package/src/core/tool-contract.ts +51 -2
  78. package/src/core/tool-discovery.ts +87 -0
  79. package/src/core/tool-failure.ts +60 -0
  80. package/src/core/tool-mode-protocol.ts +25 -0
  81. package/src/core/transcript.ts +19 -4
  82. package/src/core/trial-lifecycle.ts +14 -0
  83. package/src/core/tui-mode.ts +29 -0
  84. package/src/core/update-check.ts +97 -0
  85. package/src/core/version.ts +55 -0
  86. package/src/core/work-state.ts +49 -3
  87. package/src/core/working-set.ts +91 -0
  88. package/src/runtime/auth.ts +2 -2
  89. package/src/runtime/branch-jobs.ts +36 -0
  90. package/src/runtime/clipboard.ts +15 -4
  91. package/src/runtime/command-artifacts.ts +64 -0
  92. package/src/runtime/environment-tools.ts +60 -0
  93. package/src/runtime/evidence-notes.ts +4 -2
  94. package/src/runtime/exec-sessions.ts +206 -61
  95. package/src/runtime/execution-control.ts +1 -0
  96. package/src/runtime/extension-ui.ts +22 -30
  97. package/src/runtime/extensions.ts +95 -16
  98. package/src/runtime/harness-comparison.ts +27 -7
  99. package/src/runtime/harness-eval.ts +460 -0
  100. package/src/runtime/history-cache.ts +57 -0
  101. package/src/runtime/history-tools.ts +62 -12
  102. package/src/runtime/integrations.ts +210 -0
  103. package/src/runtime/loop.ts +1021 -268
  104. package/src/runtime/model-discovery.ts +208 -0
  105. package/src/runtime/native-eval-fixtures.ts +33 -0
  106. package/src/runtime/native-eval.ts +143 -0
  107. package/src/runtime/oauth.ts +1 -1
  108. package/src/runtime/provider-connect.ts +109 -0
  109. package/src/runtime/provider-presets.ts +87 -0
  110. package/src/runtime/query-eval-verifier.ts +28 -0
  111. package/src/runtime/recovery-coverage.ts +71 -0
  112. package/src/runtime/recovery-evidence.ts +76 -0
  113. package/src/runtime/recovery-fixtures.ts +116 -0
  114. package/src/runtime/runtime-target.ts +149 -0
  115. package/src/runtime/tool-broker.ts +137 -0
  116. package/src/runtime/tool-discovery.ts +57 -0
  117. package/src/runtime/tool-mode-eval.ts +146 -0
  118. package/src/runtime/tool-queue.ts +77 -0
  119. package/src/runtime/tools.ts +628 -183
  120. package/src/runtime/trial-control.ts +45 -0
  121. package/src/runtime/update-check.ts +47 -0
  122. package/src/runtime/window-trial.ts +2 -1
  123. package/src/runtime/working-notes.ts +33 -3
  124. package/src/runtime/workspace-lifecycle.ts +97 -0
  125. package/src/store/acceptance.ts +15 -0
  126. package/src/store/checkpoint-history.ts +43 -0
  127. package/src/store/command-artifacts.ts +96 -0
  128. package/src/store/command.ts +65 -37
  129. package/src/store/file-checkpoints.ts +104 -0
  130. package/src/store/file-index.ts +13 -1
  131. package/src/store/git-checkpoints.ts +67 -0
  132. package/src/store/observations.ts +61 -20
  133. package/src/store/process-receipts.ts +34 -0
  134. package/src/store/query-source.ts +13 -8
  135. package/src/store/recovery.ts +2 -3
  136. package/src/store/replay.ts +16 -4
  137. package/src/store/runtime-target.ts +64 -0
  138. package/src/store/search.ts +171 -132
  139. package/src/store/session-archive.ts +4 -0
  140. package/src/store/source-cancellation.ts +6 -0
  141. package/src/store/update-cache.ts +36 -0
  142. package/src/store/workspace-observation.ts +113 -0
  143. package/src/tui/ansi-spans.ts +126 -0
  144. package/src/tui/style.ts +19 -16
  145. package/src/tui/ui/atomic-draft.ts +121 -0
  146. package/src/tui/ui/canvas.ts +96 -0
  147. package/src/tui/ui/colors.ts +70 -0
  148. package/src/tui/ui/composer.ts +199 -0
  149. package/src/tui/ui/dialog.ts +379 -0
  150. package/src/tui/ui/draft.ts +23 -0
  151. package/src/tui/ui/edge.ts +186 -0
  152. package/src/tui/ui/image-size.ts +40 -0
  153. package/src/tui/ui/ink.ts +210 -0
  154. package/src/tui/ui/items.ts +40 -0
  155. package/src/tui/ui/keys.ts +40 -0
  156. package/src/tui/ui/layout.ts +74 -0
  157. package/src/tui/ui/list-model.ts +86 -0
  158. package/src/tui/ui/list-view.ts +172 -0
  159. package/src/tui/ui/mascot.ts +67 -0
  160. package/src/tui/ui/messages.ts +518 -0
  161. package/src/tui/ui/motion.ts +28 -0
  162. package/src/tui/ui/paths.ts +10 -0
  163. package/src/tui/ui/pictures.ts +78 -0
  164. package/src/tui/ui/screen.ts +421 -0
  165. package/src/tui/ui/status.ts +271 -0
  166. package/src/tui/ui/styled.ts +85 -0
  167. package/src/tui/ui/testing.ts +5 -0
  168. package/src/tui/ui/tool-card.ts +150 -0
  169. package/src/apps/extension-ui.ts +0 -330
  170. package/src/apps/resource-browser.ts +0 -75
  171. package/src/apps/session-browser.ts +0 -113
  172. package/src/apps/session-picker.ts +0 -55
  173. package/src/tui/ansi.ts +0 -67
  174. package/src/tui/autocomplete.ts +0 -29
  175. package/src/tui/chat-viewport.ts +0 -47
  176. package/src/tui/complete.ts +0 -87
  177. package/src/tui/editor.ts +0 -940
  178. package/src/tui/host.ts +0 -264
  179. package/src/tui/input.ts +0 -75
  180. package/src/tui/keys.ts +0 -378
  181. package/src/tui/markdown.ts +0 -17
  182. package/src/tui/render.ts +0 -153
  183. package/src/tui/select.ts +0 -258
  184. package/src/tui/settings.ts +0 -44
  185. package/src/tui/statusline.ts +0 -162
  186. package/src/tui/system-theme.ts +0 -700
  187. package/src/tui/terminal.ts +0 -30
  188. package/src/tui/text.ts +0 -52
  189. package/src/tui/toolview.ts +0 -386
  190. package/src/tui/transcript.ts +0 -123
  191. package/src/tui/tree.ts +0 -471
  192. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  193. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  194. package/src/vendor/pi-tui/VENDOR.md +0 -25
  195. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  196. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  197. package/src/vendor/pi-tui/colors.ts +0 -367
  198. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  199. package/src/vendor/pi-tui/components/box.ts +0 -166
  200. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  201. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  202. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  203. package/src/vendor/pi-tui/components/image.ts +0 -127
  204. package/src/vendor/pi-tui/components/input.ts +0 -494
  205. package/src/vendor/pi-tui/components/loader.ts +0 -101
  206. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  207. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  208. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  209. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  210. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  211. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  212. package/src/vendor/pi-tui/components/stack.ts +0 -154
  213. package/src/vendor/pi-tui/components/text.ts +0 -107
  214. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  215. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  216. package/src/vendor/pi-tui/editor-component.ts +0 -74
  217. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  218. package/src/vendor/pi-tui/index.ts +0 -186
  219. package/src/vendor/pi-tui/keybindings.ts +0 -320
  220. package/src/vendor/pi-tui/keys.ts +0 -1401
  221. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  222. package/src/vendor/pi-tui/latex.ts +0 -1506
  223. package/src/vendor/pi-tui/layout-node.ts +0 -51
  224. package/src/vendor/pi-tui/layout.ts +0 -449
  225. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  226. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  227. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  228. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  229. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  232. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  233. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  234. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  237. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  238. package/src/vendor/pi-tui/native/napi.h +0 -91
  239. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  240. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  241. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  244. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  245. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  246. package/src/vendor/pi-tui/native-platform.ts +0 -65
  247. package/src/vendor/pi-tui/oklab.ts +0 -233
  248. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  249. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  250. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  251. package/src/vendor/pi-tui/terminal.ts +0 -554
  252. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  253. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  254. package/src/vendor/pi-tui/tui.ts +0 -1493
  255. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  256. package/src/vendor/pi-tui/utils.ts +0 -1385
  257. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  258. package/src/vendor/pi-tui/word-navigation.ts +0 -117
package/src/apps/shell.ts CHANGED
@@ -1,34 +1,35 @@
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 type { RestorePoint, RewindMode } from "../runtime/loop.ts";
32
33
  import { createProvider, 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";
@@ -46,81 +47,57 @@ 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 { COMMANDS, parseCommand, type Command } from "./commands.ts";
74
+ import { expandInput } from "./input.ts";
101
75
  import {
102
76
  describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
103
77
  } from "./render.ts";
104
78
  import {
105
- openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, visibleSchemas, type App,
79
+ openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
106
80
  } from "./startup.ts";
107
- import { SessionBrowser } from "./session-browser.ts";
81
+ import type { TurnSurface, Ui } from "./ui/surface.ts";
108
82
 
109
- const HINT = `type a prompt, or ${COMMANDS.map((command) => `/${command.name}`).join(" ")}`;
83
+ /**
84
+ * One line. Listing all ~40 commands here wrapped into a two-line wall that
85
+ * nobody reads; typing `/` already opens the searchable command menu.
86
+ */
87
+ const HINT = `type a prompt · / for ${COMMANDS.length} commands · /login connects a provider · /model switches · /help`;
110
88
  const OAUTH_PASTE = "\0oauth";
111
- const OAUTH_DEVICE = "\0oauth-device";
112
89
 
113
90
  const initializedExtensions = new WeakSet<ExtensionRegistry>();
114
91
  const extensionHistories = new WeakMap<App, LogEvent[]>();
115
- function extensionContext(app: App, tui?: Tui): ExtensionContext {
92
+ function extensionContext(app: App, tui?: Ui): ExtensionContext {
116
93
  const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
117
94
  return {
118
- ui: tui?.extensionUi.context ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
95
+ ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
119
96
  cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
120
97
  model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
121
98
  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(); },
99
+ hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
100
+ abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
124
101
  getSystemPrompt: () => app.session.config.frozen.system,
125
102
  getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
126
103
  waitForIdle: () => app.session.whenIdle(),
@@ -133,8 +110,8 @@ function extensionContext(app: App, tui?: Tui): ExtensionContext {
133
110
  };
134
111
  }
135
112
 
136
- async function bindExtensions(app: App, tui?: Tui): Promise<void> {
137
- await tui?.extensionUi.bind(app.extensions, app.resources);
113
+ async function bindExtensions(app: App, tui?: Ui): Promise<void> {
114
+ tui?.bindExtensionRegistry();
138
115
  const registry = app.extensions; if (!registry) return;
139
116
  const history = await readNodeHistory(app.logDir, app.session.state.node);
140
117
  if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
@@ -148,29 +125,28 @@ async function bindExtensions(app: App, tui?: Tui): Promise<void> {
148
125
  user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
149
126
  setName: name => nameSession(app, name, tui),
150
127
  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 })),
128
+ getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
129
+ "name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
130
+ ? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
131
+ getAllTools: () => {
132
+ const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
133
+ return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
134
+ .map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
135
+ },
152
136
  setTools: async names => {
153
137
  if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
154
138
  const args = { ...app.args, tools: names, noTools: false };
155
- const tools = visibleSchemas(args, registry);
139
+ const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
156
140
  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;
141
+ await app.session.reloadResources({ ...app.session.config.frozen, tools });
142
+ app.session.config.tools.deferredTools = deferred; app.args = args;
158
143
  },
159
144
  setModel: async model => {
160
145
  if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
161
146
  await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
162
147
  },
163
148
  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
- },
149
+ exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
174
150
  });
175
151
  if (!initializedExtensions.has(registry)) {
176
152
  initializedExtensions.add(registry);
@@ -187,31 +163,40 @@ function parseLine(app: App, text: string): Command {
187
163
 
188
164
  /**
189
165
  * 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.
166
+ * this same set -- a second estimate would disagree with the actual cut. Policy,
167
+ * window and reserve have no defaults: a default is how the footer came to
168
+ * measure against a boundary the active policy never consults.
191
169
  */
192
170
  export interface BudgetInput {
171
+ readonly logHint?: { manifestPath: string; segmentPath: string };
193
172
  readonly frozen: FrozenPrefix;
194
173
  readonly handoff: Handoff | null;
195
174
  readonly events: readonly LogEvent[];
196
175
  readonly anchor: Anchor | null;
197
176
  readonly counter?: TokenCounter;
198
177
  readonly boundary?: Partial<BoundaryConfig>;
199
- /** maybeCut only runs at a turn boundary; the prompt is that boundary. */
200
- readonly atTurnBoundary?: boolean;
178
+ readonly policy: ContextPolicy;
179
+ readonly contextWindow: number | undefined;
180
+ readonly outputReserve: number;
201
181
  }
202
182
 
203
183
  export interface ContextBudget {
204
184
  readonly used: number;
205
- readonly trigger: number;
185
+ /** Nothing the provider reported backs the count: before the first response, after a cut. */
186
+ readonly estimated: boolean;
187
+ /** Where the active policy next acts; null when it names no boundary. */
188
+ readonly trigger: number | null;
206
189
  readonly inherited: number;
207
- readonly decision: BoundaryDecision;
190
+ readonly limits: ContextLimits;
191
+ readonly decision: ContextDecision;
192
+ readonly level: GaugeLevel;
208
193
  }
209
194
 
210
195
  /**
211
196
  * The numbers the status gauge shows, computed the same way maybeCut does:
212
197
  * 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.
198
+ * node, contextLimits and decideContext for what the next turn boundary does.
199
+ * The TUI must not recompute the work floor.
215
200
  */
216
201
  export function contextBudget(input: BudgetInput): ContextBudget {
217
202
  const counter = input.counter ?? estimateTokens;
@@ -219,8 +204,9 @@ export function contextBudget(input: BudgetInput): ContextBudget {
219
204
  frozen: input.frozen,
220
205
  handoff: input.handoff,
221
206
  events: input.events,
207
+ ...(input.logHint ? { logHint: input.logHint } : {}),
222
208
  });
223
- const { tokens } = anchoredCount(
209
+ const { tokens, exactUpTo } = anchoredCount(
224
210
  projected.messages,
225
211
  projected.system,
226
212
  input.anchor,
@@ -231,16 +217,38 @@ export function contextBudget(input: BudgetInput): ContextBudget {
231
217
  frozen: input.frozen,
232
218
  handoff: input.handoff,
233
219
  events: [],
220
+ ...(input.logHint ? { logHint: input.logHint } : {}),
234
221
  });
235
222
  const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
236
- const cfg = { ...DEFAULT_BOUNDARY, ...input.boundary };
237
- const decision = shouldCut({
223
+ const limits = contextLimits({
224
+ policy: input.policy,
225
+ contextWindow: input.contextWindow,
226
+ outputReserve: input.outputReserve,
227
+ ...(input.boundary ? { boundary: input.boundary } : {}),
228
+ });
229
+ // The footer asks what the next turn boundary does, even mid-turn. Readiness
230
+ // and price are not known here, so adaptive reads as deferrable at its soft
231
+ // trigger and certain only at the hard limit.
232
+ const decision = decideContext({
233
+ policy: input.policy,
234
+ continuationReady: false,
235
+ cost: null,
238
236
  tokens,
239
237
  inheritedTokens: inherited,
240
- atTurnBoundary: input.atTurnBoundary ?? true,
238
+ atTurnBoundary: true,
239
+ ...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
240
+ outputReserve: input.outputReserve,
241
241
  ...(input.boundary ? { config: input.boundary } : {}),
242
242
  });
243
- return { used: tokens, trigger: cfg.triggerTokens, inherited, decision };
243
+ return {
244
+ used: tokens,
245
+ estimated: exactUpTo === 0,
246
+ trigger: nextBoundary(limits),
247
+ inherited,
248
+ limits,
249
+ decision,
250
+ level: gaugeLevel(tokens, limits, decision),
251
+ };
244
252
  }
245
253
 
246
254
  export function resolveEditorCommand(
@@ -315,9 +323,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
315
323
 
316
324
  export async function runPrompt(app: App, prompt: string): Promise<void> {
317
325
  await bindExtensions(app);
318
- if (app.shutdownRequested) { await app.extensions?.dispose(); return; }
319
- const ok = await run(app, prompt);
320
- await app.extensions?.dispose();
326
+ if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
327
+ let ok: boolean;
328
+ try { ok = await run(app, prompt); }
329
+ finally { await app.session.dispose(); await app.extensions?.dispose(); }
321
330
  printUsage(app);
322
331
  // One-shot mode is scriptable, so a failed turn has to be a failed exit.
323
332
  if (!ok) process.exit(1);
@@ -366,72 +375,54 @@ async function runLines(app: App): Promise<void> {
366
375
  if (typeof outcome === "object") awaitingKeyFor = outcome.login;
367
376
  }
368
377
  await cancelPendingOAuth(app);
378
+ await app.session.dispose();
369
379
  await app.extensions?.dispose();
370
380
  printUsage(app);
371
381
  }
372
382
 
373
383
  async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
374
- const C = app.io.palette;
375
384
  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
- }
385
+ // Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
386
+ if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
387
+ const { runInteractive } = await import("./ui/app.ts");
388
+ await runInteractive(app, interactiveHooks, releaseEarlyTty);
389
+ printUsage(app);
430
390
  }
431
391
 
392
+ /**
393
+ * The business operations the interactive controller drives. Exported as one
394
+ * object so apps/ui/app.ts depends on shell.ts in one direction only and the
395
+ * set of things the UI can do is visible in one place.
396
+ */
397
+ export const interactiveHooks = {
398
+ handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
399
+ run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
400
+ parseLine: (app: App, text: string) => parseLine(app, text),
401
+ bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
402
+ extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
403
+ cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
404
+ switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
405
+ applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
406
+ cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
407
+ cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
408
+ commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
409
+ startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
410
+ login: (app: App, provider: string, key: string) => login(app, provider, key),
411
+ logout: (app: App, provider: string) => logout(app, provider),
412
+ resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
413
+ openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
414
+ forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
415
+ trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
416
+ treeNodesFor: (app: App) => treeNodesFor(app),
417
+ copyLastReply: (app: App) => copyLastReply(app),
418
+ effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
419
+ fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
420
+ dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
421
+ };
422
+
432
423
  type Outcome = "continue" | "quit" | { login: string };
433
424
 
434
- async function execute(app: App, command: Command, tui: Tui | null): Promise<Outcome> {
425
+ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
435
426
  switch (command.kind) {
436
427
  case "extension":
437
428
  if (tui) tui.runExtensionCommand(command.name, command.args);
@@ -449,6 +440,35 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
449
440
  case "state":
450
441
  printState(app);
451
442
  return "continue";
443
+ case "context":
444
+ printContext(app);
445
+ return "continue";
446
+ case "acceptance":
447
+ await printAcceptance(app);
448
+ return "continue";
449
+ case "tasks": {
450
+ const items = await app.session.workItems();
451
+ app.io.say(`\nwork tasks: ${items.length}`);
452
+ for (const item of items) {
453
+ const ready = workReadiness(items, item.id);
454
+ app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
455
+ if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
456
+ }
457
+ return "continue";
458
+ }
459
+ case "branches": {
460
+ if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
461
+ 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}` : ""}`);
462
+ return "continue";
463
+ }
464
+ case "processes": {
465
+ 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}` : ""}`);
466
+ return "continue";
467
+ }
468
+ case "accept":
469
+ await app.session.acceptCriterion(command.criterion);
470
+ await printAcceptance(app);
471
+ return "continue";
452
472
  case "help":
453
473
  printHelp(app);
454
474
  return "continue";
@@ -464,7 +484,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
464
484
  return "continue";
465
485
  }
466
486
  case "config":
467
- if (tui) await tui.openResourceSelect();
487
+ if (tui) await tui.openResources();
468
488
  else {
469
489
  const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
470
490
  for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
@@ -479,7 +499,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
479
499
  return "continue";
480
500
  }
481
501
  if (tui) {
482
- tui.openModelSelect();
502
+ tui.openModel();
483
503
  return "continue";
484
504
  }
485
505
  printModels(app);
@@ -492,7 +512,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
492
512
  return applyThinkingCommand(app, command.level, tui);
493
513
  case "login": {
494
514
  if (command.provider === undefined) {
495
- if (tui) { tui.openLoginSelect(); return "continue"; }
515
+ if (tui) { tui.openLogin(); return "continue"; }
496
516
  const C = app.io.palette;
497
517
  const providers = [...new Set(app.catalog.map((m) => m.provider))];
498
518
  app.io.say(C.bold("\nlogin"));
@@ -511,16 +531,22 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
511
531
  if (isSubscriptionLogin(command.provider)) {
512
532
  return startSubscriptionLogin(app, command.provider, tui);
513
533
  }
534
+ if (tui) {
535
+ const provider = command.provider;
536
+ const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
537
+ if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
538
+ return "continue";
539
+ }
514
540
  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.
541
+ // Line mode echoes nothing for this line; the sentence must say so,
542
+ // because "it will be visible here" is how a secret ends up in a screenshot.
517
543
  app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
518
544
  app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
519
545
  app.io.say(C.dim(" empty line cancels."));
520
546
  return { login: command.provider };
521
547
  }
522
548
  case "logout": {
523
- if (tui && command.provider === undefined) { tui.openLogoutSelect(); return "continue"; }
549
+ if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
524
550
  await logout(app, command.provider);
525
551
  return "continue";
526
552
  }
@@ -539,7 +565,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
539
565
  }
540
566
  case "tree":
541
567
  if (tui) {
542
- await tui.openTreeSelect();
568
+ await tui.openTree();
543
569
  return "continue";
544
570
  }
545
571
  await printTree(app);
@@ -553,7 +579,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
553
579
  return "continue";
554
580
  }
555
581
  if (tui) {
556
- void tui.openResumeSelect();
582
+ void tui.openResume();
557
583
  return "continue";
558
584
  }
559
585
  await printSessionList(app);
@@ -567,7 +593,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
567
593
  case "import": {
568
594
  if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
569
595
  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; }
596
+ 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
597
  try {
572
598
  const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
573
599
  if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
@@ -578,25 +604,25 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
578
604
  }
579
605
  case "debug": {
580
606
  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) ?? [];
607
+ const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
608
+ const { width, height, lines } = frame;
583
609
  const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
584
610
  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");
611
+ ...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
586
612
  try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
587
613
  catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
588
614
  return "continue";
589
615
  }
590
616
  case "changelog": {
591
617
  const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
592
- if (tui) tui.showDocument(text); else app.io.say(text);
618
+ if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
593
619
  return "continue";
594
620
  }
595
621
  case "copy":
596
622
  await copyLastReply(app, tui ?? undefined);
597
623
  return "continue";
598
624
  case "fork":
599
- if (tui && command.arg === undefined) { await tui.openForkSelect(); return "continue"; }
625
+ if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
600
626
  await forkSession(app, command.arg, tui ?? undefined);
601
627
  return "continue";
602
628
  case "clone":
@@ -605,7 +631,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
605
631
  return "continue";
606
632
  case "trust":
607
633
  if (command.target) await trustProject(app, command.target, tui ?? undefined);
608
- else if (tui) await tui.openTrustSelect();
634
+ else if (tui) await tui.openTrust();
609
635
  else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
610
636
  return "continue";
611
637
  case "models-refresh":
@@ -615,7 +641,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
615
641
  app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
616
642
  return "continue";
617
643
  case "prompt":
618
- await run(app, command.text, tui?.signal, tui ?? undefined);
644
+ await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
619
645
  app.io.say("");
620
646
  return "continue";
621
647
  default:
@@ -623,33 +649,8 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
623
649
  }
624
650
  }
625
651
 
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;
652
+ async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
653
+ return execute(app, parseLine(app, text), tui);
653
654
  }
654
655
 
655
656
  function printUsage(app: App): void {
@@ -724,6 +725,43 @@ function printState(app: App): void {
724
725
  app.io.say("");
725
726
  }
726
727
 
728
+ function printContext(app: App): void {
729
+ const session = app.session;
730
+ const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
731
+ policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
732
+ contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
733
+ ...(session.config.counter ? { counter: session.config.counter } : {}),
734
+ ...(session.config.boundary ? { boundary: session.config.boundary } : {}),
735
+ });
736
+ app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
737
+ for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
738
+ app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
739
+ app.io.say(` limits ${JSON.stringify(detail.limits)}`);
740
+ const sources = detail.sources.flat();
741
+ app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
742
+ if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
743
+ for (const section of detail.sections.filter(s => s.omitted > 0)) {
744
+ app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
745
+ }
746
+ app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
747
+ if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
748
+ app.io.say("");
749
+ }
750
+
751
+ async function printAcceptance(app: App): Promise<void> {
752
+ const state = await app.session.acceptanceState();
753
+ app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
754
+ for (const target of state?.targets ?? []) {
755
+ 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"}`);
756
+ 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"}`);
757
+ }
758
+ for (const item of state?.criteria ?? []) {
759
+ app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
760
+ if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
761
+ else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
762
+ }
763
+ }
764
+
727
765
  function printHotkeys(app: App): void {
728
766
  const C = app.io.palette;
729
767
  const map = app.ui?.keymap ?? defaultKeymap();
@@ -740,7 +778,7 @@ async function applySettingsCommand(
740
778
  app: App,
741
779
  field: string | undefined,
742
780
  value: string | undefined,
743
- tui: Tui | null,
781
+ tui: Ui | null,
744
782
  ): Promise<Outcome> {
745
783
  if (field !== undefined && value !== undefined) {
746
784
  await commitSetting(app, field, value, tui);
@@ -759,7 +797,7 @@ async function applySettingsCommand(
759
797
  return "continue";
760
798
  }
761
799
 
762
- async function commitSetting(app: App, field: string, value: string, tui: Tui | null): Promise<void> {
800
+ async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
763
801
  const C = app.io.palette;
764
802
  const key = field.toLowerCase();
765
803
  if (app.ui === undefined) {
@@ -783,10 +821,11 @@ async function commitSetting(app: App, field: string, value: string, tui: Tui |
783
821
  app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
784
822
  app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
785
823
  if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
786
- app.io.say(C.dim(` ${field} ${value}`));
824
+ if (tui) tui.toast(`${field} · ${value}`, "success");
825
+ else app.io.say(C.dim(` ${field} ${value}`));
787
826
  tui?.applyConfiguration();
788
827
  if (key === "theme") tui?.followTheme();
789
- tui?.paint();
828
+ tui?.redraw();
790
829
  }
791
830
 
792
831
  const EFFORTS = THINKING_LEVELS;
@@ -801,7 +840,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
801
840
  * must not cut: a cut drops the prefix cache, and thinking blocks stay
802
841
  * valid because the model did not change.
803
842
  */
804
- async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<boolean> {
843
+ async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
805
844
  const C = app.io.palette;
806
845
  if (!effortSupported(app.providerConfig.spec, level)) {
807
846
  app.io.say(
@@ -820,11 +859,11 @@ async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<
820
859
  app.ui.settings = { ...app.ui.settings, thinking: level };
821
860
  }
822
861
  app.io.say(C.dim(` thinking ${level}`));
823
- tui?.paint();
862
+ tui?.redraw();
824
863
  return true;
825
864
  }
826
865
 
827
- async function applyThinkingCommand(app: App, level: string | undefined, tui: Tui | null): Promise<Outcome> {
866
+ async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
828
867
  if (level !== undefined) {
829
868
  const effort = asEffort(level);
830
869
  if (effort === undefined) {
@@ -835,7 +874,7 @@ async function applyThinkingCommand(app: App, level: string | undefined, tui: Tu
835
874
  return "continue";
836
875
  }
837
876
  if (tui) {
838
- tui.openThinkingSelect();
877
+ tui.openThinking();
839
878
  return "continue";
840
879
  }
841
880
  printThinking(app);
@@ -856,7 +895,7 @@ function printThinking(app: App): void {
856
895
  app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
857
896
  }
858
897
 
859
- async function cycleThinking(app: App, tui: Tui | null): Promise<void> {
898
+ async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
860
899
  const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
861
900
  const next = cycle(supported, app.providerConfig.effort, 1);
862
901
  if (next === undefined) return;
@@ -977,10 +1016,11 @@ async function printBack(app: App): Promise<void> {
977
1016
  * Jumping still needs the TUI; this is so a pipe can see the structure.
978
1017
  */
979
1018
  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);
1019
+ const rows = flattenTree(await treeNodesFor(app));
1020
+ if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
1021
+ for (const row of rows) {
1022
+ const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
1023
+ app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
984
1024
  }
985
1025
  }
986
1026
 
@@ -1045,7 +1085,7 @@ async function printRewind(
1045
1085
  ): Promise<void> {
1046
1086
  const C = app.io.palette;
1047
1087
  const label = firstLine(point.prompt, 60);
1048
- const plan = await app.session.previewRewind(point);
1088
+ const plan = await app.session.previewRewind(point, mode);
1049
1089
  if (!plan.ok) {
1050
1090
  app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
1051
1091
  return;
@@ -1066,7 +1106,7 @@ async function printRewind(
1066
1106
  for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
1067
1107
  if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
1068
1108
  } else {
1069
- for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` keep ${p}`));
1109
+ for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
1070
1110
  if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
1071
1111
  for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
1072
1112
  if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
@@ -1216,13 +1256,15 @@ interface PendingOAuth {
1216
1256
  exchange: "anthropic" | "openai";
1217
1257
  server: CallbackServer;
1218
1258
  done: boolean;
1219
- tui: Tui | null;
1259
+ tui: Ui | null;
1220
1260
  abort: AbortController;
1261
+ /** Closes the UI's waiting dialog, if any. */
1262
+ close?: () => void;
1221
1263
  }
1222
1264
 
1223
1265
  // Authentication is scoped to its application, independent of the model/session log.
1224
1266
  const pendingLogins = new WeakMap<App, PendingOAuth>();
1225
- const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Tui | null }>();
1267
+ const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
1226
1268
  const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
1227
1269
  openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
1228
1270
 
@@ -1230,8 +1272,7 @@ async function cancelPendingOAuth(app: App): Promise<void> {
1230
1272
  const pending = pendingLogins.get(app), device = pendingDevices.get(app);
1231
1273
  pendingLogins.delete(app); pendingDevices.delete(app);
1232
1274
  device?.abort.abort(); pending?.abort.abort();
1233
- const tui = pending?.tui ?? device?.tui;
1234
- tui?.endCredentialInput();
1275
+ pending?.close?.(); device?.close?.();
1235
1276
  if (pending) { pending.done = true; await pending.server.close(); }
1236
1277
  }
1237
1278
 
@@ -1282,7 +1323,7 @@ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): P
1282
1323
  } finally {
1283
1324
  if (pendingLogins.get(app) === pending) {
1284
1325
  pendingLogins.delete(app);
1285
- pending.tui?.endCredentialInput();
1326
+ pending.close?.();
1286
1327
  }
1287
1328
  await pending.server.close();
1288
1329
  }
@@ -1313,7 +1354,7 @@ function tryOpenBrowser(url: string): void {
1313
1354
  }
1314
1355
  }
1315
1356
 
1316
- async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Promise<Outcome> {
1357
+ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
1317
1358
  const C = app.io.palette;
1318
1359
  const sub = subscriptionById(id);
1319
1360
  if (sub === undefined) return "continue";
@@ -1321,11 +1362,15 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1321
1362
  const effects = tui?.oauthEffects ?? loginEffects;
1322
1363
  app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
1323
1364
  if (sub.id === "github-copilot") {
1324
- const pending = { abort: new AbortController(), tui };
1365
+ const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
1325
1366
  pendingDevices.set(app, pending);
1326
1367
  const work = async () => { try {
1327
1368
  const credential = await effects.device({
1328
- notify: (message) => { if (pendingDevices.get(app) === pending) app.io.say(` ${message}`); },
1369
+ notify: (message) => {
1370
+ if (pendingDevices.get(app) !== pending) return;
1371
+ if (tui) { pending.close?.(); pending.close = tui.waiting("GitHub Copilot", message.split("\n"), () => { void cancelPendingOAuth(app); }); }
1372
+ else app.io.say(` ${message}`);
1373
+ },
1329
1374
  prompt: async () => "",
1330
1375
  fetch: effects.fetch,
1331
1376
  signal: pending.abort.signal,
@@ -1339,10 +1384,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1339
1384
  } finally {
1340
1385
  if (pendingDevices.get(app) === pending) {
1341
1386
  pendingDevices.delete(app);
1342
- tui?.endCredentialInput();
1387
+ pending.close?.();
1343
1388
  }
1344
1389
  } };
1345
- if (tui) { tui.beginCredentialInput(OAUTH_DEVICE); tui.runExtensionAction(work); return "continue"; }
1390
+ if (tui) { pending.close = tui.waiting("GitHub Copilot", ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
1346
1391
  await work();
1347
1392
  return "continue";
1348
1393
  }
@@ -1372,8 +1417,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1372
1417
  close: async () => {},
1373
1418
  };
1374
1419
  }
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."));
1420
+ if (!tui) {
1421
+ app.io.say(` ${url}`);
1422
+ app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
1423
+ }
1377
1424
  effects.openBrowser(url);
1378
1425
  const pending: PendingOAuth = {
1379
1426
  id: sub.id,
@@ -1392,6 +1439,12 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
1392
1439
  if (!got.ok || pending.done) return;
1393
1440
  await finishOAuthCode(app, pending, got.code);
1394
1441
  });
1442
+ if (tui) {
1443
+ // The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
1444
+ 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."],
1445
+ () => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
1446
+ return "continue";
1447
+ }
1395
1448
  return { login: OAUTH_PASTE };
1396
1449
  }
1397
1450
 
@@ -1452,7 +1505,7 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
1452
1505
  await switchToModel(app, selected.model, selected.thinking);
1453
1506
  }
1454
1507
 
1455
- async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
1508
+ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
1456
1509
  const C = app.io.palette;
1457
1510
  if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
1458
1511
  if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
@@ -1474,8 +1527,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
1474
1527
  thinkingDisplay: app.providerConfig.thinkingDisplay,
1475
1528
  };
1476
1529
  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."));
1530
+ app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
1479
1531
  try {
1480
1532
  await app.session.switchModel(next);
1481
1533
  } catch (err) {
@@ -1485,13 +1537,22 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
1485
1537
  app.providerConfig = nextConfig;
1486
1538
  app.credential = got;
1487
1539
  app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1540
+ await rememberRecentModel(app, spec);
1541
+ }
1542
+
1543
+ /** Recency feeds the picker's first section; a write failure only costs ordering. */
1544
+ async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
1545
+ if (!app.ui) return;
1546
+ const recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
1547
+ app.ui.settings = { ...app.ui.settings, recentModels };
1548
+ try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
1488
1549
  }
1489
1550
 
1490
1551
  function sessionDirOf(app: App): string | undefined {
1491
1552
  return sessionDirFor(app.args, app.workspace);
1492
1553
  }
1493
1554
 
1494
- async function openNewSession(app: App, tui?: Tui): Promise<void> {
1555
+ async function openNewSession(app: App, tui?: Ui): Promise<void> {
1495
1556
  const C = app.io.palette;
1496
1557
  const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1497
1558
  const switched = await adoptLogDir(app, created.path);
@@ -1503,7 +1564,7 @@ async function openNewSession(app: App, tui?: Tui): Promise<void> {
1503
1564
  app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
1504
1565
  }
1505
1566
 
1506
- async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<void> {
1567
+ async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
1507
1568
  const C = app.io.palette;
1508
1569
  const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
1509
1570
  if (!found.ok) {
@@ -1525,7 +1586,10 @@ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<voi
1525
1586
 
1526
1587
  async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
1527
1588
  try {
1589
+ app.session.cancel(); await app.session.whenIdle();
1590
+ 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
1591
  const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
1592
+ await app.session.dispose();
1529
1593
  await app.extensions?.dispose();
1530
1594
  app.session = opened.session;
1531
1595
  app.providerConfig = opened.providerConfig;
@@ -1574,7 +1638,7 @@ async function exportSession(app: App, file?: string): Promise<void> {
1574
1638
  app.io.say(C.green(` exported ${abs}`));
1575
1639
  }
1576
1640
 
1577
- async function copyLastReply(app: App, tui?: Tui): Promise<void> {
1641
+ async function copyLastReply(app: App, tui?: Ui): Promise<void> {
1578
1642
  const C = app.io.palette;
1579
1643
  const text = lastAssistantText(await transcriptEvents(app));
1580
1644
  if (text === null) {
@@ -1590,7 +1654,7 @@ async function copyLastReply(app: App, tui?: Tui): Promise<void> {
1590
1654
  app.io.say(text);
1591
1655
  }
1592
1656
 
1593
- async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
1657
+ async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
1594
1658
  const C = app.io.palette;
1595
1659
  const points = await app.session.userMessages();
1596
1660
  if (arg === undefined) {
@@ -1616,7 +1680,7 @@ async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
1616
1680
  if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
1617
1681
  await tui?.syncTranscript();
1618
1682
  tui?.restoreDraft(point.text, point.images ?? []);
1619
- tui?.paint();
1683
+ tui?.redraw();
1620
1684
  }
1621
1685
  }
1622
1686
 
@@ -1639,7 +1703,7 @@ async function spawnForkedSession(app: App, untilEvent: string | undefined, labe
1639
1703
  return true;
1640
1704
  }
1641
1705
 
1642
- async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<void> {
1706
+ async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
1643
1707
  const C = app.io.palette;
1644
1708
  const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
1645
1709
  const trusted = await applyTrustChoice(app.workspace, target);
@@ -1655,7 +1719,7 @@ async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<v
1655
1719
  await tui?.refreshResources();
1656
1720
  }
1657
1721
 
1658
- async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
1722
+ async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
1659
1723
  const C = app.io.palette;
1660
1724
  if (name.length === 0) {
1661
1725
  app.io.say(C.dim(" /name <name>"));
@@ -1675,7 +1739,7 @@ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
1675
1739
  }
1676
1740
 
1677
1741
  /** 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> {
1742
+ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
1679
1743
  if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
1680
1744
  for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
1681
1745
  if (result["action"] === "handled") return true;
@@ -1754,7 +1818,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1754
1818
  return false;
1755
1819
  }
1756
1820
  try {
1757
- await runTurn(app, input, signal, tui);
1821
+ await runTurn(app, input, signal, tui, skill);
1758
1822
  return true;
1759
1823
  } catch (error) {
1760
1824
  tui?.discardStream();
@@ -1768,28 +1832,14 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
1768
1832
  }
1769
1833
  }
1770
1834
 
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
- }
1835
+ export type { TurnSurface };
1787
1836
 
1788
1837
  export async function runTurn(
1789
1838
  app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
1790
1839
  prompt: UserInput,
1791
1840
  signal?: AbortSignal,
1792
1841
  tui?: TurnSurface,
1842
+ skill?: Skill,
1793
1843
  ): Promise<void> {
1794
1844
  // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1795
1845
  // deltas and discard are ignored, and the final block prints once.
@@ -1805,7 +1855,7 @@ export async function runTurn(
1805
1855
  }
1806
1856
  await registry?.emit("agent_start");
1807
1857
  await registry?.emit("turn_start", { turnIndex: 0 });
1808
- try { for await (const e of app.session.send(prompt, signal, pull)) {
1858
+ try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : app.session.send(prompt, signal, pull)) {
1809
1859
  if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
1810
1860
  if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
1811
1861
  if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
@@ -1825,7 +1875,7 @@ export async function runTurn(
1825
1875
  await tui.syncTranscript();
1826
1876
  if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
1827
1877
  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);
1878
+ 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
1879
  }
1830
1880
  }
1831
1881
  continue;
@@ -1853,14 +1903,6 @@ export async function runTurn(
1853
1903
  }
1854
1904
  if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
1855
1905
  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
1906
  if (e.kind === "assistant_text" && app.io.answer !== undefined) {
1865
1907
  app.io.answer(e.text ?? "");
1866
1908
  continue;
@@ -1868,7 +1910,7 @@ export async function runTurn(
1868
1910
  for (const line of formatEvent(
1869
1911
  e,
1870
1912
  app.io.palette,
1871
- usableColumns(process.stdout.columns),
1913
+ Math.max(20, (process.stdout.columns ?? 80) - 1),
1872
1914
  {},
1873
1915
  app.ui?.style,
1874
1916
  )) {
@@ -1880,1273 +1922,3 @@ export async function runTurn(
1880
1922
  // ---------------------------------------------------------------------------
1881
1923
  // TUI
1882
1924
  // ---------------------------------------------------------------------------
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
- }