catherd-cli 1.5.0 → 2.0.0-beta.20261022113309
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/README.md +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -44
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -400
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-confirm.tsx (MIT, © 2025 opencode);
|
|
2
|
-
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
-
import { useRef, useState } from "react";
|
|
4
|
-
import { useApp } from "../providers/app.tsx";
|
|
5
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
6
|
-
import type { Dialog as DialogState } from "../state.ts";
|
|
7
|
-
import { wrap } from "../text.ts";
|
|
8
|
-
import { Dialog } from "./dialog.tsx";
|
|
9
|
-
import { Line } from "./line.tsx";
|
|
10
|
-
|
|
11
|
-
type Confirm = Extract<DialogState, { kind: "confirm" }>;
|
|
12
|
-
|
|
13
|
-
/** A row of buttons: ←/→ or tab move, enter chooses (spec §9.2 confirmations; never "any other key"). */
|
|
14
|
-
export function Buttons(props: { labels: string[]; focused: number; width: number }) {
|
|
15
|
-
const cells = props.labels.map((l) => `[ ${l} ]`);
|
|
16
|
-
const used = cells.reduce((n, c) => n + Bun.stringWidth(c) + 2, 0);
|
|
17
|
-
return (
|
|
18
|
-
<box flexDirection="row" width={props.width} height={1}>
|
|
19
|
-
{cells.map((c, i) => (
|
|
20
|
-
<box key={c} flexDirection="row">
|
|
21
|
-
<Line width={Bun.stringWidth(c)} parts={[{ text: c }]} selected={i === props.focused} />
|
|
22
|
-
<text>{" "}</text>
|
|
23
|
-
</box>
|
|
24
|
-
))}
|
|
25
|
-
<text>{" ".repeat(Math.max(0, props.width - used))}</text>
|
|
26
|
-
</box>
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A real confirmation: the message, then [no] [yes]; a destructive one starts on "no". */
|
|
31
|
-
export function DialogConfirm(props: { dialog: Confirm }) {
|
|
32
|
-
const app = useApp();
|
|
33
|
-
const d = props.dialog;
|
|
34
|
-
const [focused, setFocusedState] = useState(d.destructive ? 0 : 1);
|
|
35
|
-
// keys in one burst read the focus as it is now, not as it was drawn
|
|
36
|
-
const focusedRef = useRef(focused);
|
|
37
|
-
const setFocused = (f: number) => {
|
|
38
|
-
focusedRef.current = f;
|
|
39
|
-
setFocusedState(f);
|
|
40
|
-
};
|
|
41
|
-
useCommandLayer("dialog", {
|
|
42
|
-
"dialog.left": () => setFocused(0),
|
|
43
|
-
"dialog.right": () => setFocused(1),
|
|
44
|
-
"dialog.submit": () => (focusedRef.current === 1 ? app.answer("yes") : app.dispatch({ type: "close" })),
|
|
45
|
-
});
|
|
46
|
-
return (
|
|
47
|
-
<Dialog
|
|
48
|
-
title={d.title}
|
|
49
|
-
rows={(inner) => [
|
|
50
|
-
...d.message.flatMap((m, i) =>
|
|
51
|
-
wrap(m, inner).map((l, j) => <Line key={`m${i}-${j}`} width={inner} parts={[{ text: l }]} />),
|
|
52
|
-
),
|
|
53
|
-
<Line key="gap" width={inner} parts={[]} />,
|
|
54
|
-
<Buttons key="buttons" labels={[d.no, d.yes]} focused={focused} width={inner} />,
|
|
55
|
-
]}
|
|
56
|
-
/>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-prompt.tsx (MIT, © 2025 opencode);
|
|
2
|
-
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
-
import type { InputRenderable } from "@opentui/core";
|
|
4
|
-
import { useEffect, useRef } from "react";
|
|
5
|
-
import { useApp } from "../providers/app.tsx";
|
|
6
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
7
|
-
import { useTone } from "../providers/theme.tsx";
|
|
8
|
-
import type { Dialog as DialogState } from "../state.ts";
|
|
9
|
-
import { Dialog } from "./dialog.tsx";
|
|
10
|
-
import { Line } from "./line.tsx";
|
|
11
|
-
|
|
12
|
-
type Prompt = Extract<DialogState, { kind: "prompt" }>;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* A value editor: one input that owns every printable key, enter to submit, esc to cancel. A refused
|
|
16
|
-
* value keeps what was typed and shows why under it (research K13).
|
|
17
|
-
*/
|
|
18
|
-
export function DialogPrompt(props: { dialog: Prompt }) {
|
|
19
|
-
const app = useApp();
|
|
20
|
-
const tone = useTone();
|
|
21
|
-
const input = useRef<InputRenderable>(null);
|
|
22
|
-
const d = props.dialog;
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
input.current?.focus();
|
|
25
|
-
}, []);
|
|
26
|
-
// the value typed so far, not the one drawn: text typed in the same tick as enter has not drawn yet
|
|
27
|
-
useCommandLayer("dialog", {
|
|
28
|
-
"dialog.submit": () => {
|
|
29
|
-
const top = app.getState().dialogs.at(-1);
|
|
30
|
-
app.answer(top?.kind === "prompt" ? top.value : d.value);
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
return (
|
|
34
|
-
<Dialog
|
|
35
|
-
title={d.title}
|
|
36
|
-
rows={(inner) => [
|
|
37
|
-
<Line key="label" width={inner} parts={[{ text: d.label, tone: "muted" }]} />,
|
|
38
|
-
<box key="input" flexDirection="row" width={inner} height={1}>
|
|
39
|
-
<text fg={tone("accent")}>{"> "}</text>
|
|
40
|
-
<input
|
|
41
|
-
ref={input}
|
|
42
|
-
value={d.value}
|
|
43
|
-
onInput={(v: string) => app.dispatch({ type: "input", value: v })}
|
|
44
|
-
width={inner - 2}
|
|
45
|
-
/>
|
|
46
|
-
</box>,
|
|
47
|
-
<Line key="error" width={inner} parts={d.error ? [{ text: d.error, tone: "error" }] : []} />,
|
|
48
|
-
<Line key="hint" width={inner} parts={[{ text: "enter to save, esc to cancel", tone: "muted" }]} />,
|
|
49
|
-
]}
|
|
50
|
-
/>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-select.tsx (MIT, © 2025 opencode);
|
|
2
|
-
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
-
import type { InputRenderable } from "@opentui/core";
|
|
4
|
-
import { useTerminalDimensions } from "@opentui/react";
|
|
5
|
-
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
6
|
-
import { useApp } from "../providers/app.tsx";
|
|
7
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
8
|
-
import { useTone, useUi } from "../providers/theme.tsx";
|
|
9
|
-
import { type Dialog as DialogState, isArmed, type SelectOption } from "../state.ts";
|
|
10
|
-
import { glyph } from "../theme.ts";
|
|
11
|
-
import { Dialog, dialogRows } from "./dialog.tsx";
|
|
12
|
-
import { Line, type Part } from "./line.tsx";
|
|
13
|
-
import { windowOf } from "./list.tsx";
|
|
14
|
-
|
|
15
|
-
type Select = Extract<DialogState, { kind: "select" }>;
|
|
16
|
-
|
|
17
|
-
interface Entry {
|
|
18
|
-
key: string;
|
|
19
|
-
heading: string | null;
|
|
20
|
-
option: SelectOption | null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Options matching every word of `text` in their title, group or detail. */
|
|
24
|
-
export function matchOptions(options: SelectOption[], text: string): SelectOption[] {
|
|
25
|
-
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
26
|
-
return options.filter((o) => {
|
|
27
|
-
const hay = `${o.title} ${o.group ?? ""} ${o.detail ?? ""} ${o.cli ?? ""}`.toLowerCase();
|
|
28
|
-
return words.every((w) => hay.includes(w));
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Grouped lines for the options: a heading before each group, Suggested first while not filtering. */
|
|
33
|
-
export function entriesOf(d: Select, text: string): Entry[] {
|
|
34
|
-
const shown = matchOptions(d.options, text);
|
|
35
|
-
const out: Entry[] = [];
|
|
36
|
-
if (!text.trim() && d.suggested?.length) {
|
|
37
|
-
out.push({ key: "h:Suggested", heading: "Suggested", option: null });
|
|
38
|
-
for (const v of d.suggested) {
|
|
39
|
-
const o = d.options.find((x) => x.value === v);
|
|
40
|
-
if (o) out.push({ key: `s:${v}`, heading: null, option: o });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
let group: string | undefined;
|
|
44
|
-
for (const o of shown) {
|
|
45
|
-
if (o.group !== undefined && o.group !== group)
|
|
46
|
-
out.push({ key: `h:${o.group}`, heading: o.group, option: null });
|
|
47
|
-
group = o.group;
|
|
48
|
-
out.push({ key: `o:${o.value}`, heading: null, option: o });
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The picker every list dialog uses (palette, help, profile list, treat-like, failover, default rung):
|
|
55
|
-
* a filter that owns the letters, ↑/↓ or ctrl+p/ctrl+n to move, enter to choose, esc to cancel; ctrl+d
|
|
56
|
-
* twice deletes where the dialog offers it (spec §9.2).
|
|
57
|
-
*/
|
|
58
|
-
export function DialogSelect(props: { dialog: Select }) {
|
|
59
|
-
const app = useApp();
|
|
60
|
-
const ui = useUi();
|
|
61
|
-
const tone = useTone();
|
|
62
|
-
const dims = useTerminalDimensions();
|
|
63
|
-
const input = useRef<InputRenderable>(null);
|
|
64
|
-
const [text, setText] = useState("");
|
|
65
|
-
const [index, setIndex] = useState(0);
|
|
66
|
-
// what a key handler reads: the filter and cursor as they are now, not as drawn (keys in one tick)
|
|
67
|
-
const textRef = useRef("");
|
|
68
|
-
const indexRef = useRef(0);
|
|
69
|
-
const [top, setTop] = useState(0);
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
input.current?.focus();
|
|
72
|
-
}, []);
|
|
73
|
-
const pick = (t: string, i: number) => {
|
|
74
|
-
const entries = entriesOf(props.dialog, t);
|
|
75
|
-
const selectable = entries.flatMap((e, n) => (e.option ? [n] : []));
|
|
76
|
-
const cur = selectable[Math.max(0, Math.min(i, selectable.length - 1))];
|
|
77
|
-
return { entries, selectable, cur, chosen: cur === undefined ? null : (entries[cur]?.option ?? null) };
|
|
78
|
-
};
|
|
79
|
-
const { entries, cur, chosen } = pick(text, index);
|
|
80
|
-
const chosenNow = () => pick(textRef.current, indexRef.current).chosen;
|
|
81
|
-
// every row the panel has above the rule and footer, less the filter and the gap under it
|
|
82
|
-
const height = Math.max(3, Math.min(entries.length, dialogRows(dims.height) - 4));
|
|
83
|
-
const w = windowOf(entries.length, cur ?? 0, height, top);
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (w.top !== top) setTop(w.top);
|
|
86
|
-
}, [w.top, top]);
|
|
87
|
-
// moving the cursor takes back a first ctrl+d (opencode's session list does the same)
|
|
88
|
-
const move = (by: number) => {
|
|
89
|
-
if (app.getState().armed) app.dispatch({ type: "disarm" });
|
|
90
|
-
const n = pick(textRef.current, indexRef.current).selectable.length;
|
|
91
|
-
indexRef.current = Math.max(0, Math.min(n - 1, indexRef.current + by));
|
|
92
|
-
setIndex(indexRef.current);
|
|
93
|
-
};
|
|
94
|
-
const armed = chosen ? isArmed(app.state, "delete", chosen.value, app.clock.now()) : false;
|
|
95
|
-
useCommandLayer("dialog", {
|
|
96
|
-
"dialog.up": () => move(-1),
|
|
97
|
-
"dialog.down": () => move(1),
|
|
98
|
-
"dialog.pageUp": () => move(-height),
|
|
99
|
-
"dialog.pageDown": () => move(height),
|
|
100
|
-
"dialog.submit": () => {
|
|
101
|
-
const c = chosenNow();
|
|
102
|
-
if (c) app.answer(c.value);
|
|
103
|
-
},
|
|
104
|
-
...(props.dialog.deletable
|
|
105
|
-
? {
|
|
106
|
-
"dialog.delete": () => {
|
|
107
|
-
const c = chosenNow();
|
|
108
|
-
if (!c) return;
|
|
109
|
-
if (isArmed(app.getState(), "delete", c.value, app.clock.now())) {
|
|
110
|
-
app.dispatch({ type: "disarm" });
|
|
111
|
-
app.answer(`delete:${c.value}`);
|
|
112
|
-
} else app.dispatch({ type: "arm", what: "delete", target: c.value, at: app.clock.now() });
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
: {}),
|
|
116
|
-
});
|
|
117
|
-
const rows = (inner: number): ReactNode[] => {
|
|
118
|
-
// columns: the title, the detail and the CLI twin, each padded to the widest in the whole list, so
|
|
119
|
-
// they line up as the list scrolls; the CLI column goes when it does not fit, the detail column
|
|
120
|
-
// falls back to the right edge when even it does not
|
|
121
|
-
const wide = (f: (o: SelectOption) => string | undefined) =>
|
|
122
|
-
Math.max(0, ...props.dialog.options.map((o) => Bun.stringWidth(f(o) ?? "")));
|
|
123
|
-
const tw = wide((o) => o.title);
|
|
124
|
-
const dw = wide((o) => o.detail);
|
|
125
|
-
const cw = wide((o) => o.cli);
|
|
126
|
-
const columns = 2 + tw + 2 + dw <= inner;
|
|
127
|
-
const withCli = columns && cw > 0 && 2 + tw + 2 + dw + 2 + cw <= inner;
|
|
128
|
-
const out: ReactNode[] = [
|
|
129
|
-
<box key="filter" flexDirection="row" width={inner} height={1}>
|
|
130
|
-
<text fg={tone("accent")}>{"> "}</text>
|
|
131
|
-
<input
|
|
132
|
-
ref={input}
|
|
133
|
-
value={text}
|
|
134
|
-
placeholder="type to filter"
|
|
135
|
-
onInput={(v: string) => {
|
|
136
|
-
textRef.current = v;
|
|
137
|
-
indexRef.current = 0;
|
|
138
|
-
setText(v);
|
|
139
|
-
setIndex(0);
|
|
140
|
-
if (app.getState().armed) app.dispatch({ type: "disarm" });
|
|
141
|
-
}}
|
|
142
|
-
width={inner - 2}
|
|
143
|
-
/>
|
|
144
|
-
</box>,
|
|
145
|
-
<Line key="gap" width={inner} parts={[]} />,
|
|
146
|
-
];
|
|
147
|
-
if (entries.length === 0) {
|
|
148
|
-
out.push(
|
|
149
|
-
<Line
|
|
150
|
-
key="none"
|
|
151
|
-
width={inner}
|
|
152
|
-
parts={[{ text: props.dialog.options.length ? "No match" : props.dialog.empty, tone: "muted" }]}
|
|
153
|
-
/>,
|
|
154
|
-
);
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
entries.slice(w.top, w.top + height).forEach((e, i) => {
|
|
158
|
-
const at = w.top + i;
|
|
159
|
-
if (i === 0 && w.above > 0)
|
|
160
|
-
return out.push(
|
|
161
|
-
<Line
|
|
162
|
-
key="up"
|
|
163
|
-
width={inner}
|
|
164
|
-
parts={[{ text: `${glyph("up", ui.plain)} ${w.above + 1} more`, tone: "muted" }]}
|
|
165
|
-
/>,
|
|
166
|
-
);
|
|
167
|
-
if (i === height - 1 && w.below > 0)
|
|
168
|
-
return out.push(
|
|
169
|
-
<Line
|
|
170
|
-
key="down"
|
|
171
|
-
width={inner}
|
|
172
|
-
parts={[{ text: `${glyph("down", ui.plain)} ${w.below + 1} more`, tone: "muted" }]}
|
|
173
|
-
/>,
|
|
174
|
-
);
|
|
175
|
-
if (!e.option)
|
|
176
|
-
return out.push(
|
|
177
|
-
<Line key={e.key} width={inner} parts={[{ text: e.heading ?? "", tone: "accent", bold: true }]} />,
|
|
178
|
-
);
|
|
179
|
-
const o = e.option;
|
|
180
|
-
const selected = at === cur;
|
|
181
|
-
const label = selected && armed ? "press ctrl+d again to delete" : o.title;
|
|
182
|
-
const detail = o.detail ?? "";
|
|
183
|
-
const left: Part[] = [
|
|
184
|
-
{ text: `${o.current ? glyph("current", ui.plain) : " "} ` },
|
|
185
|
-
{ text: label, tone: selected && armed ? "warning" : undefined },
|
|
186
|
-
];
|
|
187
|
-
const pad = (t: string, n: number) => " ".repeat(Math.max(0, n - Bun.stringWidth(t)));
|
|
188
|
-
const right: Part[] = columns
|
|
189
|
-
? [
|
|
190
|
-
{ text: `${pad(label, tw)} ` },
|
|
191
|
-
{ text: withCli ? `${detail}${pad(detail, dw)} ` : detail, tone: "muted" },
|
|
192
|
-
{ text: withCli ? (o.cli ?? "") : "", tone: "muted" },
|
|
193
|
-
]
|
|
194
|
-
: [
|
|
195
|
-
{
|
|
196
|
-
text: " ".repeat(Math.max(1, inner - Bun.stringWidth(` ${label}`) - Bun.stringWidth(detail))),
|
|
197
|
-
},
|
|
198
|
-
{ text: detail, tone: "muted" },
|
|
199
|
-
];
|
|
200
|
-
out.push(<Line key={e.key} width={inner} selected={selected} parts={[...left, ...right]} />);
|
|
201
|
-
});
|
|
202
|
-
return out;
|
|
203
|
-
};
|
|
204
|
-
return (
|
|
205
|
-
<Dialog
|
|
206
|
-
title={props.dialog.title}
|
|
207
|
-
// the palette's CLI column needs the wide panel
|
|
208
|
-
size={props.dialog.options.length > 40 || props.dialog.options.some((o) => o.cli) ? "large" : "medium"}
|
|
209
|
-
rows={rows}
|
|
210
|
-
/>
|
|
211
|
-
);
|
|
212
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog.tsx (MIT, © 2025 opencode);
|
|
2
|
-
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
-
import { useTerminalDimensions } from "@opentui/react";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
-
import { DIALOG_WIDTH, type DialogSize } from "../theme.ts";
|
|
7
|
-
import { Line } from "./line.tsx";
|
|
8
|
-
|
|
9
|
-
/** The panel's inner width: its size, clamped to the terminal less a margin, less the padding. */
|
|
10
|
-
const dialogInner = (size: DialogSize, termWidth: number): number =>
|
|
11
|
-
Math.max(10, Math.min(DIALOG_WIDTH[size], termWidth - 2) - 4);
|
|
12
|
-
|
|
13
|
-
/** The rows a quarter down the screen leaves for `rows`: the height less the top padding, the
|
|
14
|
-
* blank line, the title, the gap under it and the blank line at the bottom. */
|
|
15
|
-
export const dialogRows = (termHeight: number): number =>
|
|
16
|
-
Math.max(1, termHeight - Math.floor(termHeight / 4) - 4);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Spec §9.3: a raised panel over a dimmed backdrop, a quarter down the screen, 60, 88 or 116 columns
|
|
20
|
-
* wide, with no border; the title bold on the left and a muted `esc` on the right. `rows` returns one
|
|
21
|
-
* node per line, each `inner` columns wide (Line pads them); the panel pads every line with spaces on
|
|
22
|
-
* both sides, so it hides what is beneath even without colour.
|
|
23
|
-
*/
|
|
24
|
-
export function Dialog(props: { title: string; size?: DialogSize; rows: (inner: number) => ReactNode[] }) {
|
|
25
|
-
const dims = useTerminalDimensions();
|
|
26
|
-
const ui = useUi();
|
|
27
|
-
const tone = useTone();
|
|
28
|
-
const size = props.size ?? "medium";
|
|
29
|
-
const inner = dialogInner(size, dims.width);
|
|
30
|
-
const lines: ReactNode[] = [
|
|
31
|
-
<Line key="top" width={inner} parts={[]} />,
|
|
32
|
-
<Line
|
|
33
|
-
key="title"
|
|
34
|
-
width={inner}
|
|
35
|
-
parts={[
|
|
36
|
-
{ text: props.title, bold: true },
|
|
37
|
-
{ text: " ".repeat(Math.max(1, inner - Bun.stringWidth(props.title) - 3)) },
|
|
38
|
-
{ text: "esc", tone: "muted" },
|
|
39
|
-
]}
|
|
40
|
-
/>,
|
|
41
|
-
<Line key="gap" width={inner} parts={[]} />,
|
|
42
|
-
...props.rows(inner),
|
|
43
|
-
<Line key="bottom" width={inner} parts={[]} />,
|
|
44
|
-
];
|
|
45
|
-
return (
|
|
46
|
-
<box
|
|
47
|
-
position="absolute"
|
|
48
|
-
top={0}
|
|
49
|
-
left={0}
|
|
50
|
-
zIndex={3000}
|
|
51
|
-
width={dims.width}
|
|
52
|
-
height={dims.height}
|
|
53
|
-
alignItems="center"
|
|
54
|
-
paddingTop={Math.floor(dims.height / 4)}
|
|
55
|
-
backgroundColor={ui.color ? `${tone("backdrop")}99` : undefined}
|
|
56
|
-
>
|
|
57
|
-
<box width={inner + 4} flexDirection="column" backgroundColor={tone("surfaceRaised")}>
|
|
58
|
-
{lines.map((line, i) => (
|
|
59
|
-
<box key={i} flexDirection="row" height={1}>
|
|
60
|
-
<text>{" "}</text>
|
|
61
|
-
{line}
|
|
62
|
-
<text>{" "}</text>
|
|
63
|
-
</box>
|
|
64
|
-
))}
|
|
65
|
-
</box>
|
|
66
|
-
</box>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { RGBA, TextAttributes } from "@opentui/core";
|
|
2
|
-
import { useTone, useUi } from "../providers/theme.tsx";
|
|
3
|
-
import { ascii, truncateEnd, width as widthOf } from "../text.ts";
|
|
4
|
-
import type { Token } from "../theme.ts";
|
|
5
|
-
|
|
6
|
-
/** One run of text on a line, in a token's colour. */
|
|
7
|
-
export interface Part {
|
|
8
|
-
text: string;
|
|
9
|
-
tone?: Token;
|
|
10
|
-
bold?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** The parts cut to `max` columns at the end (an ellipsis on the last one kept). */
|
|
14
|
-
export function clip(parts: Part[], max: number, plain: boolean): Part[] {
|
|
15
|
-
const out: Part[] = [];
|
|
16
|
-
let used = 0;
|
|
17
|
-
for (const p of parts) {
|
|
18
|
-
const w = widthOf(p.text);
|
|
19
|
-
if (used + w <= max) {
|
|
20
|
-
out.push(p);
|
|
21
|
-
used += w;
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
const room = max - used;
|
|
25
|
-
if (room > 0) out.push({ ...p, text: truncateEnd(p.text, room, plain) });
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* The one line primitive every row is drawn with: exactly `width` columns. A selected line is filled
|
|
33
|
-
* with the accent and bold (spec §9.3), or drawn in reverse video without colour; under --plain every
|
|
34
|
-
* character is ASCII.
|
|
35
|
-
*/
|
|
36
|
-
export function Line(props: {
|
|
37
|
-
parts: Part[];
|
|
38
|
-
width: number;
|
|
39
|
-
selected?: boolean;
|
|
40
|
-
dim?: boolean;
|
|
41
|
-
/**
|
|
42
|
-
* Paints every cell, spaces included, so nothing beneath shows through (a toast over the tabs): the
|
|
43
|
-
* raised surface with colour, reverse video without, since a plain space paints nothing.
|
|
44
|
-
*/
|
|
45
|
-
opaque?: boolean;
|
|
46
|
-
}) {
|
|
47
|
-
const ui = useUi();
|
|
48
|
-
const tone = useTone();
|
|
49
|
-
const parts = clip(
|
|
50
|
-
ui.plain ? props.parts.map((p) => ({ ...p, text: ascii(p.text) })) : props.parts,
|
|
51
|
-
props.width,
|
|
52
|
-
ui.plain,
|
|
53
|
-
);
|
|
54
|
-
const used = parts.reduce((n, p) => n + widthOf(p.text), 0);
|
|
55
|
-
const pad = " ".repeat(Math.max(0, props.width - used));
|
|
56
|
-
const sel = props.selected === true;
|
|
57
|
-
const fg = (p: Part) =>
|
|
58
|
-
sel ? tone("selectionText") : props.dim ? tone("muted") : p.tone ? tone(p.tone) : undefined;
|
|
59
|
-
// without colour, the terminal's own default background (SGR 49, no colour) makes a space paint
|
|
60
|
-
const bg = sel
|
|
61
|
-
? tone("selection")
|
|
62
|
-
: props.opaque
|
|
63
|
-
? ui.color
|
|
64
|
-
? tone("surfaceRaised")
|
|
65
|
-
: RGBA.defaultBackground()
|
|
66
|
-
: undefined;
|
|
67
|
-
const inverse = (sel || props.opaque === true) && !ui.color ? TextAttributes.INVERSE : 0;
|
|
68
|
-
const attrs = (p: Part) => (sel || p.bold ? TextAttributes.BOLD : 0) | inverse;
|
|
69
|
-
return (
|
|
70
|
-
<text wrapMode="none">
|
|
71
|
-
{parts.map((p, i) => (
|
|
72
|
-
<span key={i} fg={fg(p)} bg={bg} attributes={attrs(p)}>
|
|
73
|
-
{p.text}
|
|
74
|
-
</span>
|
|
75
|
-
))}
|
|
76
|
-
<span bg={bg} attributes={inverse}>
|
|
77
|
-
{pad}
|
|
78
|
-
</span>
|
|
79
|
-
</text>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A full-width rule (`─`, or `-` in plain). */
|
|
84
|
-
export function Rule(props: { width: number }) {
|
|
85
|
-
const ui = useUi();
|
|
86
|
-
const tone = useTone();
|
|
87
|
-
return (
|
|
88
|
-
<text wrapMode="none" fg={tone("border")}>
|
|
89
|
-
{(ui.plain ? "-" : "─").repeat(Math.max(0, props.width))}
|
|
90
|
-
</text>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import type { InputRenderable } from "@opentui/core";
|
|
2
|
-
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { useBack } from "../providers/app.tsx";
|
|
4
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
5
|
-
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
-
import { glyph } from "../theme.ts";
|
|
7
|
-
import { Line } from "./line.tsx";
|
|
8
|
-
|
|
9
|
-
export interface ListItem {
|
|
10
|
-
key: string;
|
|
11
|
-
/** headings and continuation lines are skipped by the cursor */
|
|
12
|
-
selectable: boolean;
|
|
13
|
-
render(selected: boolean, width: number): ReactNode;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface Window {
|
|
17
|
-
top: number;
|
|
18
|
-
/** rows hidden above and below the window */
|
|
19
|
-
above: number;
|
|
20
|
-
below: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Which rows of `total` fit in `height` so that `index` shows, moving no more than needed from `top`.
|
|
27
|
-
* With 3 rows or more, a hint line (`↑ 3 more`) takes the first or the last row when rows are hidden there.
|
|
28
|
-
*/
|
|
29
|
-
export function windowOf(total: number, index: number, height: number, top: number): Window {
|
|
30
|
-
if (total <= height) return { top: 0, above: 0, below: 0 };
|
|
31
|
-
const i = clamp(index, 0, total - 1);
|
|
32
|
-
const maxTop = total - height;
|
|
33
|
-
let t = clamp(top, 0, maxTop);
|
|
34
|
-
if (height >= 3) {
|
|
35
|
-
const first = t + (t > 0 ? 1 : 0);
|
|
36
|
-
const last = t + height - 1 - (t < maxTop ? 1 : 0);
|
|
37
|
-
if (i < first) t = i === 0 ? 0 : i - 1;
|
|
38
|
-
else if (i > last) t = i === total - 1 ? maxTop : i - height + 2;
|
|
39
|
-
} else if (i < t) t = i;
|
|
40
|
-
else if (i > t + height - 1) t = i - height + 1;
|
|
41
|
-
t = clamp(t, 0, maxTop);
|
|
42
|
-
return height >= 3 ? { top: t, above: t, below: maxTop - t } : { top: t, above: 0, below: 0 };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The selectable row `by` selectable rows away from row `from` (clamped to the ends). From a row that
|
|
47
|
-
* cannot be selected, the next selectable row is one step down and the previous one a step up; `by` 0
|
|
48
|
-
* snaps to the next one (else the last).
|
|
49
|
-
*/
|
|
50
|
-
export function step(items: readonly { selectable: boolean }[], from: number, by: number): number {
|
|
51
|
-
const sel = items.flatMap((it, i) => (it.selectable ? [i] : []));
|
|
52
|
-
if (sel.length === 0) return -1;
|
|
53
|
-
const at = sel.indexOf(from);
|
|
54
|
-
if (at >= 0) return sel[clamp(at + by, 0, sel.length - 1)] as number;
|
|
55
|
-
const next = sel.findIndex((i) => i > from);
|
|
56
|
-
if (by === 0) return sel[next < 0 ? sel.length - 1 : next] as number;
|
|
57
|
-
const pos = by > 0 ? (next < 0 ? sel.length - 1 : next + by - 1) : (next < 0 ? sel.length : next) + by;
|
|
58
|
-
return sel[clamp(pos, 0, sel.length - 1)] as number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* A view's selected row: `selected` is what this render draws, `current()` what a key handler must read
|
|
63
|
-
* (a key in the same tick may have moved it already). `initial`: the row it starts on (a list snaps a key it
|
|
64
|
-
* does not hold to its first row).
|
|
65
|
-
*/
|
|
66
|
-
export function useSelected(initial: string | null = null): {
|
|
67
|
-
selected: string | null;
|
|
68
|
-
select(key: string): void;
|
|
69
|
-
current(): string | null;
|
|
70
|
-
} {
|
|
71
|
-
const [selected, setSelected] = useState<string | null>(initial);
|
|
72
|
-
const ref = useRef<string | null>(null);
|
|
73
|
-
ref.current = selected;
|
|
74
|
-
return {
|
|
75
|
-
selected,
|
|
76
|
-
select(key) {
|
|
77
|
-
ref.current = key;
|
|
78
|
-
setSelected(key);
|
|
79
|
-
},
|
|
80
|
-
current: () => ref.current,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A list or tree pane (spec §9.2 lists: arrows plus j/k, paging, Home/End, `/` filter). Controlled: the
|
|
86
|
-
* view owns the selected key and the filter text; the list moves them and draws the visible window with
|
|
87
|
-
* `↑ n more` / `↓ n more` hints instead of a scrollbar.
|
|
88
|
-
*/
|
|
89
|
-
export function List(props: {
|
|
90
|
-
items: ListItem[];
|
|
91
|
-
selected: string | null;
|
|
92
|
-
onSelect(key: string): void;
|
|
93
|
-
width: number;
|
|
94
|
-
height: number;
|
|
95
|
-
/** the filter text, null when no filter is open */
|
|
96
|
-
filter?: string | null;
|
|
97
|
-
onFilter?(text: string | null): void;
|
|
98
|
-
/** the muted line shown when there are no rows */
|
|
99
|
-
empty: string;
|
|
100
|
-
}) {
|
|
101
|
-
const ui = useUi();
|
|
102
|
-
const tone = useTone();
|
|
103
|
-
const input = useRef<InputRenderable>(null);
|
|
104
|
-
const [top, setTop] = useState(0);
|
|
105
|
-
// the command that opens the filter is drawn at once (useCommandLayer), so this runs before the next key
|
|
106
|
-
const [focusTick, setFocusTick] = useState(0);
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
if (focusTick > 0) input.current?.focus();
|
|
109
|
-
}, [focusTick]);
|
|
110
|
-
const filtering = props.filter !== undefined && props.filter !== null;
|
|
111
|
-
const bodyHeight = Math.max(1, props.height - (filtering ? 1 : 0));
|
|
112
|
-
const indexOf = (key: string | null) => {
|
|
113
|
-
const i = props.items.findIndex((it) => it.key === key);
|
|
114
|
-
return i < 0 || !props.items[i]?.selectable ? step(props.items, Math.max(0, i), 0) : i;
|
|
115
|
-
};
|
|
116
|
-
const index = indexOf(props.selected);
|
|
117
|
-
// the row a key handler moves from: the one the last key chose, which may not be drawn yet
|
|
118
|
-
const selectedRef = useRef(props.selected);
|
|
119
|
-
selectedRef.current = props.selected;
|
|
120
|
-
const choose = (i: number) => {
|
|
121
|
-
const it = props.items[i];
|
|
122
|
-
if (!it) return;
|
|
123
|
-
selectedRef.current = it.key;
|
|
124
|
-
props.onSelect(it.key);
|
|
125
|
-
};
|
|
126
|
-
const w = windowOf(props.items.length, Math.max(0, index), bodyHeight, top);
|
|
127
|
-
useEffect(() => {
|
|
128
|
-
if (w.top !== top) setTop(w.top);
|
|
129
|
-
}, [w.top, top]);
|
|
130
|
-
// the cursor snaps to a selectable row, judged on the selection now: this effect can run after a key
|
|
131
|
-
// already moved it (a key that lands between a draw and its effects), and must not take that back
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
const it = props.items[indexOf(selectedRef.current)];
|
|
134
|
-
if (it && it.key !== selectedRef.current) choose(indexOf(selectedRef.current));
|
|
135
|
-
});
|
|
136
|
-
const move = (by: number) => choose(step(props.items, Math.max(0, indexOf(selectedRef.current)), by));
|
|
137
|
-
const page = Math.max(1, bodyHeight - 2);
|
|
138
|
-
useCommandLayer("list", {
|
|
139
|
-
"list.up": () => move(-1),
|
|
140
|
-
"list.down": () => move(1),
|
|
141
|
-
"list.pageUp": () => move(-page),
|
|
142
|
-
"list.pageDown": () => move(page),
|
|
143
|
-
"list.first": () => choose(step(props.items, 0, 0)),
|
|
144
|
-
"list.last": () => choose(step(props.items, props.items.length - 1, 0)),
|
|
145
|
-
...(props.onFilter
|
|
146
|
-
? {
|
|
147
|
-
"list.filter": () => {
|
|
148
|
-
props.onFilter?.(props.filter ?? "");
|
|
149
|
-
setFocusTick((n) => n + 1);
|
|
150
|
-
},
|
|
151
|
-
}
|
|
152
|
-
: {}),
|
|
153
|
-
});
|
|
154
|
-
useCommandLayer("filter", { "filter.accept": () => input.current?.blur() });
|
|
155
|
-
useBack(filtering, "input", () => {
|
|
156
|
-
input.current?.blur();
|
|
157
|
-
props.onFilter?.(null);
|
|
158
|
-
});
|
|
159
|
-
const rows: ReactNode[] = [];
|
|
160
|
-
const visible = props.items.slice(w.top, w.top + bodyHeight);
|
|
161
|
-
visible.forEach((it, i) => {
|
|
162
|
-
const hintAbove = i === 0 && w.above > 0;
|
|
163
|
-
const hintBelow = i === visible.length - 1 && w.below > 0;
|
|
164
|
-
if (hintAbove || hintBelow) {
|
|
165
|
-
const n = hintAbove ? w.above + 1 : w.below + 1;
|
|
166
|
-
rows.push(
|
|
167
|
-
<Line
|
|
168
|
-
key={`hint-${hintAbove ? "up" : "down"}`}
|
|
169
|
-
width={props.width}
|
|
170
|
-
parts={[{ text: ` ${glyph(hintAbove ? "up" : "down", ui.plain)} ${n} more`, tone: "muted" }]}
|
|
171
|
-
/>,
|
|
172
|
-
);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
rows.push(<box key={it.key}>{it.render(w.top + i === index, props.width)}</box>);
|
|
176
|
-
});
|
|
177
|
-
if (props.items.length === 0)
|
|
178
|
-
rows.push(<Line key="empty" width={props.width} parts={[{ text: ` ${props.empty}`, tone: "muted" }]} />);
|
|
179
|
-
const matches = props.items.filter((it) => it.selectable).length;
|
|
180
|
-
return (
|
|
181
|
-
<box flexDirection="column" width={props.width} height={props.height}>
|
|
182
|
-
{filtering ? (
|
|
183
|
-
<box flexDirection="row" width={props.width} height={1}>
|
|
184
|
-
<text fg={tone("accent")}>{" / "}</text>
|
|
185
|
-
<input
|
|
186
|
-
ref={input}
|
|
187
|
-
value={props.filter ?? ""}
|
|
188
|
-
onInput={(v: string) => props.onFilter?.(v)}
|
|
189
|
-
width={Math.max(1, props.width - 18)}
|
|
190
|
-
/>
|
|
191
|
-
<text fg={tone("muted")}>{` ${matches} ${matches === 1 ? "match" : "matches"}`.padEnd(15)}</text>
|
|
192
|
-
</box>
|
|
193
|
-
) : null}
|
|
194
|
-
{rows}
|
|
195
|
-
</box>
|
|
196
|
-
);
|
|
197
|
-
}
|