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