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