carthagent 1.0.7 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { CliRenderEvents, type ScrollBoxRenderable, type SelectRenderable } from "@opentui/core";
|
|
4
|
+
import { useRenderer } from "@opentui/solid";
|
|
5
|
+
import { usePlugin } from "@opencode/plugin/tui";
|
|
6
|
+
import { clickSelect } from "./select-mouse.js";
|
|
7
|
+
import { dailyQuestsClient, voyagesClient } from "./client.js";
|
|
8
|
+
import { nativeNavigation, type NavigationInput } from "./native-navigation.js";
|
|
9
|
+
import { createVoyagesNavigationState } from "./voyages-navigation-state.mjs";
|
|
10
|
+
import { voyagesEventAction } from "./voyages-state.mjs";
|
|
11
|
+
|
|
12
|
+
type Summary = {
|
|
13
|
+
busy: boolean; error: string;
|
|
14
|
+
profile?: { name: string; shipName: string; coins: number; level: number; totalXp: number };
|
|
15
|
+
daily?: { day: string; endsAt: string; items: { id: string; title: string; status: string }[] };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function VoyagesNavigation(props: NavigationInput) {
|
|
19
|
+
const context = usePlugin();
|
|
20
|
+
const renderer = useRenderer();
|
|
21
|
+
const location = () => (props.sessionID ? context.data.session.get(props.sessionID)?.location : undefined) ?? context.location ?? context.data.location.default();
|
|
22
|
+
const [view, setView] = createSignal<Summary>({ busy: false, error: "" });
|
|
23
|
+
const [selection, setSelection] = createSignal("daily");
|
|
24
|
+
const profile = voyagesClient(context, location), daily = dailyQuestsClient(context, location);
|
|
25
|
+
const controller = createVoyagesNavigationState({ profile: profile.snapshot, daily: daily.snapshot, changed: setView });
|
|
26
|
+
let actions: SelectRenderable | undefined;
|
|
27
|
+
let scroll: ScrollBoxRenderable | undefined;
|
|
28
|
+
let scrollTop = 0;
|
|
29
|
+
let restoreTop: number | undefined;
|
|
30
|
+
const [viewportHeight, setViewportHeight] = createSignal(3);
|
|
31
|
+
const [railWidth, setRailWidth] = createSignal(0);
|
|
32
|
+
createEffect(() => {
|
|
33
|
+
if (!props.active) { scrollTop = scroll?.scrollTop ?? scrollTop; return; }
|
|
34
|
+
if (!view().busy) { restoreTop = scrollTop; renderer.requestRender(); }
|
|
35
|
+
});
|
|
36
|
+
createEffect(() => { void controller.activate(props.active); });
|
|
37
|
+
let binding = JSON.stringify(location());
|
|
38
|
+
const resetSelection = () => { actions?.setSelectedIndex(0); setSelection("daily"); };
|
|
39
|
+
createEffect(() => {
|
|
40
|
+
const next = JSON.stringify(location());
|
|
41
|
+
if (binding === next) return;
|
|
42
|
+
binding = next;
|
|
43
|
+
resetSelection();
|
|
44
|
+
void controller.contextChanged();
|
|
45
|
+
});
|
|
46
|
+
onMount(() => {
|
|
47
|
+
// FRAME follows layout. Earlier restoration clamps to the loading extent.
|
|
48
|
+
const frame = () => {
|
|
49
|
+
if (!scroll || scroll.isDestroyed || !props.active) return;
|
|
50
|
+
setViewportHeight(Math.max(1, scroll.viewport.height));
|
|
51
|
+
if (actions && !actions.isDestroyed) setRailWidth(actions.width);
|
|
52
|
+
if (restoreTop === undefined || view().busy) return;
|
|
53
|
+
scroll.scrollTop = restoreTop;
|
|
54
|
+
restoreTop = undefined;
|
|
55
|
+
};
|
|
56
|
+
renderer.on(CliRenderEvents.FRAME, frame);
|
|
57
|
+
onCleanup(() => renderer.off(CliRenderEvents.FRAME, frame));
|
|
58
|
+
const stop = context.data.listen(({ details }) => {
|
|
59
|
+
const changedLocation = binding !== JSON.stringify(location());
|
|
60
|
+
binding = JSON.stringify(location());
|
|
61
|
+
const action = changedLocation ? String(details.type) === "server.disconnected" ? "clear" : "refresh" : voyagesEventAction(details, location());
|
|
62
|
+
if (action === "ignore") return;
|
|
63
|
+
resetSelection();
|
|
64
|
+
if (action === "clear") controller.disconnect();
|
|
65
|
+
else void controller.contextChanged();
|
|
66
|
+
});
|
|
67
|
+
onCleanup(stop);
|
|
68
|
+
});
|
|
69
|
+
onCleanup(() => controller.dispose());
|
|
70
|
+
const open = (name: "voyages" | "daily-quests", extra: Record<string, string> = {}) => {
|
|
71
|
+
props.blur();
|
|
72
|
+
if (nativeNavigation(context)?.current() !== "voyages") return;
|
|
73
|
+
context.ui.router.navigate({ type: "plugin", name, data: { ...(props.sessionID ? { sessionID: props.sessionID } : {}), ...extra } });
|
|
74
|
+
};
|
|
75
|
+
// Select draws raw text; fit names and descriptions to the rail.
|
|
76
|
+
const fit = (text: string) => {
|
|
77
|
+
const room = railWidth() - 3;
|
|
78
|
+
return room > 0 && text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text;
|
|
79
|
+
};
|
|
80
|
+
const options = () => entries().map((item) => ({ ...item, name: fit(item.name), description: fit(item.description) }));
|
|
81
|
+
const entries = () => [
|
|
82
|
+
{ name: props.compact ? "Q" : "Daily coding quests", description: "Assignments & assessments", value: "daily" },
|
|
83
|
+
...(view().daily?.items.map((quest, index) => ({ name: props.compact ? String(index + 1) : quest.title, description: quest.status, value: `quest:${quest.id}` })) ?? []),
|
|
84
|
+
{ name: props.compact ? "P" : "Profile & wallet", description: "Authoritative profile", value: "profile" },
|
|
85
|
+
{ name: props.compact ? "E" : "Equipment & shop", description: "Preview before any purchase", value: "equipment" },
|
|
86
|
+
{ name: props.compact ? "R" : "Refresh summaries", description: "Read-only; no evidence upload", value: "refresh" },
|
|
87
|
+
{ name: props.compact ? "S" : "Sign in / reconnect", description: "Native Ship device sign-in", value: "signin" },
|
|
88
|
+
{ name: props.compact ? "B" : "Return to coding", description: "Keep the selected session", value: "back" },
|
|
89
|
+
];
|
|
90
|
+
const select = (value?: string) => {
|
|
91
|
+
if (!props.active || context.keymap.mode.current() !== "navigation" || !props.focused || !value) return;
|
|
92
|
+
if (value === "refresh") { void controller.refresh(); return; }
|
|
93
|
+
if (value === "signin") { controller.invalidate(); props.blur(); context.keymap.dispatch("provider.connect"); return; }
|
|
94
|
+
if (value === "back") {
|
|
95
|
+
props.blur();
|
|
96
|
+
nativeNavigation(context)?.select("projects");
|
|
97
|
+
context.ui.router.navigate(props.sessionID ? { type: "session", sessionID: props.sessionID } : { type: "home" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (value === "profile" || value === "equipment") { open("voyages", { section: value }); return; }
|
|
101
|
+
open("daily-quests", value.startsWith("quest:") ? { questID: value.slice(6) } : {});
|
|
102
|
+
};
|
|
103
|
+
const focused = () => props.active && props.focused && context.keymap.mode.current() === "navigation";
|
|
104
|
+
return <box flexDirection="column" flexGrow={1} minHeight={0} onMouseDown={() => { if (props.active && context.keymap.mode.current() === "base") props.focus(); }}>
|
|
105
|
+
<scrollbox ref={scroll} flexGrow={1} minHeight={0} contentOptions={{ flexDirection: "column" }}>
|
|
106
|
+
<Show when={!props.compact}>
|
|
107
|
+
<Show when={view().profile}>{(p) => <box flexDirection="column" flexShrink={0} paddingX={1}>
|
|
108
|
+
<text fg={context.theme.text.base}>{p().name}</text>
|
|
109
|
+
<text fg={context.theme.text.muted}>{p().shipName}</text>
|
|
110
|
+
<text fg={context.theme.text.action.primary.base}>Lv {p().level} · {p().coins} coins</text>
|
|
111
|
+
<text fg={context.theme.text.muted}>XP {p().totalXp}</text>
|
|
112
|
+
</box>}</Show>
|
|
113
|
+
<Show when={view().busy}><text flexShrink={0} fg={context.theme.text.muted}> Loading summaries…</text></Show>
|
|
114
|
+
<Show when={view().error}><text flexShrink={0} fg={context.theme.text.feedback.warning.base}> {view().error}. Sign in or refresh.</text></Show>
|
|
115
|
+
<Show when={view().daily}><text flexShrink={0} fg={context.theme.text.muted}> UTC {view().daily?.day}</text></Show>
|
|
116
|
+
</Show>
|
|
117
|
+
<select ref={actions} onMouseUp={(event) => { if (focused()) clickSelect(actions, event); }} minHeight={1} height={Math.min(18, viewportHeight(), options().length * (props.compact ? 1 : 2))} flexShrink={0} showScrollIndicator showDescription={!props.compact} options={options()} selectedIndex={Math.max(0, options().findIndex((item) => item.value === selection()))} focused={focused()} onChange={(_index, item) => {
|
|
118
|
+
if (!focused() || view().busy || !item) return;
|
|
119
|
+
setSelection(item.value);
|
|
120
|
+
if (actions) scroll?.scrollChildIntoView(actions.id);
|
|
121
|
+
}} onSelect={(_index, item) => select(item?.value)} />
|
|
122
|
+
<Show when={!props.compact}>
|
|
123
|
+
<text flexShrink={0} fg={context.theme.text.faint}> Badges & friends: no native views yet.</text>
|
|
124
|
+
<text flexShrink={0} fg={context.theme.text.faint}> Quests: no rewards. No automatic uploads.</text>
|
|
125
|
+
</Show>
|
|
126
|
+
</scrollbox>
|
|
127
|
+
<Show when={!props.compact}>
|
|
128
|
+
<text flexShrink={0} fg={context.theme.text.faint}>{focused() ? " ↑/↓ Enter · Esc coding" : " Focus pane to navigate"}</text>
|
|
129
|
+
</Show>
|
|
130
|
+
</box>;
|
|
131
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
class LocalVoyagesError extends Error {
|
|
2
|
+
constructor(code) { super(code); this.code = code; }
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** UI transaction lifetime. Credentials and durable purchase keys remain server-only. */
|
|
6
|
+
export function createVoyagesState({ api, changed = (_state) => {}, now = Date.now, timeoutSignal = () => AbortSignal.timeout(15_000) }) {
|
|
7
|
+
let generation = 0;
|
|
8
|
+
let disposed = false;
|
|
9
|
+
let controller = new AbortController();
|
|
10
|
+
const state = { snapshot: undefined, busy: false, error: "", notice: "" };
|
|
11
|
+
const emit = () => { if (!disposed) changed({ ...state }); };
|
|
12
|
+
const invalidate = () => {
|
|
13
|
+
generation++;
|
|
14
|
+
controller.abort();
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
state.snapshot = undefined;
|
|
17
|
+
state.busy = false;
|
|
18
|
+
state.error = "";
|
|
19
|
+
state.notice = "";
|
|
20
|
+
emit();
|
|
21
|
+
};
|
|
22
|
+
async function run(action) {
|
|
23
|
+
if (disposed || state.busy) return;
|
|
24
|
+
const current = generation;
|
|
25
|
+
const signal = controller.signal;
|
|
26
|
+
const live = () => !disposed && current === generation && !signal.aborted;
|
|
27
|
+
state.busy = true;
|
|
28
|
+
state.error = "";
|
|
29
|
+
state.notice = "";
|
|
30
|
+
emit();
|
|
31
|
+
try { await action({ signal, live }); }
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (!disposed && current === generation) {
|
|
34
|
+
state.snapshot = undefined;
|
|
35
|
+
const code = voyagesErrorCode(error);
|
|
36
|
+
state.error = code === 'insufficient_coins'
|
|
37
|
+
? 'insufficient_coins. Not enough coins for this item. No purchase was made; wallet refreshed.'
|
|
38
|
+
: `${code}. Refresh required; no success assumed. Sign in again if access expired or was revoked.`;
|
|
39
|
+
emit();
|
|
40
|
+
// Reconcile all failed writes, never replay them. Even if this succeeds,
|
|
41
|
+
// retain the failure message rather than claiming a successful mutation.
|
|
42
|
+
try {
|
|
43
|
+
const snapshot = await api.snapshot(AbortSignal.any([controller.signal, timeoutSignal()]));
|
|
44
|
+
if (!disposed && current === generation) state.snapshot = snapshot;
|
|
45
|
+
} catch { /* fail closed */ }
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
if (!disposed && current === generation) { state.busy = false; emit(); }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function load({ signal, live }) {
|
|
52
|
+
const snapshot = await api.snapshot(networkSignal(signal));
|
|
53
|
+
if (live()) { state.snapshot = snapshot; emit(); }
|
|
54
|
+
}
|
|
55
|
+
const networkSignal = (signal) => AbortSignal.any([signal, timeoutSignal()]);
|
|
56
|
+
return {
|
|
57
|
+
invalidate,
|
|
58
|
+
dispose() { invalidate(); disposed = true; controller.abort(); },
|
|
59
|
+
refresh() { return run(async (tx) => { state.snapshot = undefined; emit(); await load(tx); }); },
|
|
60
|
+
claimReward(sessionID, confirm) {
|
|
61
|
+
return run(async (tx) => {
|
|
62
|
+
if (!state.snapshot || !sessionID) return;
|
|
63
|
+
const preview = await api.previewReward({ sessionID }, networkSignal(tx.signal));
|
|
64
|
+
if (!tx.live() || await confirm(preview) !== true || !tx.live()) return;
|
|
65
|
+
const result = await api.claimReward({ sessionID, runId: preview.runId, snapshot: preview.snapshot }, networkSignal(tx.signal));
|
|
66
|
+
if (!tx.live()) return;
|
|
67
|
+
await load(tx);
|
|
68
|
+
if (tx.live()) state.notice = result.replayed ? 'This delivery was already rewarded. No duplicate grant.' : `Delivery rewarded: +${result.reward.coins} coins · +${result.reward.xp} XP · +${result.reward.km} km.`;
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
customize(draft, confirm) {
|
|
72
|
+
return run(async (tx) => {
|
|
73
|
+
const profile = state.snapshot?.profile;
|
|
74
|
+
if (!profile) return;
|
|
75
|
+
const patch = await draft(profile, tx.live);
|
|
76
|
+
if (!patch || !tx.live()) return;
|
|
77
|
+
if (await confirm(profile, patch) !== true || !tx.live()) return;
|
|
78
|
+
await api.customize({ version: profile.version, ...patch }, networkSignal(tx.signal));
|
|
79
|
+
if (!tx.live()) return;
|
|
80
|
+
await load(tx);
|
|
81
|
+
if (tx.live()) state.notice = "Customization saved and refreshed.";
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
equip(item, confirm) {
|
|
85
|
+
return run(async (tx) => {
|
|
86
|
+
const snapshot = state.snapshot;
|
|
87
|
+
if (!snapshot || !snapshot.items.some((entry) => entry.item === item.item)) return;
|
|
88
|
+
if (!canEquip(snapshot, item)) throw new Error("equip unavailable");
|
|
89
|
+
if (await confirm(snapshot.profile, item) !== true || !tx.live()) return;
|
|
90
|
+
await api.equip({ version: snapshot.profile.version, slot: item.slot, item: item.itemKey }, networkSignal(tx.signal));
|
|
91
|
+
if (!tx.live()) return;
|
|
92
|
+
await load(tx);
|
|
93
|
+
if (tx.live()) state.notice = "Equipment saved and refreshed.";
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
purchase(item, confirm) {
|
|
97
|
+
return run(async (tx) => {
|
|
98
|
+
const snapshot = state.snapshot;
|
|
99
|
+
if (!snapshot || snapshot.pending || !snapshot.items.some((entry) => entry.item === item.item) || item.price === null || !item.requiresPurchase || !item.available || !item.eligible || snapshot.profile.owned.includes(item.item)) return;
|
|
100
|
+
const { quote } = await api.quote({ slot: item.slot, item: item.itemKey }, networkSignal(tx.signal));
|
|
101
|
+
if (!tx.live()) return;
|
|
102
|
+
const validate = () => {
|
|
103
|
+
if (quote.item !== item.item || !Number.isSafeInteger(quote.price) || quote.price < 0 || !Number.isFinite(Date.parse(quote.expiresAt))) throw new LocalVoyagesError('invalid_quote');
|
|
104
|
+
if (Date.parse(quote.expiresAt) <= now()) throw new LocalVoyagesError('quote_expired');
|
|
105
|
+
if (quote.price > snapshot.profile.coins) throw new LocalVoyagesError('insufficient_coins');
|
|
106
|
+
};
|
|
107
|
+
validate();
|
|
108
|
+
if (await confirm(quote, snapshot.profile) !== true || !tx.live()) return;
|
|
109
|
+
validate();
|
|
110
|
+
await api.purchase({ quoteId: quote.id }, networkSignal(tx.signal));
|
|
111
|
+
if (!tx.live()) return;
|
|
112
|
+
await load(tx);
|
|
113
|
+
if (tx.live()) state.notice = "Purchase receipt received; authoritative inventory refreshed.";
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
retry(confirm) {
|
|
117
|
+
return run(async (tx) => {
|
|
118
|
+
const pending = state.snapshot?.pending ? { ...state.snapshot.pending } : undefined;
|
|
119
|
+
if (!pending || await confirm(pending) !== true || !tx.live()) return;
|
|
120
|
+
await api.retryPurchase({ quoteId: pending.quoteId }, networkSignal(tx.signal));
|
|
121
|
+
if (!tx.live()) return;
|
|
122
|
+
await load(tx);
|
|
123
|
+
if (tx.live()) state.notice = "Purchase replay receipt received; inventory refreshed.";
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Native promise RPC throws { type, message, data }, not a nested error map. */
|
|
130
|
+
export function voyagesErrorCode(error) {
|
|
131
|
+
if (error instanceof LocalVoyagesError) return error.code;
|
|
132
|
+
const code = error?.type === 'voyagesError' ? error.data?.code : undefined;
|
|
133
|
+
return typeof code === 'string' && /^[a-z_]{1,64}$/.test(code) ? code : 'voyages_request_failed';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Credential/integration events without a location are global. Generic update
|
|
137
|
+
* events have no integration ID in the native union, so cannot be narrowed more.
|
|
138
|
+
* Credential switched does carry an integration ID: ignore other providers. */
|
|
139
|
+
export function voyagesEventAction(event, location) {
|
|
140
|
+
if (event.location && (event.location.directory !== location.directory || event.location.workspaceID !== location.workspaceID)) return 'ignore';
|
|
141
|
+
if (event.type === 'server.disconnected') return 'clear';
|
|
142
|
+
if (event.type === 'credential.switched' && event.data?.integrationID !== 'experiential-labs') return 'ignore';
|
|
143
|
+
return ['server.connected', 'provider.updated', 'integration.updated', 'credential.updated', 'credential.switched'].includes(event.type) ? 'refresh' : 'ignore';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Synchronously remove the old identity before fetching, including any dialog
|
|
147
|
+
* owned by this page. The caller's dialog adapter must not close native sign-in. */
|
|
148
|
+
export function handleVoyagesEvent(controller, event, location, closeDialog = () => {}) {
|
|
149
|
+
const action = voyagesEventAction(event, location);
|
|
150
|
+
if (action === 'ignore') return;
|
|
151
|
+
controller.invalidate();
|
|
152
|
+
closeDialog();
|
|
153
|
+
if (action === 'refresh') return controller.refresh();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function canEquip(snapshot, item) {
|
|
157
|
+
return item.available === true && item.eligible === true &&
|
|
158
|
+
(snapshot.profile.owned.includes(item.item) || (item.requiresPurchase === false && item.price !== null));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function voyagesCatalogLabel(snapshot, item) {
|
|
162
|
+
const category = snapshot.profile.owned.includes(item.item) ? 'Owned' : item.price === null ? 'Retired' : item.requiresPurchase ? 'Shop' : 'Free';
|
|
163
|
+
const price = item.price === null ? 'price unavailable / retired' : `${item.price} coins`;
|
|
164
|
+
return `${category} · ${item.name} · ${price}`;
|
|
165
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show, createSignal, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import type { ScrollBoxRenderable, SelectRenderable } from "@opentui/core";
|
|
4
|
+
import { clickSelect } from "./select-mouse.js";
|
|
5
|
+
import { usePlugin } from "@opencode/plugin/tui";
|
|
6
|
+
import { voyagesClient, type VoyagesSnapshot, type VoyagesProfile, type VoyagesItem, type VoyagesQuote } from "./client.js";
|
|
7
|
+
import { canEquip, createVoyagesState, handleVoyagesEvent, voyagesCatalogLabel } from "./voyages-state.mjs";
|
|
8
|
+
|
|
9
|
+
type ViewState = { snapshot?: VoyagesSnapshot; busy: boolean; error: string; notice: string };
|
|
10
|
+
|
|
11
|
+
/** All mutations go through the lifetime-guarded controller, including dialogs. */
|
|
12
|
+
export function VoyagesPage(props: { sessionID?: string; section?: "profile" | "equipment" }) {
|
|
13
|
+
let list: SelectRenderable | undefined;
|
|
14
|
+
const context = usePlugin();
|
|
15
|
+
const location = () => (props.sessionID ? context.data.session.get(props.sessionID)?.location : undefined) ?? context.location ?? context.data.location.default();
|
|
16
|
+
const [view, setView] = createSignal<ViewState>({ busy: false, error: "", notice: "" });
|
|
17
|
+
const controller = createVoyagesState({ api: voyagesClient(context, location), changed: (next: ViewState) => setView(next) });
|
|
18
|
+
const back = () => context.ui.router.navigate(props.sessionID ? { type: "session", sessionID: props.sessionID } : { type: "home" });
|
|
19
|
+
let summary: ScrollBoxRenderable | undefined;
|
|
20
|
+
context.keymap.layer(() => ({
|
|
21
|
+
commands: [
|
|
22
|
+
{ bind: "ctrl+u", title: "Scroll Voyages profile up", run: () => { summary?.scrollBy(-1); } },
|
|
23
|
+
{ bind: "ctrl+d", title: "Scroll Voyages profile down", run: () => { summary?.scrollBy(1); } },
|
|
24
|
+
],
|
|
25
|
+
}));
|
|
26
|
+
let activeDialog: symbol | undefined;
|
|
27
|
+
const ownDialog = async <T,>(show: () => Promise<T>): Promise<T> => {
|
|
28
|
+
const token = Symbol();
|
|
29
|
+
activeDialog = token;
|
|
30
|
+
try { return await show(); }
|
|
31
|
+
finally { if (activeDialog === token) activeDialog = undefined; }
|
|
32
|
+
};
|
|
33
|
+
const closeOwnDialog = () => {
|
|
34
|
+
if (!activeDialog) return;
|
|
35
|
+
activeDialog = undefined;
|
|
36
|
+
context.ui.dialog.clear();
|
|
37
|
+
};
|
|
38
|
+
const confirm = (title: string, message: string, label = "Confirm") => ownDialog(() => context.ui.dialog.confirm({ title, message, label: { confirm: label, cancel: "Cancel" } }));
|
|
39
|
+
const customize = () => controller.customize(async (profile: VoyagesProfile, live: () => boolean) => {
|
|
40
|
+
const name = await ownDialog(() => context.ui.dialog.prompt({ title: "Preview captain name (1–20 characters)", placeholder: profile.name }));
|
|
41
|
+
if (name == null || !live()) return;
|
|
42
|
+
const shipName = await ownDialog(() => context.ui.dialog.prompt({ title: "Preview ship name (1–28 characters)", placeholder: profile.shipName }));
|
|
43
|
+
if (shipName == null || !live()) return;
|
|
44
|
+
// Empty input retains the current value. No inferred appearance choices.
|
|
45
|
+
const patch = { name: name.trim() || profile.name, shipName: shipName.trim() || profile.shipName };
|
|
46
|
+
if (patch.name.length > 20 || patch.shipName.length > 28) throw new Error("invalid name");
|
|
47
|
+
return patch;
|
|
48
|
+
}, (profile: VoyagesProfile, patch: { name: string; shipName: string }) => confirm("Save customization preview?", `Captain: ${profile.name} → ${patch.name}\nShip: ${profile.shipName} → ${patch.shipName}\nAppearance unchanged. Version ${profile.version}.\nOnly Save writes to your live profile.`, "Save"));
|
|
49
|
+
const equip = (item: VoyagesItem) => controller.equip(item, (profile: VoyagesProfile) => confirm("Save equipment?", `${item.slot}: ${String(profile.config[item.slot] ?? "not reported")} → ${item.itemKey}\n${item.name} (${item.item})\nThis replaces the equipped slot. A catalog “none” choice unequips; no fabricated empty key is sent.`, "Equip"));
|
|
50
|
+
const purchase = (item: VoyagesItem) => controller.purchase(item, (quote: VoyagesQuote, profile: VoyagesProfile) => confirm("Confirm live purchase?", `${item.name} (${quote.item})\nExact price: ${quote.price} coins\nCurrent wallet: ${profile.coins} coins\nAvailable: ${item.available}; eligible: ${item.eligible}${item.reason ? ` · ${item.reason}` : ""}\nQuote expires: ${quote.expiresAt}\nConfirm only before expiry. Cancel never purchases.`, "Purchase"));
|
|
51
|
+
const retry = () => controller.retry((pending: NonNullable<VoyagesSnapshot["pending"]>) => confirm("Resolve pending purchase?", `Outcome unknown: ${pending.item}\n${pending.price} coins · quote ${pending.quoteId}\nReplay the server's SAME saved quote/key pair only. This does not create a new purchase key. Cancel keeps it pending.`, "Retry same purchase"));
|
|
52
|
+
const chooseItem = async (item: VoyagesItem) => {
|
|
53
|
+
if (view().busy) return;
|
|
54
|
+
if (view().snapshot && canEquip(view().snapshot, item)) await equip(item);
|
|
55
|
+
else await purchase(item);
|
|
56
|
+
};
|
|
57
|
+
type Action = { name: string; description: string; value: string };
|
|
58
|
+
const options = (): Action[] => {
|
|
59
|
+
const snapshot = view().snapshot;
|
|
60
|
+
const basic = [
|
|
61
|
+
{ name: "Refresh authoritative state", description: "Reload live profile and catalog", value: "refresh" },
|
|
62
|
+
{ name: "Daily coding quests", description: "Server assignments · selected evidence · explicit external JEV consent", value: "daily" },
|
|
63
|
+
{ name: "Sign in / reconnect", description: "Native provider connection · choose Carthagent Ship device sign-in", value: "signin" },
|
|
64
|
+
{ name: "Back", description: props.sessionID ? "Return to selected session" : "Return home", value: "back" },
|
|
65
|
+
...(props.section ? [{ name: props.section === "profile" ? "Equipment & shop" : "Profile & wallet", description: "Open the other Voyages detail view", value: "section" }] : []),
|
|
66
|
+
];
|
|
67
|
+
if (!snapshot) return basic;
|
|
68
|
+
return [...basic,
|
|
69
|
+
...(props.sessionID ? [{ name: "Claim verified delivery reward", description: "Preview this session’s eligible handoff, then claim once", value: "reward" }] : []),
|
|
70
|
+
...(props.section !== "equipment" ? [{ name: "Customize captain and ship", description: "Preview names, then explicitly Save", value: "customize" }] : []),
|
|
71
|
+
...(snapshot.pending ? [{ name: "Resolve pending purchase", description: "Explicit same-pair replay; no automatic new charge", value: "retry" }] : []),
|
|
72
|
+
...(props.section !== "profile" ? snapshot.items.map((item) => ({ name: voyagesCatalogLabel(snapshot, item), description: `${item.slot} · ${item.itemKey} · ${!item.available ? "unavailable" : !item.eligible ? "ineligible" : canEquip(snapshot, item) ? "confirm equip" : item.price === null ? "purchase unavailable" : "request quote"}${item.reason ? ` · ${item.reason}` : ""}`, value: `item:${item.item}` })) : []),
|
|
73
|
+
];
|
|
74
|
+
};
|
|
75
|
+
const select = (value?: string) => {
|
|
76
|
+
if (!value || view().busy || context.keymap.mode.current() !== "base") return;
|
|
77
|
+
if (value === "back") { back(); return; }
|
|
78
|
+
if (value === "signin") {
|
|
79
|
+
controller.invalidate();
|
|
80
|
+
context.keymap.dispatch("provider.connect");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (value === "refresh") { void controller.refresh(); return; }
|
|
84
|
+
if (value === "reward" && props.sessionID) { void controller.claimReward(props.sessionID, (preview: { coins: number; xp: number; km: number; dailyLimit: number; runId: string; snapshot: string }) => confirm("Claim delivery reward?", `+${preview.coins} coins · +${preview.xp} XP · +${preview.km} km\nUp to ${preview.dailyLimit} deliveries per UTC day.\nRun: ${preview.runId}\nSnapshot: ${preview.snapshot}\nSends delivery/check/review digests, not code or output. Based on local harness verification; the server does not rerun your repository. Replaying this delivery never earns twice.`, "Claim")); return; }
|
|
85
|
+
if (value === "section") { context.ui.router.navigate({ type: "plugin", name: "voyages", data: { ...(props.sessionID ? { sessionID: props.sessionID } : {}), section: props.section === "profile" ? "equipment" : "profile" } }); return; }
|
|
86
|
+
if (value === "daily") { context.ui.router.navigate({ type: "plugin", name: "daily-quests", data: { ...(props.sessionID ? { sessionID: props.sessionID } : {}) } }); return; }
|
|
87
|
+
if (value === "customize") { void customize(); return; }
|
|
88
|
+
if (value === "retry") { void retry(); return; }
|
|
89
|
+
const item = view().snapshot?.items.find((entry) => `item:${entry.item}` === value);
|
|
90
|
+
if (item) void chooseItem(item);
|
|
91
|
+
};
|
|
92
|
+
onMount(() => {
|
|
93
|
+
let binding = JSON.stringify(location());
|
|
94
|
+
const stop = context.data.listen(({ details }) => {
|
|
95
|
+
const type = String(details.type);
|
|
96
|
+
const changedLocation = binding !== JSON.stringify(location());
|
|
97
|
+
if (changedLocation) {
|
|
98
|
+
binding = JSON.stringify(location());
|
|
99
|
+
controller.invalidate();
|
|
100
|
+
closeOwnDialog();
|
|
101
|
+
if (type !== "server.disconnected") void controller.refresh();
|
|
102
|
+
} else void handleVoyagesEvent(controller, details, location(), closeOwnDialog);
|
|
103
|
+
});
|
|
104
|
+
void controller.refresh();
|
|
105
|
+
onCleanup(() => { stop(); controller.dispose(); closeOwnDialog(); });
|
|
106
|
+
});
|
|
107
|
+
return <box flexDirection="column" flexGrow={1} padding={1}>
|
|
108
|
+
<text flexShrink={0} fg={context.theme.text.action.primary.base}>CARTHAGENT · VOYAGES</text>
|
|
109
|
+
<Show when={props.section}><text flexShrink={0} fg={context.theme.text.muted}>{props.section === "profile" ? "Profile & wallet" : "Equipment & shop"}</text></Show>
|
|
110
|
+
{/* Split the remaining height: wrapped profile rows scroll instead of shrinking over one another. */}
|
|
111
|
+
<scrollbox ref={summary} flexBasis={0} flexGrow={1} minHeight={1} contentOptions={{ flexDirection: "column" }}>
|
|
112
|
+
<Show when={view().busy}><text flexShrink={0} fg={context.theme.text.feedback.warning.base}>Loading / awaiting confirmation… other actions are locked.</text></Show>
|
|
113
|
+
<Show when={view().error}><text flexShrink={0} fg={context.theme.text.feedback.error.base}>{view().error}</text></Show>
|
|
114
|
+
<Show when={view().notice}><text flexShrink={0} fg={context.theme.text.feedback.success.base}>{view().notice}</text></Show>
|
|
115
|
+
<Show when={view().snapshot} fallback={<text flexShrink={0} fg={context.theme.text.muted}>Protected profile hidden until a successful refresh. Use Sign in for expired, revoked or legacy credentials.</text>}>{(snapshot) => <box flexDirection="column" flexShrink={0}>
|
|
116
|
+
<text flexShrink={0} fg={context.theme.text.base}>{snapshot().profile.name} · ship {snapshot().profile.shipName} · version {snapshot().profile.version}</text>
|
|
117
|
+
<text flexShrink={0} fg={context.theme.text.action.primary.base}>Wallet: {snapshot().profile.coins} coins · Level {snapshot().profile.level} · XP {snapshot().profile.totalXp} ({snapshot().profile.xpIntoLevel}/{snapshot().profile.xpForLevel})</text>
|
|
118
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Distance {snapshot().profile.km} km · weekly {snapshot().profile.weeklyKm} km</text>
|
|
119
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Skin {snapshot().profile.config.skin} · hair {snapshot().profile.config.hair} · pattern {snapshot().profile.config.pattern} · {snapshot().profile.owned.length} owned items</text>
|
|
120
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Equipped: hairStyle {snapshot().profile.config.hairStyle} · hat {snapshot().profile.config.hat} · shirt {snapshot().profile.config.shirt}</text>
|
|
121
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Bottoms {snapshot().profile.config.bottoms} · shoes {snapshot().profile.config.shoes} · pet {snapshot().profile.config.pet}</text>
|
|
122
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Hull {snapshot().profile.config.hull} · sail {snapshot().profile.config.sail} · setting {snapshot().profile.config.setting}</text>
|
|
123
|
+
<Show when={snapshot().pending}><text flexShrink={0} fg={context.theme.text.feedback.warning.base}>Purchase pending / outcome unknown. Resolve the saved purchase explicitly; new purchases are blocked.</text></Show>
|
|
124
|
+
</box>}</Show>
|
|
125
|
+
<text flexShrink={0} fg={context.theme.text.muted}>Verified delivery rewards: open Voyages from a completed Ship session to preview and claim. Daily assessment alone grants no rewards.</text>
|
|
126
|
+
<text flexShrink={0} fg={context.theme.text.faint}>Choose Back to return. Cancel in dialogs never saves or purchases.</text>
|
|
127
|
+
</scrollbox>
|
|
128
|
+
<text flexShrink={0} fg={context.theme.text.faint}>↑/↓ Enter · PgUp/Dn choices · Ctrl+U/D profile</text>
|
|
129
|
+
<select flexBasis={0} flexGrow={1} flexShrink={0} minHeight={3} ref={(node) => { list = node; }} onMouseUp={(event) => clickSelect(list, event)} showScrollIndicator keyBindings={[{ name: "pageup", action: "move-up-fast" }, { name: "pagedown", action: "move-down-fast" }]} options={options()} focused={context.keymap.mode.current() === "base"} onSelect={(_index, option) => select(option?.value)} />
|
|
130
|
+
</box>;
|
|
131
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Generation gate for async workspace refreshes and reconnect rehydration. */
|
|
2
|
+
export function createLatestRequestGate() {
|
|
3
|
+
let generation = 0;
|
|
4
|
+
let active: AbortController | undefined;
|
|
5
|
+
return {
|
|
6
|
+
begin() {
|
|
7
|
+
active?.abort();
|
|
8
|
+
active = new AbortController();
|
|
9
|
+
const request = ++generation;
|
|
10
|
+
return { signal: active.signal, isCurrent: () => request === generation && !active?.signal.aborted };
|
|
11
|
+
},
|
|
12
|
+
invalidate() { generation++; active?.abort(); active = undefined; },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type WorkspaceUpdatedEvents = {
|
|
17
|
+
on(name: "updated", handler: (event: { location?: { directory: string }; data?: { revision?: string } }) => void, options?: { signal?: AbortSignal }): () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The RPC event is location-scoped by the subscribed RPC client, not its payload. */
|
|
21
|
+
export function onWorkspaceUpdated(events: WorkspaceUpdatedEvents, directory: () => string, invalidate: () => void, options?: { signal?: AbortSignal }) {
|
|
22
|
+
return events.on("updated", (event) => {
|
|
23
|
+
if (event.location?.directory === directory()) invalidate();
|
|
24
|
+
}, options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deliveryReadiness(delivery: {
|
|
28
|
+
completion?: string;
|
|
29
|
+
gate?: string;
|
|
30
|
+
freshness?: string;
|
|
31
|
+
truncation?: Record<string, { returned: number; total: number }>;
|
|
32
|
+
} | undefined) {
|
|
33
|
+
if (!delivery) return { complete: false, coverage: "unknown", status: "not loaded" };
|
|
34
|
+
const truncation = delivery.truncation;
|
|
35
|
+
const capped = truncation ? Object.entries(truncation).filter(([, value]) => value.returned !== value.total) : [];
|
|
36
|
+
const complete = delivery.completion === "ready";
|
|
37
|
+
const totals = capped.map(([name, value]) => `${name} ${value.returned}/${value.total}`);
|
|
38
|
+
return {
|
|
39
|
+
complete,
|
|
40
|
+
coverage: truncation ? (totals.length ? "partial" : "complete") : "unknown",
|
|
41
|
+
status: `completion ${delivery.completion ?? "unknown"} · gate ${delivery.gate ?? "unknown"} · freshness ${delivery.freshness ?? "unknown"}${totals.length ? ` · partial coverage: ${totals.join(", ")}` : ""}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderDeliveryReadiness(delivery: Parameters<typeof deliveryReadiness>[0]) {
|
|
46
|
+
const readiness = deliveryReadiness(delivery);
|
|
47
|
+
const sections = delivery?.truncation
|
|
48
|
+
? Object.entries(delivery.truncation).map(([name, value]) => `${name}: ${value.returned} of ${value.total} returned`)
|
|
49
|
+
: ["Coverage metadata unavailable: completeness cannot be confirmed"];
|
|
50
|
+
const receipts = (delivery && "evidence" in delivery && Array.isArray(delivery.evidence) ? delivery.evidence : [])
|
|
51
|
+
.map((item) => `Receipt ${item.id}: ${item.status}`);
|
|
52
|
+
const gate = delivery?.gate === "open" || delivery?.gate === "locked" ? delivery.gate : "unknown";
|
|
53
|
+
return {
|
|
54
|
+
label: `Readiness: ${delivery?.completion === "ready" ? "ready" : delivery?.completion === "incomplete" ? "incomplete" : "unknown"} · ${readiness.status}`,
|
|
55
|
+
gate,
|
|
56
|
+
coverageLabel: readiness.coverage === "unknown" ? "Coverage: unknown" : `Coverage: ${readiness.coverage}`,
|
|
57
|
+
coverage: readiness.coverage,
|
|
58
|
+
sections,
|
|
59
|
+
receipts,
|
|
60
|
+
};
|
|
61
|
+
}
|