@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/ink.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal-owned colour (docs/tasks/refactor-tui-terminal-colors.md). Pure.
|
|
3
|
-
*
|
|
4
|
-
* The terminal already has a palette the user chose, often with transparency
|
|
5
|
-
* and blur behind it. Painting our own background defeats both, and fixed RGB
|
|
6
|
-
* accents clash with whatever palette is underneath. So the view asks the
|
|
7
|
-
* terminal (OSC 4/10/11 via OpenTUI) and derives every role from the answer:
|
|
8
|
-
*
|
|
9
|
-
* text the terminal's own foreground (SGR 39), never a guess
|
|
10
|
-
* background the terminal's own background (SGR 49): nothing is painted
|
|
11
|
-
* accents one of the 16 slots, picked for contrast and hue
|
|
12
|
-
* greys the foreground blended toward the background, as faint as a
|
|
13
|
-
* contrast target allows (Codex styles.md does the same)
|
|
14
|
-
*
|
|
15
|
-
* When the terminal does not answer, a dark/light hint picks the slots and
|
|
16
|
-
* fixed greys. Semantic colour still only marks glyphs and rules; it never
|
|
17
|
-
* colours body text (D11 keeps green/red for logged outcomes).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/** How a colour is emitted. Indexed and default colours follow the terminal if its palette changes. */
|
|
21
|
-
export type InkSpec =
|
|
22
|
-
| { readonly kind: "default" }
|
|
23
|
-
| { readonly kind: "index"; readonly index: number }
|
|
24
|
-
| { readonly kind: "rgb"; readonly hex: string };
|
|
25
|
-
|
|
26
|
-
export interface TerminalPalette {
|
|
27
|
-
readonly foreground: string;
|
|
28
|
-
readonly background: string;
|
|
29
|
-
/** The 16 ANSI slots as #rrggbb. */
|
|
30
|
-
readonly slots: readonly string[];
|
|
31
|
-
/** The terminal's own selection colours (OSC 17 / 19), when it reports them. */
|
|
32
|
-
readonly highlight?: { readonly background: string; readonly foreground: string };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export const INK_ROLES = ["text", "muted", "edge", "line", "accent", "success", "error", "warning", "reasoning", "code"] as const;
|
|
36
|
-
export type InkRole = (typeof INK_ROLES)[number];
|
|
37
|
-
export type InkSet = { readonly [K in InkRole]: InkSpec } & {
|
|
38
|
-
readonly background: InkSpec;
|
|
39
|
-
/** Selected text: the terminal's selection colours, else an inverse of its own ground (feat-tui-copy-selection DoD 5). */
|
|
40
|
-
readonly selectionBg: InkSpec;
|
|
41
|
-
readonly selectionFg: InkSpec;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Contrast targets. 4.5:1 is WCAG AA for text and 3:1 for UI components;
|
|
46
|
-
* 1.8:1 for decorative rules is an assumption (visible, never louder than text).
|
|
47
|
-
* A terminal whose own foreground is weaker than these gets proportionally
|
|
48
|
-
* lower targets, so muted text never becomes louder than body text.
|
|
49
|
-
*/
|
|
50
|
-
const MUTED = { target: 4.5, share: 0.62 };
|
|
51
|
-
const EDGE = { target: 3, share: 0.42 };
|
|
52
|
-
const LINE = { target: 1.8, share: 0.3 };
|
|
53
|
-
/** Minimum contrast for an accent glyph against the background (WCAG non-text). */
|
|
54
|
-
const ACCENT_CONTRAST = 3;
|
|
55
|
-
/** Below this saturation a slot is a grey, not a hue (Solarized's bright slots). Assumed. */
|
|
56
|
-
const CHROMATIC = 0.15;
|
|
57
|
-
/** A bright slot stands in for its normal slot only within this hue distance. Assumed. */
|
|
58
|
-
const SAME_HUE_DEGREES = 40;
|
|
59
|
-
|
|
60
|
-
/** [normal, bright] slot pairs per role, in preference order. */
|
|
61
|
-
const SLOTS: Readonly<Record<Exclude<InkRole, "text" | "muted" | "edge" | "line">, readonly (readonly [number, number])[]>> = {
|
|
62
|
-
accent: [[4, 12], [6, 14]],
|
|
63
|
-
success: [[2, 10]],
|
|
64
|
-
error: [[1, 9]],
|
|
65
|
-
warning: [[3, 11]],
|
|
66
|
-
reasoning: [[5, 13]],
|
|
67
|
-
code: [[6, 14]],
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/** Greys used when the terminal does not report its palette. Chosen for ≥ 4.5 / 3 / 1.6 on typical dark (#1e1e1e) and light (#ffffff) grounds. */
|
|
71
|
-
const FALLBACK_GREYS = {
|
|
72
|
-
dark: { muted: "#9a9a9a", edge: "#6e6e6e", line: "#4a4a4a" },
|
|
73
|
-
light: { muted: "#6a6a6a", edge: "#949494", line: "#c8c8c8" },
|
|
74
|
-
} as const;
|
|
75
|
-
|
|
76
|
-
export function terminalInks(palette: TerminalPalette | null, hint: "dark" | "light"): InkSet {
|
|
77
|
-
const valid = palette !== null && isHex(palette.foreground) && isHex(palette.background) && palette.slots.length >= 16 && palette.slots.slice(0, 16).every(isHex);
|
|
78
|
-
if (!valid) return fallbackInks(hint);
|
|
79
|
-
const { foreground: fg, background: bg } = palette;
|
|
80
|
-
const dark = luminance(bg) < luminance(fg);
|
|
81
|
-
const c = contrast(fg, bg);
|
|
82
|
-
const pick = (pairs: readonly (readonly [number, number])[]): InkSpec => pickSlot(palette.slots, pairs, dark, bg);
|
|
83
|
-
const highlight = palette.highlight && isHex(palette.highlight.background) && isHex(palette.highlight.foreground) ? palette.highlight : undefined;
|
|
84
|
-
return {
|
|
85
|
-
background: { kind: "default" },
|
|
86
|
-
text: { kind: "default" },
|
|
87
|
-
selectionBg: { kind: "rgb", hex: highlight?.background ?? fg },
|
|
88
|
-
selectionFg: { kind: "rgb", hex: highlight?.foreground ?? bg },
|
|
89
|
-
muted: { kind: "rgb", hex: faintest(fg, bg, Math.min(MUTED.target, MUTED.share * c)) },
|
|
90
|
-
edge: { kind: "rgb", hex: faintest(fg, bg, Math.min(EDGE.target, EDGE.share * c)) },
|
|
91
|
-
line: { kind: "rgb", hex: faintest(fg, bg, Math.min(LINE.target, LINE.share * c)) },
|
|
92
|
-
accent: pick(SLOTS.accent), success: pick(SLOTS.success), error: pick(SLOTS.error),
|
|
93
|
-
warning: pick(SLOTS.warning), reasoning: pick(SLOTS.reasoning), code: pick(SLOTS.code),
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function fallbackInks(hint: "dark" | "light"): InkSet {
|
|
98
|
-
const greys = FALLBACK_GREYS[hint];
|
|
99
|
-
// Without a palette, a bright slot is the safer guess on dark grounds and the normal slot on light ones.
|
|
100
|
-
const slot = (pair: readonly [number, number]): InkSpec => ({ kind: "index", index: hint === "dark" ? pair[1] : pair[0] });
|
|
101
|
-
return {
|
|
102
|
-
background: { kind: "default" }, text: { kind: "default" },
|
|
103
|
-
// Inverse through the terminal's own black and white slots: its ground colours are unknown here.
|
|
104
|
-
selectionBg: { kind: "index", index: hint === "dark" ? 15 : 0 }, selectionFg: { kind: "index", index: hint === "dark" ? 0 : 15 },
|
|
105
|
-
muted: { kind: "rgb", hex: greys.muted }, edge: { kind: "rgb", hex: greys.edge }, line: { kind: "rgb", hex: greys.line },
|
|
106
|
-
accent: slot(SLOTS.accent[0]!), success: slot(SLOTS.success[0]!), error: slot(SLOTS.error[0]!),
|
|
107
|
-
warning: slot(SLOTS.warning[0]!), reasoning: slot(SLOTS.reasoning[0]!), code: slot(SLOTS.code[0]!),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function pickSlot(slots: readonly string[], pairs: readonly (readonly [number, number])[], dark: boolean, bg: string): InkSpec {
|
|
112
|
-
const candidates: number[] = [];
|
|
113
|
-
for (const [normal, bright] of pairs) {
|
|
114
|
-
const normalOk = saturation(slots[normal]!) > CHROMATIC;
|
|
115
|
-
const brightOk = saturation(slots[bright]!) > CHROMATIC && sameHue(slots[bright]!, slots[normal]!);
|
|
116
|
-
for (const index of dark ? [bright, normal] : [normal, bright]) if (index === bright ? brightOk : normalOk) candidates.push(index);
|
|
117
|
-
}
|
|
118
|
-
const readable = candidates.find(index => contrast(slots[index]!, bg) >= ACCENT_CONTRAST);
|
|
119
|
-
if (readable !== undefined) return { kind: "index", index: readable };
|
|
120
|
-
// Every candidate is too faint on this ground: keep the first hue, pushed away from the background.
|
|
121
|
-
if (candidates.length) return { kind: "rgb", hex: deepen(slots[candidates[0]!]!, bg, ACCENT_CONTRAST) };
|
|
122
|
-
// No chromatic slot at all (a monochrome palette): the terminal's own slot is still the honest choice.
|
|
123
|
-
return { kind: "index", index: pairs[0]![0] };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ----------------------------------------------------------------- colour math
|
|
127
|
-
|
|
128
|
-
export function isHex(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value); }
|
|
129
|
-
|
|
130
|
-
function channels(hex: string): [number, number, number] {
|
|
131
|
-
const n = Number.parseInt(hex.slice(1), 16);
|
|
132
|
-
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function toHex(values: readonly number[]): string {
|
|
136
|
-
return `#${values.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** WCAG relative luminance. */
|
|
140
|
-
export function luminance(hex: string): number {
|
|
141
|
-
const linear = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
|
|
142
|
-
const [r, g, b] = channels(hex);
|
|
143
|
-
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** WCAG contrast ratio, 1–21. */
|
|
147
|
-
export function contrast(a: string, b: string): number {
|
|
148
|
-
const x = luminance(a), y = luminance(b);
|
|
149
|
-
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** `share` of `from` over `to` (1 = from, 0 = to). */
|
|
153
|
-
export function mix(from: string, to: string, share: number): string {
|
|
154
|
-
const a = channels(from), b = channels(to);
|
|
155
|
-
return toHex(a.map((v, i) => b[i]! + (v - b[i]!) * share));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function saturation(hex: string): number {
|
|
159
|
-
const values = channels(hex).map(v => v / 255);
|
|
160
|
-
const max = Math.max(...values), min = Math.min(...values);
|
|
161
|
-
return max === 0 ? 0 : (max - min) / max;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function hue(hex: string): number {
|
|
165
|
-
const [r, g, b] = channels(hex).map(v => v / 255) as [number, number, number];
|
|
166
|
-
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
|
|
167
|
-
if (d === 0) return 0;
|
|
168
|
-
const sector = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
169
|
-
return (sector * 60 + 360) % 360;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function sameHue(a: string, b: string): boolean {
|
|
173
|
-
const d = Math.abs(hue(a) - hue(b));
|
|
174
|
-
return Math.min(d, 360 - d) <= SAME_HUE_DEGREES;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* The blend of fg into bg closest to bg that still reaches `target` against
|
|
179
|
-
* bg, in 1% steps. Never louder than fg itself: a channel-wise blend is not
|
|
180
|
-
* monotone in luminance when fg and bg differ in hue, so an intermediate mix
|
|
181
|
-
* can out-contrast fg, and a grey must not compete with body text.
|
|
182
|
-
*/
|
|
183
|
-
export function faintest(fg: string, bg: string, target: number): string {
|
|
184
|
-
const ceiling = contrast(fg, bg);
|
|
185
|
-
if (ceiling <= target) return fg;
|
|
186
|
-
for (let step = 1; step <= 100; step++) {
|
|
187
|
-
const color = mix(fg, bg, step / 100);
|
|
188
|
-
const c = contrast(color, bg);
|
|
189
|
-
if (c >= target) return c <= ceiling ? color : fg;
|
|
190
|
-
}
|
|
191
|
-
return fg;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Push a colour toward white (dark ground) or black (light ground) until it
|
|
196
|
-
* reaches `target` against bg, in 5% steps. Mixing with white or black
|
|
197
|
-
* preserves hue exactly, and luminance moves monotonically away from bg, so
|
|
198
|
-
* a too-faint yellow becomes a darker yellow rather than some other colour.
|
|
199
|
-
* Either extreme reaches at least 4.58:1 against any ground, so any target up
|
|
200
|
-
* to that is always met.
|
|
201
|
-
*/
|
|
202
|
-
export function deepen(color: string, bg: string, target: number): string {
|
|
203
|
-
if (contrast(color, bg) >= target) return color;
|
|
204
|
-
const extreme = contrast("#ffffff", bg) >= contrast("#000000", bg) ? "#ffffff" : "#000000";
|
|
205
|
-
for (let step = 1; step <= 20; step++) {
|
|
206
|
-
const next = mix(extreme, color, step / 20);
|
|
207
|
-
if (contrast(next, bg) >= target) return next;
|
|
208
|
-
}
|
|
209
|
-
return extreme;
|
|
210
|
-
}
|
package/src/tui/ui/items.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the message stream shows, as plain data. The app projects its log
|
|
3
|
-
* into these (apps/ui/project.ts); the view never sees log events.
|
|
4
|
-
*
|
|
5
|
-
* `rev` changes whenever an item's content changes; equal id + rev means the
|
|
6
|
-
* renderable can be reused untouched, which keeps long sessions cheap.
|
|
7
|
-
*/
|
|
8
|
-
export type ToolStatus = "pending" | "ok" | "error";
|
|
9
|
-
|
|
10
|
-
/** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
|
|
11
|
-
export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number;
|
|
12
|
-
/** The blob a click opens when it differs from `sha256`: what was attached, before any reduction for the model. */
|
|
13
|
-
readonly full?: string }
|
|
14
|
-
|
|
15
|
-
export type ViewItem =
|
|
16
|
-
| { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
|
|
17
|
-
| { readonly kind: "assistant"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean }
|
|
18
|
-
| { readonly kind: "thinking"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean; readonly label?: string }
|
|
19
|
-
| { readonly kind: "tool"; readonly id: string; readonly rev: string; readonly name: string; readonly summary: string; readonly status: ToolStatus;
|
|
20
|
-
/** Evidence shown right-aligned: "exit 0", "+3 −1", "12 lines". Never the model's own claim (D11). */
|
|
21
|
-
readonly evidence?: string; readonly preview?: readonly string[]; readonly images?: readonly ImageView[]; readonly detail: (width: number) => string[] }
|
|
22
|
-
| { readonly kind: "notice"; readonly id: string; readonly rev: string; readonly text: string; readonly tone?: "info" | "success" | "warning" | "error" }
|
|
23
|
-
| { readonly kind: "segment"; readonly id: string; readonly rev: string; readonly label: string; readonly detail?: string }
|
|
24
|
-
| { readonly kind: "lines"; readonly id: string; readonly rev: string; readonly render: (width: number) => string[] };
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Pure: what the reader missed between two projections. `added` counts
|
|
28
|
-
* items that did not exist before (a streaming reply growing is one
|
|
29
|
-
* message, not one per delta); `changed` says whether anything already
|
|
30
|
-
* shown changed. The session-start rule is chrome and never counts.
|
|
31
|
-
*/
|
|
32
|
-
export function unseen(previous: readonly ViewItem[], next: readonly ViewItem[]): { added: number; changed: boolean } {
|
|
33
|
-
const before = new Map(previous.map(item => [item.id, item.rev]));
|
|
34
|
-
let added = 0, changed = false;
|
|
35
|
-
for (const item of next) {
|
|
36
|
-
if (item.id === "session-start") continue;
|
|
37
|
-
const rev = before.get(item.id);
|
|
38
|
-
if (rev === undefined) added++;
|
|
39
|
-
else if (rev !== item.rev) changed = true;
|
|
40
|
-
}
|
|
41
|
-
return { added, changed };
|
|
42
|
-
}
|
package/src/tui/ui/keys.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One key shape for the whole view. OpenTUI's KeyEvent is normalised into a
|
|
3
|
-
* chord string in core/keymap's canonical order (ctrl+alt+shift+super+key)
|
|
4
|
-
* so bindings parsed from settings compare by string equality.
|
|
5
|
-
*/
|
|
6
|
-
import type { KeyEvent } from "@opentui/core";
|
|
7
|
-
|
|
8
|
-
export interface KeyPress {
|
|
9
|
-
readonly chord: string;
|
|
10
|
-
readonly name: string;
|
|
11
|
-
readonly ctrl: boolean;
|
|
12
|
-
readonly alt: boolean;
|
|
13
|
-
readonly shift: boolean;
|
|
14
|
-
/** Printable text this key inserts, or "" for a control key. */
|
|
15
|
-
readonly text: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const RENAME: Readonly<Record<string, string>> = { return: "enter", esc: "escape", " ": "space" };
|
|
19
|
-
|
|
20
|
-
export function keyPress(event: Pick<KeyEvent, "name" | "ctrl" | "meta" | "shift" | "sequence"> & { option?: boolean; super?: boolean }): KeyPress {
|
|
21
|
-
const raw = (event.name || "").toLowerCase();
|
|
22
|
-
const name = RENAME[raw] ?? raw;
|
|
23
|
-
const alt = Boolean(event.meta || event.option);
|
|
24
|
-
const sup = Boolean(event.super);
|
|
25
|
-
const printable = !event.ctrl && !alt && !sup && typeof event.sequence === "string" && event.sequence.length > 0 && !/[\x00-\x1f\x7f]/.test(event.sequence) && !event.sequence.startsWith("\x1b");
|
|
26
|
-
const parts: string[] = [];
|
|
27
|
-
if (event.ctrl) parts.push("ctrl");
|
|
28
|
-
if (alt) parts.push("alt");
|
|
29
|
-
// Shift on a printable character is already in the character itself.
|
|
30
|
-
if (event.shift && !(printable && name.length === 1)) parts.push("shift");
|
|
31
|
-
if (sup) parts.push("super");
|
|
32
|
-
parts.push(name);
|
|
33
|
-
return { chord: parts.join("+"), name, ctrl: Boolean(event.ctrl), alt, shift: Boolean(event.shift), text: printable ? event.sequence : "" };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export type Bindings = Readonly<Record<string, readonly string[]>>;
|
|
37
|
-
|
|
38
|
-
export function bound(bindings: Bindings, action: string, key: KeyPress): boolean {
|
|
39
|
-
return (bindings[action] ?? []).includes(key.chord);
|
|
40
|
-
}
|
package/src/tui/ui/layout.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
|
|
3
|
-
*
|
|
4
|
-
* sidebar ≥ 110 cols and ≥ 20 rows right column plus compact provider header
|
|
5
|
-
* topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
|
|
6
|
-
* bare everything else the input box's bottom edge only;
|
|
7
|
-
* session and branch go to the terminal title
|
|
8
|
-
*
|
|
9
|
-
* The input box's bottom edge carries model and thinking in every tier, and
|
|
10
|
-
* the context share wherever there is no sidebar to hold it. Every status
|
|
11
|
-
* field has exactly one place per tier, so nothing is shown twice.
|
|
12
|
-
*
|
|
13
|
-
* The 110-column threshold is the author's choice (2026-10-06), set so the
|
|
14
|
-
* main column keeps the 75 columns OpenCode keeps beside its 42-column sidebar
|
|
15
|
-
* at its 121-column threshold (packages/tui routes/session/index.tsx).
|
|
16
|
-
*/
|
|
17
|
-
import { assertNever } from "../exhaustive.ts";
|
|
18
|
-
|
|
19
|
-
export type Tier = "sidebar" | "topbar" | "bare";
|
|
20
|
-
|
|
21
|
-
export interface ScreenLayout {
|
|
22
|
-
readonly tier: Tier;
|
|
23
|
-
/** Columns of the sidebar including its separator; 0 when hidden. */
|
|
24
|
-
readonly sidebarWidth: number;
|
|
25
|
-
readonly mainWidth: number;
|
|
26
|
-
readonly topbar: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const SIDEBAR_MIN_COLUMNS = 110;
|
|
30
|
-
export const SIDEBAR_MIN_ROWS = 20;
|
|
31
|
-
export const TOPBAR_MIN_COLUMNS = 80;
|
|
32
|
-
export const TOPBAR_MIN_ROWS = 18;
|
|
33
|
-
/** The main column never gets narrower than this beside the sidebar. */
|
|
34
|
-
export const MAIN_MIN_COLUMNS = 75;
|
|
35
|
-
|
|
36
|
-
/** Sidebar content width (without the separator column). Wider terminals give it more room; assumed steps. */
|
|
37
|
-
export function sidebarContentWidth(cols: number): number {
|
|
38
|
-
return cols >= 150 ? 42 : cols >= 130 ? 38 : 34;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function screenLayout(cols: number, rows: number, sidebarWanted = true): ScreenLayout {
|
|
42
|
-
const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
|
|
43
|
-
if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
|
|
44
|
-
const sidebarWidth = sidebarContentWidth(c) + 1;
|
|
45
|
-
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: true };
|
|
46
|
-
}
|
|
47
|
-
if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
|
|
48
|
-
return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
52
|
-
export type StatusField = (typeof STATUS_FIELDS)[number];
|
|
53
|
-
/** Screen surfaces only; the terminal title is outside the frame and listed separately. */
|
|
54
|
-
export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The one place each field takes in a tier. Hidden fields stay one command
|
|
58
|
-
* away (/usage, /state, /tasks, /session); the terminal title also carries
|
|
59
|
-
* session and branch in every tier, outside the frame.
|
|
60
|
-
*/
|
|
61
|
-
export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
|
|
62
|
-
switch (tier) {
|
|
63
|
-
case "sidebar":
|
|
64
|
-
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", goal: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
65
|
-
usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
|
|
66
|
-
case "topbar":
|
|
67
|
-
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
68
|
-
usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
|
|
69
|
-
case "bare":
|
|
70
|
-
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
71
|
-
usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
|
|
72
|
-
default: return assertNever(tier, "placement");
|
|
73
|
-
}
|
|
74
|
-
}
|
package/src/tui/ui/list-model.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a list dialog shows for a query. Pure.
|
|
3
|
-
*
|
|
4
|
-
* Every picker in the UI (palette, model, login, resume, tree, settings, …)
|
|
5
|
-
* is the same shape: items grouped into sections, a fuzzy query, one
|
|
6
|
-
* selected row, optional checkboxes. One model means one set of keys and
|
|
7
|
-
* one place where "Enter confirms the row under the cursor" is proven.
|
|
8
|
-
*
|
|
9
|
-
* With an empty query, items keep their given order and each new section
|
|
10
|
-
* gets a header row (OpenCode's dialog-model). With a query, sections
|
|
11
|
-
* flatten into one ranked list: grouping a three-hit search only adds noise.
|
|
12
|
-
*/
|
|
13
|
-
import { fuzzyMatch } from "../fuzzy.ts";
|
|
14
|
-
|
|
15
|
-
export interface ListItem {
|
|
16
|
-
readonly value: string;
|
|
17
|
-
readonly label: string;
|
|
18
|
-
readonly description?: string;
|
|
19
|
-
readonly section?: string;
|
|
20
|
-
/** Right-aligned hint: a key chord, "current", a count. */
|
|
21
|
-
readonly hint?: string;
|
|
22
|
-
/** Extra text matched by the query but not shown. */
|
|
23
|
-
readonly keywords?: string;
|
|
24
|
-
readonly disabled?: boolean;
|
|
25
|
-
/** Indentation (tree rows). */
|
|
26
|
-
readonly depth?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type ListRow =
|
|
30
|
-
| { readonly kind: "header"; readonly text: string }
|
|
31
|
-
| { readonly kind: "item"; readonly item: ListItem; readonly index: number };
|
|
32
|
-
|
|
33
|
-
export function filterItems(items: readonly ListItem[], query: string): ListItem[] {
|
|
34
|
-
const q = query.trim();
|
|
35
|
-
if (!q) return [...items];
|
|
36
|
-
const ranked: { item: ListItem; score: number; index: number }[] = [];
|
|
37
|
-
items.forEach((item, index) => {
|
|
38
|
-
const label = fuzzyMatch(item.label, q);
|
|
39
|
-
const rest = fuzzyMatch(`${item.label} ${item.description ?? ""} ${item.section ?? ""} ${item.keywords ?? ""}`, q);
|
|
40
|
-
// A hit in the label outranks a hit that needed the description.
|
|
41
|
-
const score = label !== null ? label : rest !== null ? rest + 1000 : null;
|
|
42
|
-
if (score !== null) ranked.push({ item, score, index });
|
|
43
|
-
});
|
|
44
|
-
ranked.sort((a, b) => a.score - b.score || a.index - b.index);
|
|
45
|
-
return ranked.map(entry => entry.item);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function listRows(items: readonly ListItem[], query: string): ListRow[] {
|
|
49
|
-
const filtered = filterItems(items, query);
|
|
50
|
-
const rows: ListRow[] = [];
|
|
51
|
-
let section: string | undefined;
|
|
52
|
-
const grouped = !query.trim();
|
|
53
|
-
filtered.forEach((item, index) => {
|
|
54
|
-
if (grouped && item.section && item.section !== section) rows.push({ kind: "header", text: item.section });
|
|
55
|
-
section = item.section;
|
|
56
|
-
rows.push({ kind: "item", item, index });
|
|
57
|
-
});
|
|
58
|
-
return rows;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Next selectable index (wrapping), skipping disabled items. -1 when none. */
|
|
62
|
-
export function step(items: readonly ListItem[], from: number, delta: number): number {
|
|
63
|
-
const n = items.length;
|
|
64
|
-
if (n === 0) return -1;
|
|
65
|
-
for (let k = 1; k <= n; k++) {
|
|
66
|
-
const candidate = (((from + delta * k) % n) + n) % n;
|
|
67
|
-
if (!items[candidate]!.disabled) return candidate;
|
|
68
|
-
}
|
|
69
|
-
return -1;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** First enabled index at or after `from`, used when a query changes the list. */
|
|
73
|
-
export function firstEnabled(items: readonly ListItem[], from = 0): number {
|
|
74
|
-
for (let i = Math.max(0, from); i < items.length; i++) if (!items[i]!.disabled) return i;
|
|
75
|
-
for (let i = 0; i < Math.min(from, items.length); i++) if (!items[i]!.disabled) return i;
|
|
76
|
-
return -1;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Rows to draw so the selected item stays visible, keeping its header when it fits. */
|
|
80
|
-
export function windowRows(rows: readonly ListRow[], selected: number, height: number): { start: number; end: number } {
|
|
81
|
-
if (rows.length <= height) return { start: 0, end: rows.length };
|
|
82
|
-
const at = Math.max(0, rows.findIndex(row => row.kind === "item" && row.index === selected));
|
|
83
|
-
let start = Math.max(0, Math.min(at - Math.floor(height / 2), rows.length - height));
|
|
84
|
-
if (height > 1 && start > 0 && rows[at - 1]?.kind === "header" && at - 1 < start) start = at - 1;
|
|
85
|
-
return { start, end: Math.min(rows.length, start + height) };
|
|
86
|
-
}
|
package/src/tui/ui/list-view.ts
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The one list component: grouped rows, selection, optional checkboxes,
|
|
3
|
-
* right-aligned hints, and a search field on the bottom row — the row the
|
|
4
|
-
* draft occupies when no picker is open, so the input box keeps its shape
|
|
5
|
-
* (docs/tasks/refactor-tui-dock-layout.md). State lives in list-model.ts
|
|
6
|
-
* (pure); this class draws it and exposes the moves the dialog host routes.
|
|
7
|
-
*
|
|
8
|
-
* Rows are a Canvas: moving the selection repaints cells, not nodes. The
|
|
9
|
-
* search text is a focused Input so cursor, paste and word deletion stay
|
|
10
|
-
* OpenTUI's.
|
|
11
|
-
*/
|
|
12
|
-
import { BoxRenderable, InputRenderable, InputRenderableEvents, type RenderContext } from "@opentui/core";
|
|
13
|
-
import { Canvas } from "./canvas.ts";
|
|
14
|
-
import type { UiColors } from "./colors.ts";
|
|
15
|
-
import { clipText, segsWidth, splitLine, type Seg } from "./edge.ts";
|
|
16
|
-
import { filterItems, firstEnabled, listRows, step, windowRows, type ListItem, type ListRow } from "./list-model.ts";
|
|
17
|
-
import type { Line } from "./status.ts";
|
|
18
|
-
|
|
19
|
-
export interface ListViewOptions {
|
|
20
|
-
readonly colors: UiColors;
|
|
21
|
-
readonly rows?: number;
|
|
22
|
-
readonly search?: boolean;
|
|
23
|
-
readonly multi?: boolean;
|
|
24
|
-
readonly placeholder?: string;
|
|
25
|
-
readonly empty?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Visible rows. Ten keeps a picker under half of a 30-row terminal. Assumed. */
|
|
29
|
-
const DEFAULT_ROWS = 10;
|
|
30
|
-
let serial = 0;
|
|
31
|
-
|
|
32
|
-
export class ListView extends BoxRenderable {
|
|
33
|
-
private colors: UiColors;
|
|
34
|
-
private maxRows: number;
|
|
35
|
-
private readonly multi: boolean;
|
|
36
|
-
private readonly emptyText: string;
|
|
37
|
-
readonly searchInput: InputRenderable | undefined;
|
|
38
|
-
private readonly searchRow: BoxRenderable | undefined;
|
|
39
|
-
private readonly searchGlyph: Canvas | undefined;
|
|
40
|
-
private readonly body: Canvas;
|
|
41
|
-
private items: ListItem[] = [];
|
|
42
|
-
private filtered: ListItem[] = [];
|
|
43
|
-
private rowsModel: ListRow[] = [];
|
|
44
|
-
private selected = -1;
|
|
45
|
-
readonly checked = new Set<string>();
|
|
46
|
-
onChange: (() => void) | undefined;
|
|
47
|
-
|
|
48
|
-
constructor(ctx: RenderContext, options: ListViewOptions) {
|
|
49
|
-
const id = `list-${serial++}`;
|
|
50
|
-
super(ctx, { id, flexDirection: "column", flexShrink: 0 });
|
|
51
|
-
this.colors = options.colors;
|
|
52
|
-
this.maxRows = options.rows ?? DEFAULT_ROWS;
|
|
53
|
-
this.multi = options.multi ?? false;
|
|
54
|
-
this.emptyText = options.empty ?? "No matches";
|
|
55
|
-
this.body = new Canvas(ctx, { id: `${id}-rows`, height: 1, flexShrink: 0, colors: this.colors });
|
|
56
|
-
this.add(this.body);
|
|
57
|
-
if (options.search !== false) {
|
|
58
|
-
this.searchRow = new BoxRenderable(ctx, { id: `${id}-search`, flexDirection: "row", height: 1, marginTop: 1, flexShrink: 0 });
|
|
59
|
-
this.searchGlyph = new Canvas(ctx, { id: `${id}-glyph`, width: 2, height: 1, flexShrink: 0, colors: this.colors });
|
|
60
|
-
this.searchGlyph.setRows([[{ text: "›", role: "accent", bold: true }]]);
|
|
61
|
-
this.searchInput = new InputRenderable(ctx, { id: `${id}-input`, flexGrow: 1, placeholder: options.placeholder ?? "Search",
|
|
62
|
-
textColor: this.colors.text, focusedTextColor: this.colors.text, placeholderColor: this.colors.muted, backgroundColor: "transparent", focusedBackgroundColor: "transparent" });
|
|
63
|
-
this.searchInput.on(InputRenderableEvents.INPUT, () => this.refilter());
|
|
64
|
-
this.searchRow.add(this.searchGlyph); this.searchRow.add(this.searchInput);
|
|
65
|
-
this.add(this.searchRow);
|
|
66
|
-
}
|
|
67
|
-
this.onSizeChange = () => this.paint();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
get query(): string { return this.searchInput?.value ?? ""; }
|
|
71
|
-
get current(): ListItem | undefined { return this.selected >= 0 ? this.filtered[this.selected] : undefined; }
|
|
72
|
-
get visibleItems(): readonly ListItem[] { return this.filtered; }
|
|
73
|
-
/** "3/40" when the list scrolls, for the box's top edge; "" when everything is visible. */
|
|
74
|
-
get scrollPosition(): string { return this.rowsModel.length > this.maxRows && this.selected >= 0 ? `${this.selected + 1}/${this.filtered.length}` : ""; }
|
|
75
|
-
/** Plain text of the drawn rows (tests). */
|
|
76
|
-
get text(): string { return this.body.text; }
|
|
77
|
-
|
|
78
|
-
setColors(colors: UiColors): void {
|
|
79
|
-
this.colors = colors;
|
|
80
|
-
this.body.setColors(colors);
|
|
81
|
-
this.searchGlyph?.setColors(colors);
|
|
82
|
-
if (this.searchInput) {
|
|
83
|
-
this.searchInput.textColor = colors.text;
|
|
84
|
-
this.searchInput.focusedTextColor = colors.text;
|
|
85
|
-
this.searchInput.placeholderColor = colors.muted;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
setRowLimit(rows: number): void {
|
|
90
|
-
const limit = Math.max(1, rows);
|
|
91
|
-
if (limit === this.maxRows) return;
|
|
92
|
-
this.maxRows = limit; this.paint();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
setItems(items: readonly ListItem[], selectedValue?: string): void {
|
|
96
|
-
const keep = selectedValue ?? this.current?.value;
|
|
97
|
-
this.items = [...items];
|
|
98
|
-
this.refilter(keep);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
setQuery(query: string): void {
|
|
102
|
-
if (this.searchInput) this.searchInput.value = query;
|
|
103
|
-
this.refilter();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
move(delta: number): void {
|
|
107
|
-
const next = step(this.filtered, this.selected, delta);
|
|
108
|
-
if (next >= 0) this.selected = next;
|
|
109
|
-
this.paint();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
page(delta: number): void {
|
|
113
|
-
const target = Math.max(0, Math.min(this.filtered.length - 1, this.selected + delta * this.maxRows));
|
|
114
|
-
this.selected = firstEnabled(this.filtered, target);
|
|
115
|
-
this.paint();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
toggle(): void {
|
|
119
|
-
const item = this.current;
|
|
120
|
-
if (!this.multi || !item || item.disabled) return;
|
|
121
|
-
if (this.checked.has(item.value)) this.checked.delete(item.value); else this.checked.add(item.value);
|
|
122
|
-
this.paint();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
toggleAllVisible(): void {
|
|
126
|
-
if (!this.multi) return;
|
|
127
|
-
const enabled = this.filtered.filter(item => !item.disabled);
|
|
128
|
-
const all = enabled.every(item => this.checked.has(item.value));
|
|
129
|
-
for (const item of enabled) { if (all) this.checked.delete(item.value); else this.checked.add(item.value); }
|
|
130
|
-
this.paint();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private refilter(keep?: string): void {
|
|
134
|
-
this.filtered = filterItems(this.items, this.query);
|
|
135
|
-
this.rowsModel = listRows(this.items, this.query);
|
|
136
|
-
const at = keep === undefined ? -1 : this.filtered.findIndex(item => item.value === keep);
|
|
137
|
-
this.selected = at >= 0 && !this.filtered[at]!.disabled ? at : firstEnabled(this.filtered);
|
|
138
|
-
this.paint();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private paint(): void {
|
|
142
|
-
const width = Math.max(10, this.width || 60);
|
|
143
|
-
const lines: Line[] = [];
|
|
144
|
-
if (this.rowsModel.length === 0) lines.push([{ text: this.emptyText, role: "muted" }]);
|
|
145
|
-
else {
|
|
146
|
-
const { start, end } = windowRows(this.rowsModel, this.selected, this.maxRows);
|
|
147
|
-
for (let r = start; r < end; r++) lines.push(this.line(this.rowsModel[r]!, width));
|
|
148
|
-
}
|
|
149
|
-
this.body.height = lines.length;
|
|
150
|
-
this.body.setRows(lines);
|
|
151
|
-
this.onChange?.();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private line(row: ListRow, width: number): Line {
|
|
155
|
-
if (row.kind === "header") return [{ text: row.text.toUpperCase(), role: "muted", bold: true }];
|
|
156
|
-
const item = row.item, selected = row.index === this.selected;
|
|
157
|
-
const lead: Seg[] = [
|
|
158
|
-
{ text: selected ? "▸ " : " ", role: "accent", bold: true },
|
|
159
|
-
...(this.multi ? [{ text: this.checked.has(item.value) ? "◉ " : "○ ", role: this.checked.has(item.value) ? "success" as const : "muted" as const }] : []),
|
|
160
|
-
...(item.depth ? [{ text: " ".repeat(item.depth), role: "text" as const }] : []),
|
|
161
|
-
];
|
|
162
|
-
const label: Seg = { text: item.label, role: item.disabled ? "muted" : "text", bold: selected };
|
|
163
|
-
// Keep the actionable identity before optional detail on narrow boxes.
|
|
164
|
-
const detail: Seg[] = item.description && width >= 60 ? [{ text: ` ${item.description}`, role: "muted" }] : [];
|
|
165
|
-
const hint: Seg[] = item.hint ? [{ text: item.hint, role: "muted" }] : [];
|
|
166
|
-
if (!hint.length) return [...lead, label, ...detail];
|
|
167
|
-
const room = Math.max(4, width - segsWidth(hint) - 1);
|
|
168
|
-
const left = [...lead, label, ...detail];
|
|
169
|
-
const fitted = segsWidth(left) <= room ? left : [...lead, { ...label, text: clipText(label.text, Math.max(1, room - segsWidth(lead))) }];
|
|
170
|
-
return splitLine(width, [{ segs: fitted, priority: 0 }], [{ segs: hint, priority: 1 }], 1);
|
|
171
|
-
}
|
|
172
|
-
}
|