catherd-cli 1.4.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 +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- 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 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- 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 +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- 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 +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -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 +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -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 +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- 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/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- 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 +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- 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-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -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 -36
- 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 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- 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 -373
- 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 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- 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 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- 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 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import { useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
2
|
-
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { errorMessage } from "../../../domain/errors.ts";
|
|
4
|
-
import {
|
|
5
|
-
applyPatch,
|
|
6
|
-
type Change,
|
|
7
|
-
diffProfiles,
|
|
8
|
-
type ProfileDoc,
|
|
9
|
-
patchBetween,
|
|
10
|
-
resolveProfile,
|
|
11
|
-
} from "../../../domain/profile.ts";
|
|
12
|
-
import { repairs, type Validation } from "../../../domain/profile-rules.ts";
|
|
13
|
-
import type { Effects } from "../effects.ts";
|
|
14
|
-
import { useApp } from "../providers/app.tsx";
|
|
15
|
-
import { useData, useLoad } from "../providers/data.tsx";
|
|
16
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
17
|
-
import { useUi } from "../providers/theme.tsx";
|
|
18
|
-
import { withStaged } from "../profile-tree.ts";
|
|
19
|
-
import { type Dialog as DialogState, type Draft, isSaving } from "../state.ts";
|
|
20
|
-
import { wrap } from "../text.ts";
|
|
21
|
-
import { glyph } from "../theme.ts";
|
|
22
|
-
import { Buttons } from "../widgets/dialog-confirm.tsx";
|
|
23
|
-
import { Dialog, dialogRows } from "../widgets/dialog.tsx";
|
|
24
|
-
import { Line, type Part } from "../widgets/line.tsx";
|
|
25
|
-
|
|
26
|
-
type Save = Extract<DialogState, { kind: "save" }>;
|
|
27
|
-
|
|
28
|
-
/** Said when a Save found the file changed since the dialog showed what saving would do. */
|
|
29
|
-
const CHANGED_ON_DISK = "the profile changed on disk — check the changes and choose again";
|
|
30
|
-
|
|
31
|
-
const show = (v: unknown): string =>
|
|
32
|
-
v === null || v === undefined ? "none" : Array.isArray(v) ? v.join(", ") : String(v);
|
|
33
|
-
|
|
34
|
-
/** What saving a draft would do: the changed fields, staged treat-likes, issues, and agent changes. */
|
|
35
|
-
export interface SavePreview {
|
|
36
|
-
changes: Change[];
|
|
37
|
-
treatLikes: [string, string][];
|
|
38
|
-
validation: Validation;
|
|
39
|
-
/** spec 1.2 §6.2: the save leaves errors but removes one of the stored profile's and adds none */
|
|
40
|
-
repair: boolean;
|
|
41
|
-
agentsAdded: string[];
|
|
42
|
-
agentsRemoved: string[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The save is a patch over the file as it is when it is written (Ruling 7), so the preview applies the
|
|
47
|
-
* draft's patch to the file as it is now, not to the copy the draft began from.
|
|
48
|
-
*/
|
|
49
|
-
function previewSave(
|
|
50
|
-
d: Draft,
|
|
51
|
-
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
|
|
52
|
-
now: ProfileDoc = fx.readProfile(d.name),
|
|
53
|
-
): SavePreview {
|
|
54
|
-
const before = resolveProfile(now, d.name, d.host);
|
|
55
|
-
const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name, d.host);
|
|
56
|
-
const { catalog } = fx.catalog(after.billing);
|
|
57
|
-
const a = new Set(fx.agents(before));
|
|
58
|
-
const b = new Set(fx.agents(after));
|
|
59
|
-
const staged = withStaged(catalog, d.treatLikes);
|
|
60
|
-
const validation = fx.validate(after, staged);
|
|
61
|
-
return {
|
|
62
|
-
changes: diffProfiles(before, after),
|
|
63
|
-
treatLikes: Object.entries(d.treatLikes),
|
|
64
|
-
validation,
|
|
65
|
-
repair: validation.errors.length > 0 && repairs(fx.validate(before, staged), validation),
|
|
66
|
-
agentsAdded: [...b].filter((x) => !a.has(x)),
|
|
67
|
-
agentsRemoved: [...a].filter((x) => !b.has(x)),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** A block of the save dialog: heading lines, then items of one or more lines each. */
|
|
72
|
-
interface Section {
|
|
73
|
-
head: Part[][];
|
|
74
|
-
items: Part[][][];
|
|
75
|
-
/**
|
|
76
|
-
* How long the section holds on when rows run short (spec §9.2, Ruling 8):
|
|
77
|
-
* "filler" (changes, warnings, agent files) loses items first, then goes whole;
|
|
78
|
-
* "note" (what applies when) goes whole, never in part;
|
|
79
|
-
* "error" (validation errors) loses items only after both, and keeps its first;
|
|
80
|
-
* "failure" (why the last save failed) is never cut.
|
|
81
|
-
*/
|
|
82
|
-
hold: "filler" | "note" | "error" | "failure";
|
|
83
|
-
/** the line that ends a cut section; "… and N more" when unset */
|
|
84
|
-
more?: (n: number) => string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const HOLD = { filler: 0, note: 1, error: 2, failure: 3 } as const;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* The sections' lines in at most `rows` lines. Empty sections are left out. Over budget, in turn:
|
|
91
|
-
* the longest filler section loses its last item (a cut section ends with one muted "… and N more"
|
|
92
|
-
* line); filler and note sections go whole, the least-held first; error sections lose items down
|
|
93
|
-
* to their first; last, lines come off the end of every section but the failure, then the failure.
|
|
94
|
-
*/
|
|
95
|
-
export function fitSections(sections: Section[], rows: number): Part[][] {
|
|
96
|
-
const shown = sections.filter((s) => s.items.length > 0);
|
|
97
|
-
const keep = shown.map((s) => s.items.length);
|
|
98
|
-
const gone = shown.map(() => false);
|
|
99
|
-
const size = (i: number) => {
|
|
100
|
-
if (gone[i]) return 0;
|
|
101
|
-
const s = shown[i]!;
|
|
102
|
-
const body = s.items.slice(0, keep[i]).reduce((n, it) => n + it.length, 0);
|
|
103
|
-
return s.head.length + body + (keep[i]! < s.items.length ? 1 : 0);
|
|
104
|
-
};
|
|
105
|
-
const total = () => shown.reduce((n, _, i) => n + size(i), 0);
|
|
106
|
-
/** the largest live section `ok` accepts, or -1 */
|
|
107
|
-
const largest = (ok: (s: Section, i: number) => boolean) => {
|
|
108
|
-
let at = -1;
|
|
109
|
-
for (const [i, s] of shown.entries()) if (!gone[i] && ok(s, i) && (at < 0 || size(i) > size(at))) at = i;
|
|
110
|
-
return at;
|
|
111
|
-
};
|
|
112
|
-
for (let at; total() > rows && (at = largest((s, i) => s.hold === "filler" && keep[i]! > 0)) >= 0;)
|
|
113
|
-
keep[at]! -= 1;
|
|
114
|
-
for (const hold of ["filler", "note"] as const)
|
|
115
|
-
for (let at; total() > rows && (at = largest((s) => s.hold === hold)) >= 0;) gone[at] = true;
|
|
116
|
-
for (let at; total() > rows && (at = largest((s, i) => s.hold === "error" && keep[i]! > 1)) >= 0;)
|
|
117
|
-
keep[at]! -= 1;
|
|
118
|
-
const lines = shown.map((s, i) => {
|
|
119
|
-
if (gone[i]) return [];
|
|
120
|
-
const more = s.items.length - keep[i]!;
|
|
121
|
-
const tail = s.more?.(more) ?? `… and ${more} more`;
|
|
122
|
-
return [
|
|
123
|
-
...s.head,
|
|
124
|
-
...s.items.slice(0, keep[i]).flat(),
|
|
125
|
-
...(more > 0 ? [[{ text: tail, tone: "muted" as const }]] : []),
|
|
126
|
-
];
|
|
127
|
-
});
|
|
128
|
-
// still over: the blank lines between sections go, then lines come off the end of the least-held
|
|
129
|
-
// sections, the failure last
|
|
130
|
-
let over = lines.reduce((n, l) => n + l.length, 0) - Math.max(0, rows);
|
|
131
|
-
const order = shown.map((_, i) => i).sort((x, y) => HOLD[shown[x]!.hold] - HOLD[shown[y]!.hold]);
|
|
132
|
-
for (const i of order)
|
|
133
|
-
while (over > 0 && lines[i]![0]?.length === 0 && shown[i]!.head.length > 0 && !gone[i]) {
|
|
134
|
-
lines[i]!.shift();
|
|
135
|
-
over -= 1;
|
|
136
|
-
}
|
|
137
|
-
for (const i of order) {
|
|
138
|
-
const off = Math.min(over, lines[i]!.length);
|
|
139
|
-
lines[i]!.splice(lines[i]!.length - off, off);
|
|
140
|
-
over -= off;
|
|
141
|
-
}
|
|
142
|
-
return lines.flat();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Spec §9.2: ctrl+s opens this, never enter. It shows the diff, the agent-file changes and what applies
|
|
147
|
-
* when, with Save, Save & make active and Cancel; errors leave only Cancel.
|
|
148
|
-
*/
|
|
149
|
-
export function SaveDialog(props: { dialog: Save }) {
|
|
150
|
-
const app = useApp();
|
|
151
|
-
const data = useData();
|
|
152
|
-
const ui = useUi();
|
|
153
|
-
const dims = useTerminalDimensions();
|
|
154
|
-
const renderer = useRenderer();
|
|
155
|
-
const name = props.dialog.purpose.name;
|
|
156
|
-
const draft = app.state.drafts[name];
|
|
157
|
-
// the preview reads the catalog and agent files: off the render path, so a read that throws is shown
|
|
158
|
-
// here as the reason nothing can be saved, not as a broken screen
|
|
159
|
-
const key = draft ? JSON.stringify([draft.doc, draft.treatLikes]) : "";
|
|
160
|
-
const loaded = useLoad(() => (draft ? previewSave(draft, app.effects) : null), key);
|
|
161
|
-
// a Save re-reads the file: when that changed what saving would do, the dialog shows the new preview
|
|
162
|
-
// in place of the loaded one and asks again (another process may have written the profile meanwhile)
|
|
163
|
-
const [fresh, setFresh] = useState<{ key: string; value: SavePreview | null; error: string | null } | null>(
|
|
164
|
-
null,
|
|
165
|
-
);
|
|
166
|
-
const shown = fresh?.key === key ? fresh : loaded;
|
|
167
|
-
const preview = shown.value;
|
|
168
|
-
const error = shown.error;
|
|
169
|
-
// what the dialog shows now, for keys in one burst
|
|
170
|
-
const shownRef = useRef<SavePreview | null>(preview);
|
|
171
|
-
shownRef.current = preview;
|
|
172
|
-
// set when a Save replaces the preview, cleared by the first frame drawn to the terminal after the
|
|
173
|
-
// commit that holds the new one: a Save between the two (a later key in the same burst) would answer a
|
|
174
|
-
// preview nobody has seen. React may commit between two keys of one burst, so a commit is not enough.
|
|
175
|
-
const unseenRef = useRef(false);
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (!unseenRef.current) return;
|
|
178
|
-
const seen = () => {
|
|
179
|
-
unseenRef.current = false;
|
|
180
|
-
};
|
|
181
|
-
renderer.once("frame", seen);
|
|
182
|
-
renderer.requestRender();
|
|
183
|
-
return () => {
|
|
184
|
-
renderer.off("frame", seen);
|
|
185
|
-
};
|
|
186
|
-
}, [fresh, renderer]);
|
|
187
|
-
const [changed, setChanged] = useState(false);
|
|
188
|
-
/** reads the file again; a preview other than the shown one replaces it and asks again (false) */
|
|
189
|
-
const recheck = (): { same: boolean; now: ProfileDoc | null } => {
|
|
190
|
-
const d = app.getState().drafts[name];
|
|
191
|
-
let doc: ProfileDoc | null = null;
|
|
192
|
-
let now: SavePreview | null = null;
|
|
193
|
-
let failed: string | null = null;
|
|
194
|
-
try {
|
|
195
|
-
doc = d ? app.effects.readProfile(name) : null;
|
|
196
|
-
now = d && doc ? previewSave(d, app.effects, doc) : null;
|
|
197
|
-
} catch (e) {
|
|
198
|
-
failed = errorMessage(e);
|
|
199
|
-
}
|
|
200
|
-
if (failed === null && JSON.stringify(now) === JSON.stringify(shownRef.current))
|
|
201
|
-
return { same: true, now: doc };
|
|
202
|
-
shownRef.current = now;
|
|
203
|
-
unseenRef.current = true;
|
|
204
|
-
setFresh({ key, value: now, error: failed });
|
|
205
|
-
setChanged(true);
|
|
206
|
-
return { same: false, now: doc };
|
|
207
|
-
};
|
|
208
|
-
// spec 1.2 §6.2: a save that repairs part of an invalid profile is offered; any other error leaves Cancel
|
|
209
|
-
const blocked = error !== null || ((preview?.validation.errors.length ?? 0) > 0 && !preview?.repair);
|
|
210
|
-
// spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
|
|
211
|
-
const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
|
|
212
|
-
const [focused, setFocusedState] = useState(0);
|
|
213
|
-
// keys in one burst read the focus as it is now, not as it was drawn
|
|
214
|
-
const focusedRef = useRef(0);
|
|
215
|
-
const setFocused = (f: number) => {
|
|
216
|
-
focusedRef.current = f;
|
|
217
|
-
setFocusedState(f);
|
|
218
|
-
};
|
|
219
|
-
useCommandLayer("dialog", {
|
|
220
|
-
"dialog.left": () => setFocused(Math.max(0, focusedRef.current - 1)),
|
|
221
|
-
"dialog.right": () => setFocused(Math.min(labels.length - 1, focusedRef.current + 1)),
|
|
222
|
-
"dialog.submit": () => {
|
|
223
|
-
// a save that is writing takes no answer
|
|
224
|
-
if (isSaving(app.getState())) return;
|
|
225
|
-
const label = labels[Math.min(focusedRef.current, labels.length - 1)];
|
|
226
|
-
// nothing is saved before its diff has been shown
|
|
227
|
-
if (label !== "Cancel" && (!shownRef.current || unseenRef.current)) return;
|
|
228
|
-
if (label === "Cancel") return app.dispatch({ type: "close" });
|
|
229
|
-
// only the save the dialog shows is made: the file is read again now, and a different preview
|
|
230
|
-
// replaces the shown one and asks again instead of saving
|
|
231
|
-
const { same, now } = recheck();
|
|
232
|
-
if (!same || !now) return;
|
|
233
|
-
// the save writes only over the file this preview read: one changed while it writes (another
|
|
234
|
-
// process, while a treat-like waits for the catalog lock) is refused, and the dialog asks again
|
|
235
|
-
const extra = {
|
|
236
|
-
shown: now,
|
|
237
|
-
// the notice shows even when the change is one this preview does not show (another field)
|
|
238
|
-
changed: () => {
|
|
239
|
-
if (recheck().same) setChanged(true);
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
app.answer(label === "Save" ? "save" : "activate", extra);
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
const rows = (inner: number): ReactNode[] => {
|
|
246
|
-
const out: ReactNode[] = [];
|
|
247
|
-
const line = (key: string, parts: Part[]) => out.push(<Line key={key} width={inner} parts={parts} />);
|
|
248
|
-
if (error !== null) {
|
|
249
|
-
wrap(`Cannot show what saving would do: ${error}`, inner).forEach((t, i) =>
|
|
250
|
-
line(`fail${i}`, [{ text: t, tone: "error" }]),
|
|
251
|
-
);
|
|
252
|
-
line("gap", []);
|
|
253
|
-
out.push(<Buttons key="buttons" labels={labels} focused={0} width={inner} />);
|
|
254
|
-
return out;
|
|
255
|
-
}
|
|
256
|
-
if (!preview) {
|
|
257
|
-
line("gone", [{ text: draft ? "reading…" : "Nothing to save.", tone: "muted" }]);
|
|
258
|
-
return out;
|
|
259
|
-
}
|
|
260
|
-
const repo = data.profiles.value?.repo;
|
|
261
|
-
const text = (t: string, tone: Part["tone"]): Part[][] => wrap(t, inner).map((l) => [{ text: l, tone }]);
|
|
262
|
-
const sections: Section[] = [
|
|
263
|
-
{
|
|
264
|
-
head: [],
|
|
265
|
-
hold: "filler",
|
|
266
|
-
items: [
|
|
267
|
-
...preview.changes.map((c): Part[][] => [
|
|
268
|
-
[
|
|
269
|
-
{ text: `${c.path} ` },
|
|
270
|
-
{ text: show(c.before), tone: "diffDel" },
|
|
271
|
-
{ text: ` ${glyph("arrow", ui.plain)} ` },
|
|
272
|
-
{ text: show(c.after), tone: "diffAdd" },
|
|
273
|
-
],
|
|
274
|
-
]),
|
|
275
|
-
...preview.treatLikes.map(([rung, like]): Part[][] => [
|
|
276
|
-
[{ text: `treat ${rung} like ` }, { text: like, tone: "diffAdd" }],
|
|
277
|
-
]),
|
|
278
|
-
],
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
head: preview.repair
|
|
282
|
-
? [[], ...text("This save fixes an error and adds none; these stay open:", "warning")]
|
|
283
|
-
: [[]],
|
|
284
|
-
hold: "error",
|
|
285
|
-
more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
|
|
286
|
-
items: preview.validation.errors.map((e) =>
|
|
287
|
-
text(`${glyph("fail", ui.plain)} ${e.path}: ${e.message}`, "error"),
|
|
288
|
-
),
|
|
289
|
-
},
|
|
290
|
-
{
|
|
291
|
-
// warnings follow the errors in one block
|
|
292
|
-
head: preview.validation.errors.length ? [] : [[]],
|
|
293
|
-
hold: "filler",
|
|
294
|
-
more: (n) => `… and ${n} more ${n === 1 ? "warning" : "warnings"}`,
|
|
295
|
-
items: preview.validation.warnings.map((w) =>
|
|
296
|
-
text(`${glyph("warn", ui.plain)} ${w.path}: ${w.message}`, "warning"),
|
|
297
|
-
),
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
head: [[], [{ text: "Agent files", bold: true }]],
|
|
301
|
-
hold: "filler",
|
|
302
|
-
items: [
|
|
303
|
-
...preview.agentsAdded.map((a): Part[][] => [[{ text: `+ ${a}`, tone: "diffAdd" }]]),
|
|
304
|
-
...preview.agentsRemoved.map((a): Part[][] => [[{ text: `- ${a}`, tone: "diffDel" }]]),
|
|
305
|
-
],
|
|
306
|
-
},
|
|
307
|
-
{
|
|
308
|
-
head: [[]],
|
|
309
|
-
hold: "note",
|
|
310
|
-
items: [
|
|
311
|
-
text(
|
|
312
|
-
"Applies to: native Claude agents in new Claude Code sessions; codex, claude-code and opencode from the next dispatch.",
|
|
313
|
-
"muted",
|
|
314
|
-
),
|
|
315
|
-
// inside a bound repo, making a profile active binds the repo to it (the button keeps §9.2's label)
|
|
316
|
-
...(repo ? [text(`Save & make active binds ${repo} to ${name}.`, "muted")] : []),
|
|
317
|
-
],
|
|
318
|
-
},
|
|
319
|
-
{ head: [[]], hold: "failure", items: props.dialog.error ? [text(props.dialog.error, "error")] : [] },
|
|
320
|
-
];
|
|
321
|
-
// the gap and the buttons always fit: every section shares what is left of the panel
|
|
322
|
-
const notice =
|
|
323
|
-
changed && !props.dialog.saving ? wrap(`${glyph("warn", ui.plain)} ${CHANGED_ON_DISK}`, inner) : [];
|
|
324
|
-
fitSections(sections, dialogRows(dims.height) - 2 - notice.length).forEach((p, i) => line(`r${i}`, p));
|
|
325
|
-
line("gap5", []);
|
|
326
|
-
notice.forEach((t, i) => line(`changed${i}`, [{ text: t, tone: "warning" }]));
|
|
327
|
-
if (props.dialog.saving) line("saving", [{ text: "saving…", tone: "muted" }]);
|
|
328
|
-
else
|
|
329
|
-
out.push(
|
|
330
|
-
<Buttons
|
|
331
|
-
key="buttons"
|
|
332
|
-
labels={labels}
|
|
333
|
-
focused={Math.min(focused, labels.length - 1)}
|
|
334
|
-
width={inner}
|
|
335
|
-
/>,
|
|
336
|
-
);
|
|
337
|
-
return out;
|
|
338
|
-
};
|
|
339
|
-
return <Dialog title={`Save profile ${name}`} size="large" rows={rows} />;
|
|
340
|
-
}
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import type { Check } from "../../../services/doctor-checks.ts";
|
|
2
|
-
import { useApp, useNow } from "../providers/app.tsx";
|
|
3
|
-
import { useData } from "../providers/data.tsx";
|
|
4
|
-
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
5
|
-
import { useUi } from "../providers/theme.tsx";
|
|
6
|
-
import { ago, plural, wrap } from "../text.ts";
|
|
7
|
-
import { glyph, STATE_TOKEN } from "../theme.ts";
|
|
8
|
-
import { Line, type Part } from "../widgets/line.tsx";
|
|
9
|
-
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
10
|
-
import { hereWord, type RunRow } from "../effects.ts";
|
|
11
|
-
import { showProfile } from "./profile-actions.ts";
|
|
12
|
-
import { staleLines } from "./profiles.tsx";
|
|
13
|
-
|
|
14
|
-
/** `✓ ready` / `! not logged in` / `✗ missing`: state is always glyph and word (spec §9.3). */
|
|
15
|
-
function stateParts(state: Check["state"], word: string, plain: boolean): Part[] {
|
|
16
|
-
return [{ text: `${glyph(state, plain)} ${word}`.padEnd(16), tone: STATE_TOKEN[state] }];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** A run in one line: its state word first, then title, repo and progress (research C4). */
|
|
20
|
-
export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
|
|
21
|
-
const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
|
|
22
|
-
if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
|
|
23
|
-
return [
|
|
24
|
-
r.live
|
|
25
|
-
? { text: `${glyph("live", plain)} live `, tone: "info" }
|
|
26
|
-
: { text: `${glyph("dot", plain)} idle `, tone: "muted" },
|
|
27
|
-
{ text: `${r.title} `, bold: true },
|
|
28
|
-
{ text: `${r.repo} `, tone: "muted" },
|
|
29
|
-
{ text: `${r.live ? `${r.live} live · ` : ""}${bits.join(" · ")} `, tone: "muted" },
|
|
30
|
-
{ text: ago(now - Date.parse(r.createdAt)), tone: "muted" },
|
|
31
|
-
];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Spec §9.1 tab 1: each check as `glyph word — detail` with its full fix command (y copies it), the
|
|
36
|
-
* active profile, and the last five runs. enter opens the profile or a run.
|
|
37
|
-
*/
|
|
38
|
-
export function StatusView(props: { width: number; height: number }) {
|
|
39
|
-
const app = useApp();
|
|
40
|
-
const data = useData();
|
|
41
|
-
const ui = useUi();
|
|
42
|
-
const now = useNow(1_000);
|
|
43
|
-
const { selected, select: setSelected, current: selectedNow } = useSelected();
|
|
44
|
-
const checks = data.report?.checks ?? [];
|
|
45
|
-
const profiles = data.profiles.value ?? { names: [], active: "…", here: "…", repo: null };
|
|
46
|
-
const runs = (data.runs.value?.rows ?? []).slice(0, 5);
|
|
47
|
-
const items: ListItem[] = [];
|
|
48
|
-
const heading = (key: string, text: string, right = "") =>
|
|
49
|
-
items.push({
|
|
50
|
-
key,
|
|
51
|
-
selectable: false,
|
|
52
|
-
render: (_s, w) => (
|
|
53
|
-
<Line
|
|
54
|
-
width={w}
|
|
55
|
-
parts={[
|
|
56
|
-
{ text, bold: true },
|
|
57
|
-
{ text: " ".repeat(Math.max(1, w - text.length - right.length - 1)) },
|
|
58
|
-
{ text: right, tone: "muted" },
|
|
59
|
-
]}
|
|
60
|
-
/>
|
|
61
|
-
),
|
|
62
|
-
});
|
|
63
|
-
const checked = data.checking
|
|
64
|
-
? "checking…"
|
|
65
|
-
: data.checkedAt !== null
|
|
66
|
-
? `checked ${ago(now - data.checkedAt)}`
|
|
67
|
-
: "";
|
|
68
|
-
heading("h:setup", " SETUP", checked);
|
|
69
|
-
if (data.checkError)
|
|
70
|
-
items.push({
|
|
71
|
-
key: "error",
|
|
72
|
-
selectable: false,
|
|
73
|
-
render: (_s, w) => (
|
|
74
|
-
<Line
|
|
75
|
-
width={w}
|
|
76
|
-
parts={[{ text: ` ${glyph("fail", ui.plain)} ${data.checkError}`, tone: "error" }]}
|
|
77
|
-
/>
|
|
78
|
-
),
|
|
79
|
-
});
|
|
80
|
-
for (const c of checks) {
|
|
81
|
-
// the detail wraps under the label instead of being cut: it is often a path (spec §9.3)
|
|
82
|
-
const said = wrap(`${c.label}${c.detail ? ` — ${c.detail}` : ""}`, Math.max(20, props.width - 19));
|
|
83
|
-
const first = said[0] ?? "";
|
|
84
|
-
const label = first.startsWith(c.label) ? c.label : "";
|
|
85
|
-
items.push({
|
|
86
|
-
key: `check:${c.id}`,
|
|
87
|
-
selectable: true,
|
|
88
|
-
render: (sel, w) => (
|
|
89
|
-
<Line
|
|
90
|
-
width={w}
|
|
91
|
-
selected={sel}
|
|
92
|
-
parts={[
|
|
93
|
-
{ text: " " },
|
|
94
|
-
...stateParts(c.state, c.word, ui.plain),
|
|
95
|
-
{ text: label, bold: true },
|
|
96
|
-
{ text: first.slice(label.length), tone: "muted" },
|
|
97
|
-
]}
|
|
98
|
-
/>
|
|
99
|
-
),
|
|
100
|
-
});
|
|
101
|
-
said.slice(1).forEach((line, i) =>
|
|
102
|
-
items.push({
|
|
103
|
-
key: `detail:${c.id}:${i}`,
|
|
104
|
-
selectable: false,
|
|
105
|
-
render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(19)}${line}`, tone: "muted" }]} />,
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
if (c.fix)
|
|
109
|
-
wrap(`fix: ${c.fix}`, Math.max(20, props.width - 22)).forEach((line, i) =>
|
|
110
|
-
items.push({
|
|
111
|
-
key: `fix:${c.id}:${i}`,
|
|
112
|
-
selectable: false,
|
|
113
|
-
render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(20)}${line}`, tone: "muted" }]} />,
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
heading("h:profile", " PROFILE");
|
|
118
|
-
// until the first check is in, nothing is selectable, so the cursor starts on the first check
|
|
119
|
-
const ready = data.report !== null || data.checkError !== null;
|
|
120
|
-
items.push({
|
|
121
|
-
key: "profile",
|
|
122
|
-
selectable: ready,
|
|
123
|
-
render: (sel, w) => (
|
|
124
|
-
<Line
|
|
125
|
-
width={w}
|
|
126
|
-
selected={sel}
|
|
127
|
-
parts={[
|
|
128
|
-
{ text: ` ${profiles.here}`, bold: true },
|
|
129
|
-
{
|
|
130
|
-
// "…" until the first read: never "0 profiles" on the first frame (spec 1.1 §13)
|
|
131
|
-
text: ` ${hereWord(profiles)} · ${data.profiles.value ? plural(profiles.names.length, "profile") : "…"}`,
|
|
132
|
-
tone: "muted",
|
|
133
|
-
},
|
|
134
|
-
]}
|
|
135
|
-
/>
|
|
136
|
-
),
|
|
137
|
-
});
|
|
138
|
-
// a later read that failed: the row above is the last good read, not the profile now
|
|
139
|
-
for (const [i, p] of staleLines(data, ui.plain, { fix: false }).entries())
|
|
140
|
-
items.push({
|
|
141
|
-
key: `profile:stale:${i}`,
|
|
142
|
-
selectable: false,
|
|
143
|
-
render: (_s, w) => <Line width={w} parts={[{ ...p, text: ` ${p.text}` }]} />,
|
|
144
|
-
});
|
|
145
|
-
heading("h:runs", " RECENT RUNS");
|
|
146
|
-
if (runs.length === 0)
|
|
147
|
-
items.push({
|
|
148
|
-
key: "runs:none",
|
|
149
|
-
selectable: false,
|
|
150
|
-
render: (_s, w) => <Line width={w} parts={[{ text: " no runs yet", tone: "muted" }]} />,
|
|
151
|
-
});
|
|
152
|
-
for (const r of runs)
|
|
153
|
-
items.push({
|
|
154
|
-
key: `run:${r.id}`,
|
|
155
|
-
selectable: ready,
|
|
156
|
-
render: (sel, w) => (
|
|
157
|
-
<Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
|
|
158
|
-
),
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const checkAt = (key: string | null) => checks.find((c) => key === `check:${c.id}`);
|
|
162
|
-
useCommandLayer("tab.status", {
|
|
163
|
-
"status.recheck": () => data.recheck(),
|
|
164
|
-
"status.copy": () => {
|
|
165
|
-
const selectedCheck = checkAt(selectedNow());
|
|
166
|
-
if (!selectedCheck?.fix) return app.toast({ variant: "info", message: "This row has no fix command" });
|
|
167
|
-
const ok = app.copy(selectedCheck.fix);
|
|
168
|
-
app.toast(
|
|
169
|
-
ok
|
|
170
|
-
? { variant: "success", message: "Copied the fix command" }
|
|
171
|
-
: { variant: "warning", message: "This terminal cannot copy; the command is shown in full" },
|
|
172
|
-
);
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
useCommandLayer("row.status", {
|
|
176
|
-
"status.open": () => {
|
|
177
|
-
const selected = selectedNow();
|
|
178
|
-
const selectedCheck = checkAt(selected);
|
|
179
|
-
// the PROFILE row is what this directory runs on; doctor's `profile` row is the global active one,
|
|
180
|
-
// and its `profile:<name>` rows are the repo-bound ones
|
|
181
|
-
const name =
|
|
182
|
-
selected === "profile"
|
|
183
|
-
? profiles.here
|
|
184
|
-
: selectedCheck?.id === "profile"
|
|
185
|
-
? profiles.active
|
|
186
|
-
: selectedCheck?.id.startsWith("profile:")
|
|
187
|
-
? selectedCheck.id.slice("profile:".length)
|
|
188
|
-
: null;
|
|
189
|
-
if (name !== null) {
|
|
190
|
-
showProfile(app, name);
|
|
191
|
-
} else if (selected?.startsWith("run:")) {
|
|
192
|
-
// spec §4: a run opens in its session's screen, which shows it with the session's other runs
|
|
193
|
-
const row = runs.find((r) => `run:${r.id}` === selected);
|
|
194
|
-
app.dispatch({ type: "tab", tab: "runs" });
|
|
195
|
-
if (row) app.dispatch({ type: "session", key: row.session });
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
return (
|
|
200
|
-
<List
|
|
201
|
-
items={items}
|
|
202
|
-
selected={selected}
|
|
203
|
-
onSelect={setSelected}
|
|
204
|
-
width={props.width}
|
|
205
|
-
height={props.height}
|
|
206
|
-
empty="checking…"
|
|
207
|
-
/>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core";
|
|
2
|
-
import { usePendingSequence } from "@opentui/keymap/react";
|
|
3
|
-
import { type CommandId, formatKeys, LEADER } from "../commands.ts";
|
|
4
|
-
import { MODE, reachableCommands, useKeybinds, useKeymap, useKeymapVersion } from "../providers/keymap.tsx";
|
|
5
|
-
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
-
import { FACES, type Mood } from "../theme.ts";
|
|
7
|
-
import { TABS, type Tab } from "../state.ts";
|
|
8
|
-
import { width as widthOf } from "../text.ts";
|
|
9
|
-
import { Line, type Part } from "./line.tsx";
|
|
10
|
-
|
|
11
|
-
const TAB_TITLE: Record<Tab, string> = { status: "Status", profiles: "Profiles", runs: "Runs" };
|
|
12
|
-
|
|
13
|
-
/** Spec §9.3 header: one line with the version, the profile, the unsaved count and the mood face. */
|
|
14
|
-
export function Header(props: {
|
|
15
|
-
version: string;
|
|
16
|
-
profile: string | null;
|
|
17
|
-
active: boolean;
|
|
18
|
-
/** the word after the profile: "active", or "this repo" in a bound repo */
|
|
19
|
-
label?: string;
|
|
20
|
-
dirty: number;
|
|
21
|
-
mood: Mood;
|
|
22
|
-
width: number;
|
|
23
|
-
}) {
|
|
24
|
-
const pending = usePendingSequence();
|
|
25
|
-
const left: Part[] = [{ text: ` catherd ${props.version}`, bold: true }];
|
|
26
|
-
if (props.profile) {
|
|
27
|
-
left.push({ text: ` profile ${props.profile}` });
|
|
28
|
-
if (props.active) left.push({ text: ` (${props.label ?? "active"})`, tone: "muted" });
|
|
29
|
-
}
|
|
30
|
-
if (props.dirty > 0) left.push({ text: ` · ${props.dirty} unsaved`, tone: "warning" });
|
|
31
|
-
const right: Part[] = [];
|
|
32
|
-
if (pending.length)
|
|
33
|
-
right.push({
|
|
34
|
-
text: `${pending.map((p) => (p.tokenName === "leader" ? LEADER : p.display)).join(" ")} … `,
|
|
35
|
-
tone: "accent",
|
|
36
|
-
});
|
|
37
|
-
right.push({ text: `${FACES[props.mood]} `, tone: props.mood === "failed" ? "error" : "muted" });
|
|
38
|
-
const used = [...left, ...right].reduce((n, p) => n + widthOf(p.text), 0);
|
|
39
|
-
const gap = Math.max(1, props.width - used);
|
|
40
|
-
return <Line width={props.width} parts={[...left, { text: " ".repeat(gap) }, ...right]} />;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The tab strip: ` 1 Status 2 Profiles 3 Runs`, the active one bold in the accent. */
|
|
44
|
-
export function Tabs(props: { tab: Tab; width: number }) {
|
|
45
|
-
const ui = useUi();
|
|
46
|
-
const tone = useTone();
|
|
47
|
-
return (
|
|
48
|
-
<box flexDirection="row" width={props.width} height={1}>
|
|
49
|
-
{TABS.map((t, i) => {
|
|
50
|
-
const on = t === props.tab;
|
|
51
|
-
const text = ` ${i + 1} ${TAB_TITLE[t]} `;
|
|
52
|
-
return (
|
|
53
|
-
<text
|
|
54
|
-
key={t}
|
|
55
|
-
wrapMode="none"
|
|
56
|
-
fg={on ? tone(ui.color ? "accent" : "text") : tone("muted")}
|
|
57
|
-
attributes={on ? TextAttributes.BOLD | (ui.color ? 0 : TextAttributes.INVERSE) : 0}
|
|
58
|
-
>
|
|
59
|
-
{i === 0 ? text : ` ${text}`}
|
|
60
|
-
</text>
|
|
61
|
-
);
|
|
62
|
-
})}
|
|
63
|
-
</box>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Spec §9.2: footer hints are generated from the commands a key would reach now, in their `hint` order,
|
|
69
|
-
* as `key label` (the key as the user bound it). Hints drop from the end to fit, but the palette and
|
|
70
|
-
* help hints stay.
|
|
71
|
-
*/
|
|
72
|
-
export function Footer(props: { width: number }) {
|
|
73
|
-
useKeymapVersion();
|
|
74
|
-
const keymap = useKeymap();
|
|
75
|
-
const keys = useKeybinds();
|
|
76
|
-
const ui = useUi();
|
|
77
|
-
const modal = keymap.getData(MODE) === "modal";
|
|
78
|
-
const hints: { id: string; text: string }[] = [];
|
|
79
|
-
let last: { order: number; label: string; keys: string[] } | null = null;
|
|
80
|
-
for (const c of reachableCommands(keymap)
|
|
81
|
-
.filter((c) => c.hint !== null && keys[c.id].length > 0 && (c.id !== "app.back" || modal))
|
|
82
|
-
.sort((a, b) => (a.hint as number) - (b.hint as number))) {
|
|
83
|
-
const key = formatKeys(keys[c.id as CommandId].slice(0, 1), ui.plain);
|
|
84
|
-
const label = c.short ?? c.title;
|
|
85
|
-
// commands with the same order and label read as one hint: `↑↓ move`
|
|
86
|
-
if (last && last.order === c.hint && last.label === label) {
|
|
87
|
-
last.keys.push(key);
|
|
88
|
-
const joined = last.keys.join(ui.plain ? "/" : "");
|
|
89
|
-
(hints.at(-1) as { text: string }).text = `${joined} ${label}`;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
last = { order: c.hint as number, label, keys: [key] };
|
|
93
|
-
hints.push({ id: c.id, text: `${key} ${label}` });
|
|
94
|
-
}
|
|
95
|
-
const fixed = hints.filter((h) => h.id === "app.palette" || h.id === "app.help");
|
|
96
|
-
const rest = hints.filter((h) => !fixed.includes(h));
|
|
97
|
-
const sep = " ";
|
|
98
|
-
const room = props.width - 1 - fixed.reduce((n, h) => n + widthOf(h.text) + sep.length, 0);
|
|
99
|
-
const kept: string[] = [];
|
|
100
|
-
let used = 0;
|
|
101
|
-
for (const h of rest) {
|
|
102
|
-
if (used + widthOf(h.text) + sep.length > room) break;
|
|
103
|
-
kept.push(h.text);
|
|
104
|
-
used += widthOf(h.text) + sep.length;
|
|
105
|
-
}
|
|
106
|
-
const all = [...kept, ...fixed.map((h) => h.text)];
|
|
107
|
-
const parts: Part[] = [{ text: " " }];
|
|
108
|
-
all.forEach((t, i) => {
|
|
109
|
-
const at = t.indexOf(" ");
|
|
110
|
-
parts.push({ text: t.slice(0, at), bold: true }, { text: t.slice(at), tone: "muted" });
|
|
111
|
-
if (i < all.length - 1) parts.push({ text: sep });
|
|
112
|
-
});
|
|
113
|
-
return <Line width={props.width} parts={parts} />;
|
|
114
|
-
}
|