@visual-z/xeno 0.8.4 → 0.9.1
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 +19 -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/tui/ui/mascot.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The empty-session greeting (docs/tasks/refactor-tui-dock-layout.md). Pure.
|
|
3
|
-
*
|
|
4
|
-
* ▝▖ ▗▘ xeno 0.2.1
|
|
5
|
-
* ▐█▙█▟█▌ DeepSeek V4 Pro · high
|
|
6
|
-
* ▀▘ ▝▀ ~/Projects/workspace/xeno-tui ⎇ main
|
|
7
|
-
*
|
|
8
|
-
* An original 7×3 glyph (xeno: the stranger) drawn with ordinary quadrant
|
|
9
|
-
* blocks, so no Nerd Font is needed. Its entrance plays once: antennae rise,
|
|
10
|
-
* wiggle, one blink, then rest. Every frame keeps the same cells, so the
|
|
11
|
-
* terminal never reflows and a selection is never disturbed. Claude Code's
|
|
12
|
-
* fullscreen welcome plays a 0.4–1.3 s entrance the same way; Codex and
|
|
13
|
-
* OpenCode both dropped animations that kept running.
|
|
14
|
-
*/
|
|
15
|
-
import type { Line } from "./status.ts";
|
|
16
|
-
|
|
17
|
-
export const MASCOT_WIDTH = 7;
|
|
18
|
-
|
|
19
|
-
const ANTENNAE_UP = "▝▖ ▗▘";
|
|
20
|
-
const ANTENNAE_IN = "▗▘ ▝▖";
|
|
21
|
-
const ANTENNAE_DOWN = " ▖ ▗ ";
|
|
22
|
-
const EYES_OPEN = "▐█▙█▟█▌";
|
|
23
|
-
const EYES_SHUT = "▐█████▌";
|
|
24
|
-
const FEET = " ▀▘ ▝▀ ";
|
|
25
|
-
|
|
26
|
-
/** The resting pose: what a still terminal and every frame after the entrance show. */
|
|
27
|
-
export const MASCOT: readonly string[] = [ANTENNAE_UP, EYES_OPEN, FEET];
|
|
28
|
-
|
|
29
|
-
/** 60 ms per frame: about 16 fps, smooth for blocky glyphs without a frame per paint. Assumed. */
|
|
30
|
-
export const MASCOT_FRAME_MS = 60;
|
|
31
|
-
|
|
32
|
-
/** Asleep, antennae rise, eyes open, wiggle, wiggle, blink, rest. 14 frames = 840 ms, under the 1 s the card allows. */
|
|
33
|
-
const ENTRANCE: readonly (readonly [string, string])[] = [
|
|
34
|
-
[ANTENNAE_DOWN, EYES_SHUT], [ANTENNAE_DOWN, EYES_SHUT], [ANTENNAE_UP, EYES_SHUT], [ANTENNAE_UP, EYES_OPEN],
|
|
35
|
-
[ANTENNAE_IN, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_IN, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
|
|
36
|
-
[ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_SHUT], [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
|
|
37
|
-
[ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
|
|
38
|
-
];
|
|
39
|
-
export const MASCOT_FRAMES = ENTRANCE.length;
|
|
40
|
-
|
|
41
|
-
/** Frame `n` of the entrance; anything past the end is the resting pose. */
|
|
42
|
-
export function mascotFrame(n: number): readonly string[] {
|
|
43
|
-
const pose = ENTRANCE[Math.max(0, Math.floor(n))];
|
|
44
|
-
return pose ? [pose[0], pose[1], FEET] : MASCOT;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface Greeting {
|
|
48
|
-
readonly version: string;
|
|
49
|
-
/** Model display name and thinking level, or null when no provider can authenticate. */
|
|
50
|
-
readonly model: string | null;
|
|
51
|
-
readonly thinking: string;
|
|
52
|
-
readonly workspace: string;
|
|
53
|
-
readonly branch: string | null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Three rows: mascot frame, a two-cell gap, then version / model / location. */
|
|
57
|
-
export function greetingLines(greeting: Greeting, frame: readonly string[] = MASCOT): Line[] {
|
|
58
|
-
const art = (row: number) => ({ text: frame[row] ?? "", role: "accent" as const });
|
|
59
|
-
const gap = { text: " ", role: "text" as const };
|
|
60
|
-
return [
|
|
61
|
-
[art(0), gap, { text: "xeno", role: "text", bold: true }, { text: ` ${greeting.version}`, role: "muted" }],
|
|
62
|
-
[art(1), gap, ...(greeting.model
|
|
63
|
-
? [{ text: greeting.model, role: "text" as const }, { text: ` · ${greeting.thinking}`, role: "muted" as const }]
|
|
64
|
-
: [{ text: "No provider connected yet · /provider", role: "warning" as const }])],
|
|
65
|
-
[art(2), gap, { text: greeting.workspace, role: "muted" }, ...(greeting.branch ? [{ text: ` ⎇ ${greeting.branch}`, role: "muted" as const }] : [])],
|
|
66
|
-
];
|
|
67
|
-
}
|
package/src/tui/ui/messages.ts
DELETED
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The message stream: a bottom-sticky ScrollBox holding a bounded window of
|
|
3
|
-
* item renderables (docs/tasks/refactor-tui-chrome-cleanup.md).
|
|
4
|
-
*
|
|
5
|
-
* Every mounted renderable costs layout and a tree walk on every frame, the
|
|
6
|
-
* spinner's included (OpenTUI 0.5.14 culls off-screen children from painting
|
|
7
|
-
* only), so a long session must not mount its whole history. The window is
|
|
8
|
-
* the last WINDOW items while following the bottom; reading upward pages
|
|
9
|
-
* PAGE more in at a time, never more than CAP, with the first visible item
|
|
10
|
-
* held in place. Renderables are caches of the projection: keyed by id,
|
|
11
|
-
* reused while `rev` is unchanged, destroyed (native memory included) when
|
|
12
|
-
* they leave the window.
|
|
13
|
-
*
|
|
14
|
-
* Reading position is the reader's: once they scroll up, stickiness drops
|
|
15
|
-
* and new content raises "↓ N new" instead of yanking the view. Returning
|
|
16
|
-
* to the bottom, by wheel or Ctrl+End, re-engages following.
|
|
17
|
-
*/
|
|
18
|
-
import {
|
|
19
|
-
BoxRenderable, MarkdownRenderable, ScrollBoxRenderable, SyntaxStyle, TextBufferRenderable, TextRenderable,
|
|
20
|
-
type CliRenderer, type Renderable, type RGBA,
|
|
21
|
-
} from "@opentui/core";
|
|
22
|
-
import { Canvas } from "./canvas.ts";
|
|
23
|
-
import type { UiColors } from "./colors.ts";
|
|
24
|
-
import { edgeLine, type Seg } from "./edge.ts";
|
|
25
|
-
import { assertNever } from "../exhaustive.ts";
|
|
26
|
-
import { unseen, type ImageView, type ViewItem } from "./items.ts";
|
|
27
|
-
import { DEFAULT_CELL, IMAGES_PER_ITEM, transcriptImageRows } from "./image-size.ts";
|
|
28
|
-
import { Pictures, type PictureSource } from "./pictures.ts";
|
|
29
|
-
import { ansiText, parts } from "./styled.ts";
|
|
30
|
-
|
|
31
|
-
export interface MessageFeatures {
|
|
32
|
-
/** Highlighted code block as ANSI lines, or undefined for the built-in renderer. */
|
|
33
|
-
codeBlock?(code: string, language: string | undefined, width: number): string[] | undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Without a source (tests, print mode) pictures are named, not drawn. */
|
|
37
|
-
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
|
|
38
|
-
|
|
39
|
-
interface Entry { rev: string; node: Renderable; item: ViewItem }
|
|
40
|
-
|
|
41
|
-
/** Items kept mounted while following the bottom: about three screens of a typical session. Measured basis in the card. */
|
|
42
|
-
export const WINDOW = 60;
|
|
43
|
-
/** Never more than this mounted, however far the reader pages. */
|
|
44
|
-
export const CAP = 80;
|
|
45
|
-
/** Items mounted per page when the reader reaches either end of the window. */
|
|
46
|
-
export const PAGE = 20;
|
|
47
|
-
/** Rows from the window's edge at which the next page loads. Assumed. */
|
|
48
|
-
const EDGE_ROWS = 2;
|
|
49
|
-
|
|
50
|
-
let serial = 0;
|
|
51
|
-
|
|
52
|
-
export class MessageList {
|
|
53
|
-
readonly scroll: ScrollBoxRenderable;
|
|
54
|
-
private readonly renderer: CliRenderer;
|
|
55
|
-
private colors: UiColors;
|
|
56
|
-
private syntax: SyntaxStyle;
|
|
57
|
-
private readonly features: MessageFeatures;
|
|
58
|
-
private readonly glyph: () => string;
|
|
59
|
-
private entries = new Map<string, Entry>();
|
|
60
|
-
private order: string[] = [];
|
|
61
|
-
private items: readonly ViewItem[] = [];
|
|
62
|
-
/** The mounted window over `items`: [start, end). */
|
|
63
|
-
private start = 0;
|
|
64
|
-
private end = 0;
|
|
65
|
-
private readonly earlier: Canvas;
|
|
66
|
-
private toolsExpanded = false;
|
|
67
|
-
private thinkingExpanded = false;
|
|
68
|
-
private waiting = false;
|
|
69
|
-
private picturesSource: PictureSource = NO_PICTURES;
|
|
70
|
-
private readonly toolOverrides = new Map<string, boolean>();
|
|
71
|
-
private followingBottom = true;
|
|
72
|
-
private paging = false;
|
|
73
|
-
private unreadCount = 0;
|
|
74
|
-
onUnread: ((count: number) => void) | undefined;
|
|
75
|
-
|
|
76
|
-
constructor(renderer: CliRenderer, colors: UiColors, features: MessageFeatures = {}, glyph: () => string = () => "●") {
|
|
77
|
-
this.renderer = renderer;
|
|
78
|
-
this.colors = colors;
|
|
79
|
-
this.features = features;
|
|
80
|
-
this.glyph = glyph;
|
|
81
|
-
this.syntax = syntaxStyle(colors);
|
|
82
|
-
this.scroll = new ScrollBoxRenderable(renderer, {
|
|
83
|
-
id: "messages", flexGrow: 1, flexShrink: 1, stickyScroll: true, stickyStart: "bottom",
|
|
84
|
-
// Content starts in the input box's prompt column, so a sent line does not jump sideways.
|
|
85
|
-
contentOptions: { flexDirection: "column", paddingLeft: 3, paddingRight: 3, paddingBottom: 1 },
|
|
86
|
-
scrollbarOptions: { trackOptions: { foregroundColor: colors.line, backgroundColor: "transparent" } },
|
|
87
|
-
});
|
|
88
|
-
this.earlier = new Canvas(renderer, { id: "messages-earlier", height: 1, visible: false, flexShrink: 0, colors,
|
|
89
|
-
onMouseDown: () => this.pageUp() });
|
|
90
|
-
this.scroll.content.add(this.earlier);
|
|
91
|
-
this.scroll.verticalScrollBar.on("change", () => this.trackPosition());
|
|
92
|
-
this.selectAnywhere();
|
|
93
|
-
// Hidden by default: the scroll position is already visible as "↓ N new" when it matters, and
|
|
94
|
-
// OpenTUI draws the thumb as a full-cell block, a heavy bar beside a quiet transcript.
|
|
95
|
-
this.setScrollbar("hidden");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* A drag may start anywhere in the transcript, as a terminal's own
|
|
100
|
-
* selection does: on padding, a glyph or the empty end of a row, not only on
|
|
101
|
-
* a word. OpenTUI starts a selection only on a selectable node under the
|
|
102
|
-
* pointer; presses elsewhere bubble here and start it on the content box,
|
|
103
|
-
* which owns no text, so the selection still collects only text nodes
|
|
104
|
-
* (feat-tui-copy-selection DoD 1).
|
|
105
|
-
*/
|
|
106
|
-
private selectAnywhere(): void {
|
|
107
|
-
const content = this.scroll.content;
|
|
108
|
-
content.selectable = true;
|
|
109
|
-
content.onMouseDown = event => {
|
|
110
|
-
if (event.button !== 0 || event.modifiers.ctrl || this.renderer.getSelection()?.isDragging) return;
|
|
111
|
-
this.renderer.startSelection(content, event.x, event.y);
|
|
112
|
-
// Otherwise the renderer clears any selection after an unhandled press.
|
|
113
|
-
event.preventDefault();
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** `auto` shows the bar only while the transcript overflows; `always` keeps it; `hidden` never draws it. */
|
|
118
|
-
setScrollbar(mode: "auto" | "always" | "hidden"): void {
|
|
119
|
-
const bar = this.scroll.verticalScrollBar;
|
|
120
|
-
if (mode === "auto") bar.resetVisibilityControl(); else bar.visible = mode === "always";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
get unread(): number { return this.unreadCount; }
|
|
124
|
-
get atBottom(): boolean { return this.followingBottom; }
|
|
125
|
-
/** How many items are mounted right now (tests, /debug). */
|
|
126
|
-
get mounted(): number { return this.order.length; }
|
|
127
|
-
get window(): { readonly start: number; readonly end: number; readonly total: number } { return { start: this.start, end: this.end, total: this.items.length }; }
|
|
128
|
-
/** The "↑ N earlier" row at the top of the window, or "" when everything from the start is mounted. */
|
|
129
|
-
get earlierLabel(): string { return this.earlier.visible ? this.earlier.text : ""; }
|
|
130
|
-
|
|
131
|
-
/** A tool is waiting for the user's approval: pending tools show ◌ instead of the spinner. */
|
|
132
|
-
setWaiting(waiting: boolean): void {
|
|
133
|
-
if (waiting === this.waiting) return;
|
|
134
|
-
this.waiting = waiting;
|
|
135
|
-
for (const entry of this.entries.values()) if (entry.item.kind === "tool" && entry.item.status === "pending") this.replace(entry.item);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
setColors(colors: UiColors): void {
|
|
139
|
-
this.colors = colors;
|
|
140
|
-
this.syntax = syntaxStyle(colors);
|
|
141
|
-
this.earlier.setColors(colors);
|
|
142
|
-
this.rebuild();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Folding changes only tool and thinking blocks; everything else, and the reading position, stays put. */
|
|
146
|
-
setExpanded(tools: boolean, thinking: boolean): void {
|
|
147
|
-
if (tools === this.toolsExpanded && thinking === this.thinkingExpanded) return;
|
|
148
|
-
const toolsChanged = tools !== this.toolsExpanded, thinkingChanged = thinking !== this.thinkingExpanded;
|
|
149
|
-
this.toolsExpanded = tools;
|
|
150
|
-
this.thinkingExpanded = thinking;
|
|
151
|
-
if (toolsChanged) this.toolOverrides.clear();
|
|
152
|
-
const following = this.followingBottom;
|
|
153
|
-
for (const entry of this.entries.values()) {
|
|
154
|
-
if ((toolsChanged && entry.item.kind === "tool") || (thinkingChanged && entry.item.kind === "thinking")) this.replace(entry.item);
|
|
155
|
-
}
|
|
156
|
-
if (following) this.stickToBottom();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
update(items: readonly ViewItem[]): void {
|
|
160
|
-
// Only a reader who has scrolled away needs to know what arrived (an O(n) diff otherwise wasted).
|
|
161
|
-
const missed = this.followingBottom ? undefined : unseen(this.items, items);
|
|
162
|
-
const previous = this.items, firstMounted = this.order[0];
|
|
163
|
-
this.items = items;
|
|
164
|
-
if (this.followingBottom) { this.end = items.length; this.start = Math.max(0, this.end - WINDOW); }
|
|
165
|
-
else {
|
|
166
|
-
// Keep the reader's window anchored on the same first item; take new tail items only up to the cap.
|
|
167
|
-
const anchor = firstMounted === undefined ? -1 : items.findIndex(item => item.id === firstMounted);
|
|
168
|
-
const size = this.end - this.start;
|
|
169
|
-
const tailWasMounted = this.end >= previous.length;
|
|
170
|
-
this.start = anchor >= 0 ? anchor : Math.min(this.start, Math.max(0, items.length - 1));
|
|
171
|
-
this.end = Math.min(items.length, this.start + Math.min(CAP, tailWasMounted ? size + Math.max(0, items.length - previous.length) : size));
|
|
172
|
-
}
|
|
173
|
-
this.reconcile();
|
|
174
|
-
if (missed && (missed.added > 0 || missed.changed)) {
|
|
175
|
-
// A live reply that only grew still means "there is more below".
|
|
176
|
-
const next = Math.max(1, this.unreadCount + missed.added);
|
|
177
|
-
if (next !== this.unreadCount) { this.unreadCount = next; this.onUnread?.(next); }
|
|
178
|
-
}
|
|
179
|
-
this.renderer.requestRender();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Make the mounted nodes match items[start, end), reusing what is unchanged. */
|
|
183
|
-
private reconcile(): void {
|
|
184
|
-
const wanted = this.items.slice(this.start, this.end);
|
|
185
|
-
const keep = new Set(wanted.map(item => item.id));
|
|
186
|
-
for (const [id, entry] of this.entries) if (!keep.has(id)) { this.scroll.content.remove(entry.node); entry.node.destroyRecursively(); this.entries.delete(id); }
|
|
187
|
-
const content = this.scroll.content, base = 1; // child 0 is the "N earlier" row
|
|
188
|
-
let reordered = this.order.length !== wanted.length;
|
|
189
|
-
wanted.forEach((item, index) => {
|
|
190
|
-
if (!reordered && this.order[index] !== item.id) reordered = true;
|
|
191
|
-
const entry = this.entries.get(item.id);
|
|
192
|
-
if (entry && entry.rev === item.rev) { entry.item = item; return; }
|
|
193
|
-
if (entry && item.kind === "assistant" && entry.item.kind === "assistant" && entry.node instanceof AssistantBlock) {
|
|
194
|
-
// Streaming: append into the same block so only its tail re-parses.
|
|
195
|
-
entry.node.markdown.content = item.text;
|
|
196
|
-
entry.node.markdown.streaming = item.streaming === true;
|
|
197
|
-
entry.node.paintSelection();
|
|
198
|
-
entry.rev = item.rev; entry.item = item;
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const node = this.build(item);
|
|
202
|
-
if (entry) { content.remove(entry.node); entry.node.destroyRecursively(); }
|
|
203
|
-
this.entries.set(item.id, { rev: item.rev, node, item });
|
|
204
|
-
content.add(node, base + index);
|
|
205
|
-
});
|
|
206
|
-
// Reorders are rare (a notice anchored before a reply); re-add in order when they happen.
|
|
207
|
-
if (reordered) {
|
|
208
|
-
for (const item of wanted) content.remove(this.entries.get(item.id)!.node);
|
|
209
|
-
for (const item of wanted) content.add(this.entries.get(item.id)!.node);
|
|
210
|
-
}
|
|
211
|
-
this.order = wanted.map(item => item.id);
|
|
212
|
-
this.paintEarlier();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private paintEarlier(): void {
|
|
216
|
-
this.earlier.visible = this.start > 0;
|
|
217
|
-
if (this.start > 0) this.earlier.setRows([[{ text: `↑ ${this.start} earlier`, role: "muted" }]]);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
scrollToBottom(): void {
|
|
221
|
-
this.followingBottom = true;
|
|
222
|
-
if (this.unreadCount) { this.unreadCount = 0; this.onUnread?.(0); }
|
|
223
|
-
if (this.end < this.items.length || this.end - this.start > WINDOW) { this.end = this.items.length; this.start = Math.max(0, this.end - WINDOW); this.reconcile(); }
|
|
224
|
-
this.stickToBottom();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Scroll height is known only after layout; anchor now and again after the next frame. */
|
|
228
|
-
private stickToBottom(): void {
|
|
229
|
-
this.scroll.scrollTo(this.scroll.scrollHeight);
|
|
230
|
-
this.renderer.requestRender();
|
|
231
|
-
setTimeout(() => { if (this.followingBottom) { this.scroll.scrollTo(this.scroll.scrollHeight); this.renderer.requestRender(); } }, 0);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** The very start of the session: the first WINDOW items, top of the view. */
|
|
235
|
-
scrollToTop(): void {
|
|
236
|
-
this.followingBottom = false;
|
|
237
|
-
if (this.start > 0) { this.start = 0; this.end = Math.min(this.items.length, WINDOW); this.reconcile(); this.renderer.root.calculateLayout(); }
|
|
238
|
-
this.scroll.scrollTo(0);
|
|
239
|
-
this.trackPosition();
|
|
240
|
-
}
|
|
241
|
-
page(direction: number): void { this.scroll.scrollBy(direction * Math.max(1, Math.floor(this.viewHeight() * 0.8))); this.trackPosition(); }
|
|
242
|
-
|
|
243
|
-
private viewHeight(): number { return this.scroll.viewport.height; }
|
|
244
|
-
|
|
245
|
-
private trackPosition(): void {
|
|
246
|
-
if (this.paging) return;
|
|
247
|
-
const max = Math.max(0, this.scroll.scrollHeight - this.viewHeight());
|
|
248
|
-
const top = this.scroll.scrollTop;
|
|
249
|
-
// Bottom means the session's last item, not just the window's.
|
|
250
|
-
const bottom = top >= max - 1 && this.end >= this.items.length;
|
|
251
|
-
if (!bottom && top <= EDGE_ROWS && this.start > 0) { this.pageUp(); return; }
|
|
252
|
-
if (!bottom && top >= max - EDGE_ROWS && this.end < this.items.length) { this.pageDown(); return; }
|
|
253
|
-
if (bottom === this.followingBottom) return;
|
|
254
|
-
this.followingBottom = bottom;
|
|
255
|
-
if (bottom && this.unreadCount) { this.unreadCount = 0; this.onUnread?.(0); }
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** Mount PAGE earlier items, trimming the far end past CAP, with the first visible item held in place. */
|
|
259
|
-
private pageUp(): void {
|
|
260
|
-
if (this.start === 0) return;
|
|
261
|
-
this.followingBottom = false;
|
|
262
|
-
this.shifting(() => {
|
|
263
|
-
this.start = Math.max(0, this.start - PAGE);
|
|
264
|
-
if (this.end - this.start > CAP) this.end = this.start + CAP;
|
|
265
|
-
this.reconcile();
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Mount PAGE later items, trimming the near end past CAP. */
|
|
270
|
-
private pageDown(): void {
|
|
271
|
-
if (this.end >= this.items.length) return;
|
|
272
|
-
this.shifting(() => {
|
|
273
|
-
this.end = Math.min(this.items.length, this.end + PAGE);
|
|
274
|
-
if (this.end - this.start > CAP) this.start = this.end - CAP;
|
|
275
|
-
this.reconcile();
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Run a window change without moving what the reader sees: find the first
|
|
281
|
-
* item at or below the viewport top, lay out again synchronously, and shift
|
|
282
|
-
* the scroll position by however far that item moved.
|
|
283
|
-
*/
|
|
284
|
-
private shifting(change: () => void): void {
|
|
285
|
-
const top = this.scroll.scrollTop;
|
|
286
|
-
const anchor = this.order.map(id => this.entries.get(id)!.node).find(node => node.getLayoutNode().getComputedTop() + node.getLayoutNode().getComputedHeight() > top);
|
|
287
|
-
const before = anchor?.getLayoutNode().getComputedTop();
|
|
288
|
-
this.paging = true;
|
|
289
|
-
try {
|
|
290
|
-
change();
|
|
291
|
-
this.renderer.root.calculateLayout();
|
|
292
|
-
const after = anchor && !anchor.isDestroyed ? anchor.getLayoutNode().getComputedTop() : undefined;
|
|
293
|
-
const height = this.scroll.content.getLayoutNode().getComputedHeight();
|
|
294
|
-
this.scroll.verticalScrollBar.scrollSize = height;
|
|
295
|
-
if (before !== undefined && after !== undefined) this.scroll.scrollTop = Math.max(0, top + (after - before));
|
|
296
|
-
} finally { this.paging = false; }
|
|
297
|
-
this.renderer.requestRender();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
private rebuild(): void {
|
|
301
|
-
for (const entry of this.entries.values()) { this.scroll.content.remove(entry.node); entry.node.destroyRecursively(); }
|
|
302
|
-
this.entries.clear(); this.order = [];
|
|
303
|
-
const following = this.followingBottom;
|
|
304
|
-
this.reconcile();
|
|
305
|
-
if (following) this.stickToBottom();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
private toolOpen(id: string): boolean { return this.toolOverrides.get(id) ?? this.toolsExpanded; }
|
|
309
|
-
|
|
310
|
-
/** How pictures are drawn now. A change (settings, the terminal's reported capabilities) redraws the items that have any. */
|
|
311
|
-
setPictures(source: PictureSource): void {
|
|
312
|
-
const before = this.picturesSource;
|
|
313
|
-
this.picturesSource = source;
|
|
314
|
-
if (before.mode === source.mode && before.protocol === source.protocol && before.widthCells === source.widthCells
|
|
315
|
-
&& before.cell.width === source.cell.width && before.cell.height === source.cell.height) return;
|
|
316
|
-
for (const entry of [...this.entries.values()]) if ((entry.item.kind === "user" || entry.item.kind === "tool") && entry.item.images?.length) this.replace(entry.item);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private pictures(images: readonly ImageView[], limit: number, marginLeft = 0): Pictures {
|
|
320
|
-
const maxCols = Math.max(10, (this.scroll.width || this.renderer.width) - 10);
|
|
321
|
-
return new Pictures(this.renderer, this.colors, images, this.picturesSource, { maxCols, maxRows: transcriptImageRows(this.renderer.height), limit, marginTop: 1, marginLeft });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
private build(item: ViewItem): Renderable {
|
|
325
|
-
const c = this.colors, r = this.renderer, id = `${item.kind}-${serial++}`;
|
|
326
|
-
switch (item.kind) {
|
|
327
|
-
case "user": {
|
|
328
|
-
// `›` is the same mark the input box uses, so a sent line reads as what was typed.
|
|
329
|
-
const row = new BoxRenderable(r, { id, flexDirection: "row", marginTop: 1 });
|
|
330
|
-
// The mark is chrome: a copied message is its words, not its `›`.
|
|
331
|
-
const mark = new Canvas(r, { id: `${id}-mark`, width: 2, height: 1, flexShrink: 0, colors: c });
|
|
332
|
-
mark.setRows([[{ text: "›", role: item.interrupted ? "warning" : "accent", bold: true }]]);
|
|
333
|
-
row.add(mark);
|
|
334
|
-
const body = new BoxRenderable(r, { id: `${id}-body`, flexDirection: "column", flexGrow: 1, flexShrink: 1 });
|
|
335
|
-
body.add(this.text(parts({ text: item.text, fg: c.text }), { wrapMode: "word" }));
|
|
336
|
-
if (item.images?.length) body.add(this.pictures(item.images, IMAGES_PER_ITEM));
|
|
337
|
-
if (item.interrupted) body.add(this.text(parts({ text: "interrupted", fg: c.warning })));
|
|
338
|
-
row.add(body);
|
|
339
|
-
return row;
|
|
340
|
-
}
|
|
341
|
-
case "assistant":
|
|
342
|
-
return new AssistantBlock(r, id, item, c, this.syntax, this.features.codeBlock);
|
|
343
|
-
case "thinking": {
|
|
344
|
-
if (!this.thinkingExpanded) {
|
|
345
|
-
const label = item.label ?? (item.streaming ? "Thinking…" : "Thought");
|
|
346
|
-
return this.text(parts({ text: "∴ ", fg: c.muted }, { text: label, fg: c.muted, italic: true }), { marginTop: 1 });
|
|
347
|
-
}
|
|
348
|
-
const box = new BoxRenderable(r, { id, border: ["left"], borderColor: c.line, paddingLeft: 1, marginTop: 1, marginLeft: 2 });
|
|
349
|
-
box.add(this.text(parts({ text: item.text, fg: c.muted, italic: true }), { wrapMode: "word" }));
|
|
350
|
-
return box;
|
|
351
|
-
}
|
|
352
|
-
case "tool": return this.toolCard(id, item);
|
|
353
|
-
case "notice": {
|
|
354
|
-
const fg = item.tone === "error" ? c.error : item.tone === "warning" ? c.warning : item.tone === "success" ? c.success : c.muted;
|
|
355
|
-
return this.text(parts({ text: item.text, fg }), { wrapMode: "word", marginTop: 1 });
|
|
356
|
-
}
|
|
357
|
-
case "segment": {
|
|
358
|
-
// The log made visible: the session start and every context boundary are rules (refactor-tui-dock-layout DoD 7).
|
|
359
|
-
const box = new BoxRenderable(r, { id, flexDirection: "column", marginTop: 1, flexShrink: 0 });
|
|
360
|
-
const rule = new Canvas(r, { id: `${id}-rule`, height: 1, colors: c });
|
|
361
|
-
const paint = () => rule.setRows([edgeLine({ width: Math.max(1, rule.width || 40), left: [{ segs: [{ text: item.label, role: "muted" }], priority: 0, min: 4 }], role: "line", lead: 2, trail: 0 })]);
|
|
362
|
-
rule.onSizeChange = paint; paint();
|
|
363
|
-
box.add(rule);
|
|
364
|
-
if (item.detail) box.add(this.text(parts({ text: item.detail, fg: c.muted }), { wrapMode: "none", truncate: true }));
|
|
365
|
-
return box;
|
|
366
|
-
}
|
|
367
|
-
case "lines":
|
|
368
|
-
// Notices and extension blocks are not pre-wrapped to the column; long ones wrap instead of being cut.
|
|
369
|
-
return new LinesBlock(r, id, item.render, { marginTop: 1, fg: c.text, wrapMode: "word", ...this.selection() });
|
|
370
|
-
default:
|
|
371
|
-
return assertNever(item, "message item");
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* `✓ read src/a.ts 215 lines`. The glyph is the logged outcome (D11):
|
|
377
|
-
* ✓ ok, ✗ failed, the shared spinner while running, ◌ while waiting for
|
|
378
|
-
* approval. Shell output and failures keep a two-line preview; everything
|
|
379
|
-
* else is one row until expanded (Ctrl+O, or a click without a drag).
|
|
380
|
-
*
|
|
381
|
-
* The glyph and the preview's rule are chrome (Canvas / a Box border), the
|
|
382
|
-
* words are text nodes, so a mouse selection copies `bash echo hi exit 0`
|
|
383
|
-
* and never the ✓ or the │ (feat-tui-copy-selection).
|
|
384
|
-
*/
|
|
385
|
-
private toolCard(id: string, item: Extract<ViewItem, { kind: "tool" }>): Renderable {
|
|
386
|
-
const c = this.colors, r = this.renderer, open = this.toolOpen(item.id);
|
|
387
|
-
const card = new BoxRenderable(r, { id, flexDirection: "column", marginTop: 1 });
|
|
388
|
-
const shell = item.name === "!" || item.name === "!!";
|
|
389
|
-
const glyph: Seg = item.status === "ok" ? { text: "✓", role: "success" } : item.status === "error" ? { text: "✗", role: "error" }
|
|
390
|
-
: this.waiting ? { text: "◌", role: "warning" } : { text: "●", role: "accent", spin: true };
|
|
391
|
-
// A click toggles the card; a drag is a selection and leaves it alone.
|
|
392
|
-
let down: { x: number; y: number } | undefined;
|
|
393
|
-
const head = new BoxRenderable(r, { id: `${id}-head`, flexDirection: "row", height: 1,
|
|
394
|
-
onMouseDown: event => { down = { x: event.x, y: event.y }; },
|
|
395
|
-
onMouseUp: event => { if (down && down.x === event.x && down.y === event.y) { this.toolOverrides.set(item.id, !this.toolOpen(item.id)); this.replace(item); } down = undefined; } });
|
|
396
|
-
const mark = new Canvas(r, { id: `${id}-glyph`, width: 2, height: 1, flexShrink: 0, colors: c, glyph: this.glyph });
|
|
397
|
-
mark.setRows([[glyph]]);
|
|
398
|
-
head.add(mark);
|
|
399
|
-
head.add(this.text(parts({ text: shell ? item.name : item.name.padEnd(6), fg: c.text, bold: true }, { text: ` ${item.summary}`, fg: c.text },
|
|
400
|
-
item.evidence ? { text: ` ${item.evidence}`, fg: c.muted } : undefined), { flexGrow: 1, flexShrink: 1, wrapMode: "none", truncate: true }));
|
|
401
|
-
card.add(head);
|
|
402
|
-
const preview = !open && (shell || item.name === "bash" || item.status === "error") ? item.preview ?? [] : [];
|
|
403
|
-
if (preview.length) {
|
|
404
|
-
const body = new BoxRenderable(r, { id: `${id}-preview`, flexDirection: "column", border: ["left"], borderColor: c.line, paddingLeft: 1, marginLeft: 2 });
|
|
405
|
-
for (const line of preview) body.add(this.text(parts({ text: line, fg: c.muted }), { height: 1, wrapMode: "none", truncate: true }));
|
|
406
|
-
card.add(body);
|
|
407
|
-
}
|
|
408
|
-
// A tool that returned pictures shows the first, the rest as "+N more" (Grok shows one per tool block).
|
|
409
|
-
if (item.images?.length) card.add(this.pictures(item.images, 1, 2));
|
|
410
|
-
if (open) {
|
|
411
|
-
const body = new BoxRenderable(r, { id: `${id}-body`, border: ["left"], borderColor: c.line, paddingLeft: 1, marginLeft: 2 });
|
|
412
|
-
body.add(new LinesBlock(r, `${id}-lines`, item.detail, { fg: c.text, ...this.selection() }));
|
|
413
|
-
card.add(body);
|
|
414
|
-
}
|
|
415
|
-
return card;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/** Colours for mouse-selected text: the terminal's own, or its inverse (feat-tui-copy-selection DoD 5). */
|
|
419
|
-
private selection(): { selectionBg: RGBA; selectionFg: RGBA } { return { selectionBg: this.colors.selectionBg, selectionFg: this.colors.selectionFg }; }
|
|
420
|
-
|
|
421
|
-
/** A selectable text node in the transcript. */
|
|
422
|
-
private text(content: ReturnType<typeof parts>, extra: Record<string, unknown> = {}): TextRenderable {
|
|
423
|
-
return new TextRenderable(this.renderer, { id: `t-${serial++}`, content, ...this.selection(), ...extra });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
private replace(item: ViewItem): void {
|
|
427
|
-
const entry = this.entries.get(item.id);
|
|
428
|
-
if (!entry) return;
|
|
429
|
-
const index = this.order.indexOf(item.id);
|
|
430
|
-
this.scroll.content.remove(entry.node); entry.node.destroyRecursively();
|
|
431
|
-
entry.node = this.build(item);
|
|
432
|
-
this.scroll.content.add(entry.node, 1 + index);
|
|
433
|
-
this.renderer.requestRender();
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Stable Markdown identity across streamed chunks: a delta re-parses only the tail. */
|
|
438
|
-
class AssistantBlock extends BoxRenderable {
|
|
439
|
-
readonly markdown: MarkdownRenderable;
|
|
440
|
-
private readonly colors: UiColors;
|
|
441
|
-
constructor(r: CliRenderer, id: string, item: Extract<ViewItem, { kind: "assistant" }>, c: UiColors, syntax: SyntaxStyle, highlight?: MessageFeatures["codeBlock"]) {
|
|
442
|
-
// Indented under the user's line, as the reply to it; no name tag, no streaming label (the box edge shows activity).
|
|
443
|
-
super(r, { id, flexDirection: "column", marginTop: 1, marginLeft: 2 });
|
|
444
|
-
this.colors = c;
|
|
445
|
-
this.markdown = new MarkdownRenderable(r, { id: `${id}-markdown`, content: item.text, syntaxStyle: syntax, fg: c.text, conceal: true, streaming: item.streaming === true,
|
|
446
|
-
...(highlight ? { renderNode: codeRenderer(r, c, highlight) } : {}) });
|
|
447
|
-
this.add(this.markdown);
|
|
448
|
-
this.paintSelection();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Markdown builds its own text nodes, with OpenTUI's default selection
|
|
453
|
-
* colours (opaque white, invisible on a light terminal). Give each the
|
|
454
|
-
* view's; a streamed delta may have added nodes, so this runs after updates.
|
|
455
|
-
*/
|
|
456
|
-
paintSelection(): void {
|
|
457
|
-
const { selectionBg, selectionFg } = this.colors;
|
|
458
|
-
const visit = (node: Renderable) => {
|
|
459
|
-
if (node instanceof TextBufferRenderable && node.selectionBg !== selectionBg) { node.selectionBg = selectionBg; node.selectionFg = selectionFg; }
|
|
460
|
-
for (const child of node.getChildren()) visit(child as Renderable);
|
|
461
|
-
};
|
|
462
|
-
visit(this.markdown);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
/** A width-dependent ANSI block (tool output, diffs, extension renderers). Re-renders when its width changes. */
|
|
468
|
-
export class LinesBlock extends TextRenderable {
|
|
469
|
-
private readonly paint: (width: number) => string[];
|
|
470
|
-
private paintedWidth = -1;
|
|
471
|
-
constructor(r: CliRenderer, id: string, paint: (width: number) => string[], extra: Record<string, unknown> = {}) {
|
|
472
|
-
super(r, { id, content: "", wrapMode: "none", ...extra });
|
|
473
|
-
this.paint = paint;
|
|
474
|
-
this.onSizeChange = () => this.repaint();
|
|
475
|
-
this.repaint(Math.max(20, r.width - 8));
|
|
476
|
-
}
|
|
477
|
-
private repaint(fallback?: number): void {
|
|
478
|
-
const width = this.width > 0 ? this.width : fallback ?? 80;
|
|
479
|
-
if (width === this.paintedWidth) return;
|
|
480
|
-
this.paintedWidth = width;
|
|
481
|
-
let lines: string[];
|
|
482
|
-
try { lines = this.paint(width); } catch { lines = ["(this block failed to render)"]; }
|
|
483
|
-
this.content = ansiText(lines);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** Code blocks of every language go through the app's highlighter; everything else is OpenTUI's. */
|
|
488
|
-
function codeRenderer(r: CliRenderer, c: UiColors, highlight: NonNullable<MessageFeatures["codeBlock"]>): MarkdownRendererNode {
|
|
489
|
-
return (token) => {
|
|
490
|
-
if (token.type !== "code") return undefined;
|
|
491
|
-
const code = token as { text: string; lang?: string };
|
|
492
|
-
const lines = highlight(code.text, code.lang || undefined, Math.max(20, r.width - 10));
|
|
493
|
-
if (!lines) return undefined;
|
|
494
|
-
const box = new BoxRenderable(r, { id: `code-${serial++}`, border: ["left"], borderColor: c.line, paddingLeft: 1, marginTop: 1, marginBottom: 1 });
|
|
495
|
-
box.add(new TextRenderable(r, { id: `code-t-${serial++}`, content: ansiText(lines), fg: c.text, wrapMode: "word" }));
|
|
496
|
-
return box;
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
type MarkdownRendererNode = NonNullable<ConstructorParameters<typeof MarkdownRenderable>[1]["renderNode"]>;
|
|
500
|
-
|
|
501
|
-
function syntaxStyle(c: UiColors): SyntaxStyle {
|
|
502
|
-
return SyntaxStyle.fromStyles({
|
|
503
|
-
default: { fg: c.text },
|
|
504
|
-
"markup.heading": { fg: c.accent, bold: true },
|
|
505
|
-
"markup.strong": { bold: true },
|
|
506
|
-
"markup.italic": { italic: true },
|
|
507
|
-
"markup.strikethrough": { dim: true },
|
|
508
|
-
"markup.raw": { fg: c.warning },
|
|
509
|
-
"markup.raw.block": { fg: c.text },
|
|
510
|
-
"markup.quote": { fg: c.muted, italic: true },
|
|
511
|
-
"markup.list": { fg: c.accent },
|
|
512
|
-
"markup.link": { fg: c.accent, underline: true },
|
|
513
|
-
"markup.link.url": { fg: c.muted, underline: true },
|
|
514
|
-
"markup.link.label": { fg: c.accent },
|
|
515
|
-
conceal: { fg: c.muted },
|
|
516
|
-
keyword: { fg: c.reasoning }, string: { fg: c.success }, comment: { fg: c.muted, italic: true }, number: { fg: c.warning }, function: { fg: c.accent },
|
|
517
|
-
});
|
|
518
|
-
}
|
package/src/tui/ui/motion.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A small, owned animation clock. Only the subscribing chrome is redrawn;
|
|
3
|
-
* animations never trigger transcript projection (D20 view isolation), and
|
|
4
|
-
* a clock that is not running holds no timer.
|
|
5
|
-
*/
|
|
6
|
-
export class Motion {
|
|
7
|
-
private timer: ReturnType<typeof setInterval> | undefined;
|
|
8
|
-
private frame = 0;
|
|
9
|
-
get running(): boolean { return this.timer !== undefined; }
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Paint frame 0 now, then one frame per `intervalMs`. A finite animation
|
|
13
|
-
* (`lastFrame`) stops itself after painting that frame.
|
|
14
|
-
*/
|
|
15
|
-
start(paint: (frame: number) => void, lastFrame?: number, intervalMs = 120): void {
|
|
16
|
-
this.stop();
|
|
17
|
-
this.frame = 0;
|
|
18
|
-
paint(this.frame);
|
|
19
|
-
if (lastFrame !== undefined && lastFrame <= 0) return;
|
|
20
|
-
this.timer = setInterval(() => {
|
|
21
|
-
paint(++this.frame);
|
|
22
|
-
if (lastFrame !== undefined && this.frame >= lastFrame) this.stop();
|
|
23
|
-
}, intervalMs);
|
|
24
|
-
this.timer.unref?.();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
stop(): void { clearInterval(this.timer); this.timer = undefined; }
|
|
28
|
-
}
|
package/src/tui/ui/paths.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** `~`-relative display of a directory; pure. */
|
|
2
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
-
|
|
4
|
-
export function displayPath(cwd: string, home: string | undefined): string {
|
|
5
|
-
if (!home) return cwd;
|
|
6
|
-
const relativeToHome = relative(resolve(home), resolve(cwd));
|
|
7
|
-
const inside = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
|
|
8
|
-
if (!inside) return cwd;
|
|
9
|
-
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
10
|
-
}
|