carthagent 1.0.7 → 1.1.0-preview.12

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.
Files changed (275) hide show
  1. package/README.md +80 -79
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  4. package/docs/glyph-review/test-isolation-v1.md +26 -0
  5. package/docs/glyph-review/typecheck-base.log +1 -0
  6. package/docs/glyph-review/typecheck-v4.log +1 -0
  7. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  8. package/docs/help-dispatch-review.md +69 -0
  9. package/docs/legacy-cli.md +150 -0
  10. package/docs/live-theme-plugin-api.md +18 -0
  11. package/docs/native-cli.md +102 -0
  12. package/docs/native-compatibility.md +16 -0
  13. package/docs/native-daily-quests.md +132 -0
  14. package/docs/native-glyph-pty.md +59 -0
  15. package/docs/native-manual-testing.md +314 -0
  16. package/docs/native-slash-acceptance-handoff.md +49 -0
  17. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  18. package/docs/native-terminal-performance.md +310 -0
  19. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  20. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  21. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  22. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  23. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  24. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  31. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  32. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  33. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  35. package/docs/native-terminal-semantics.md +89 -0
  36. package/docs/native-upgrade.md +177 -0
  37. package/docs/opencode-composed-source-review.md +89 -0
  38. package/docs/opencode-migration.md +225 -9
  39. package/docs/opencode-owned-runtime.md +241 -0
  40. package/docs/opencode-runtime.md +12 -0
  41. package/docs/opencode-theme-provider-proof/README.md +55 -0
  42. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  43. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  44. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  45. package/docs/owned-host-controls-proof.md +39 -0
  46. package/docs/owned-runtime-release-manifest.md +42 -0
  47. package/docs/owned-visual-clear-proof.md +40 -0
  48. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  49. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  50. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  51. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  52. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  53. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  54. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  57. package/docs/release-notes-native-draft.md +73 -0
  58. package/docs/release-notes-preview.10.md +121 -0
  59. package/docs/release-notes-preview.11.md +76 -0
  60. package/docs/release-notes-preview.12.md +55 -0
  61. package/docs/release-notes-preview.9.md +130 -0
  62. package/evaluate.mjs +28 -1
  63. package/lib/budget.mjs +1 -1
  64. package/lib/codegraph.mjs +1 -1
  65. package/lib/delivery.mjs +3 -2
  66. package/lib/environment.mjs +8 -0
  67. package/lib/guidance.mjs +1 -1
  68. package/lib/opencode/cli-runtime.mjs +1171 -0
  69. package/lib/opencode/cli.mjs +252 -0
  70. package/lib/opencode/credentials.mjs +63 -8
  71. package/lib/opencode/demo.mjs +53 -0
  72. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  73. package/lib/opencode/evaluation.mjs +32 -12
  74. package/lib/opencode/headless.mjs +8 -4
  75. package/lib/opencode/provider-policy.mjs +23 -0
  76. package/lib/opencode/review.mjs +37 -16
  77. package/lib/opencode/runtime-artifact.mjs +335 -0
  78. package/lib/opencode/runtime-github-transport.mjs +121 -0
  79. package/lib/opencode/runtime-release.json +36 -0
  80. package/lib/opencode/runtime.mjs +298 -20
  81. package/lib/opencode/service.mjs +77 -35
  82. package/lib/opencode/session-options.mjs +6 -2
  83. package/lib/opencode/update.mjs +222 -0
  84. package/lib/opencode/workspace.mjs +1 -1
  85. package/lib/planning-advice.mjs +2 -2
  86. package/lib/tui/framing.mjs +1 -80
  87. package/lib/update.mjs +10 -1
  88. package/package.json +33 -7
  89. package/plugins/carthagent/README.md +23 -0
  90. package/plugins/carthagent/src/bootstrap.ts +1 -1
  91. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  92. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  93. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  94. package/plugins/carthagent/src/index.ts +71 -24
  95. package/plugins/carthagent/src/rpc.ts +51 -6
  96. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  97. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  98. package/plugins/carthagent/src/server/daily-source-rpc.ts +197 -0
  99. package/plugins/carthagent/src/server/delivery.ts +217 -106
  100. package/plugins/carthagent/src/server/framing.mjs +80 -0
  101. package/plugins/carthagent/src/server/policy.ts +1 -1
  102. package/plugins/carthagent/src/server/providers.ts +58 -30
  103. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  104. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  105. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  106. package/plugins/carthagent/src/server/ship-guidance.ts +39 -0
  107. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  108. package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
  109. package/plugins/carthagent/src/testing.ts +5 -0
  110. package/plugins/carthagent/src/tui.tsx +492 -0
  111. package/plugins/carthagent/src/ui/client.ts +125 -0
  112. package/plugins/carthagent/src/ui/commands.ts +70 -0
  113. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  114. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  115. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  116. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  117. package/plugins/carthagent/src/ui/daily-quests.tsx +232 -0
  118. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  119. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  120. package/plugins/carthagent/src/ui/native-review.d.mts +31 -0
  121. package/plugins/carthagent/src/ui/native-review.mjs +154 -0
  122. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  123. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  124. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  125. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  126. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  127. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  128. package/plugins/carthagent/src/ui/voyages-navigation.tsx +122 -0
  129. package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
  130. package/plugins/carthagent/src/ui/voyages.tsx +127 -0
  131. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  132. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  133. package/plugins/carthagent/src/voyages-rpc.ts +60 -0
  134. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  135. package/plugins/carthagent/tests/commands.test.ts +107 -0
  136. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  137. package/plugins/carthagent/tests/voyages-navigation.test.tsx +258 -0
  138. package/plugins/carthagent/themes/carthage.json +67 -0
  139. package/plugins/carthagent/tui.ts +26 -1
  140. package/script/ctg/build-owned-runtime.sh +166 -0
  141. package/tests/budget-engine.test.mjs +4 -3
  142. package/tests/cli.test.mjs +52 -96
  143. package/tests/daily-evidence.test.mjs +62 -0
  144. package/tests/delivery.test.mjs +22 -1
  145. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  146. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  147. package/tests/installed-runtime-contract.test.mjs +43 -0
  148. package/tests/native-cli-host-smoke.mjs +457 -0
  149. package/tests/native-daily-editor-focus.test.mjs +213 -0
  150. package/tests/native-daily-editor.test.mjs +43 -0
  151. package/tests/native-daily-quests-layout.test.mjs +53 -0
  152. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  153. package/tests/native-daily-quests-ui.test.mjs +778 -0
  154. package/tests/native-daily-source.test.mjs +426 -0
  155. package/tests/native-demo-ui.test.mjs +48 -0
  156. package/tests/native-evaluation-admission.test.mjs +24 -0
  157. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  158. package/tests/native-fixture-runtime.test.mjs +111 -0
  159. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  160. package/tests/native-glyph-pty.test.mjs +107 -0
  161. package/tests/native-manual-projects.test.mjs +170 -0
  162. package/tests/native-owned-runtime-env.test.mjs +140 -0
  163. package/tests/native-paired-guards.test.mjs +464 -0
  164. package/tests/native-provider-policy.test.mjs +31 -0
  165. package/tests/native-pty-invocation.test.mjs +114 -0
  166. package/tests/native-pty-runtime.test.mjs +45 -0
  167. package/tests/native-resume-session.test.mjs +46 -0
  168. package/tests/native-review-consumer.test.mjs +267 -0
  169. package/tests/native-runtime-identity.test.mjs +214 -0
  170. package/tests/native-ship-parity.test.mjs +526 -0
  171. package/tests/native-ship-plan.test.mjs +75 -0
  172. package/tests/native-ship-rpc.test.mjs +71 -0
  173. package/tests/native-ui-commands.test.mjs +6 -0
  174. package/tests/native-voyages-layout.test.mjs +28 -0
  175. package/tests/native-voyages-navigation.test.mjs +169 -0
  176. package/tests/native-voyages-rpc.test.mjs +395 -0
  177. package/tests/native-voyages-ui.test.mjs +277 -0
  178. package/tests/opencode-atomic-write.test.mjs +55 -0
  179. package/tests/opencode-cli.test.mjs +1312 -0
  180. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  181. package/tests/opencode-providers.test.mjs +245 -18
  182. package/tests/opencode-runtime.test.mjs +546 -8
  183. package/tests/opencode-session-controls.test.mjs +15 -0
  184. package/tests/opencode-ship-rpc.test.mjs +71 -0
  185. package/tests/opencode-update.test.mjs +475 -0
  186. package/tests/opencode-workspace.test.mjs +51 -2
  187. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  188. package/tests/release-workflow.test.mjs +46 -0
  189. package/tests/runtime-artifact.test.mjs +269 -0
  190. package/tests/runtime-development-trust.test.mjs +230 -0
  191. package/tests/runtime-github-transport.test.mjs +132 -0
  192. package/tests/runtime-token-env.test.mjs +23 -0
  193. package/tests/test_native_terminal_probe.py +76 -0
  194. package/tests/test_native_terminal_semantics.py +232 -0
  195. package/tests/update.test.mjs +36 -0
  196. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  197. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  198. package/tests/workspace-live.test.mjs +127 -0
  199. package/tools/benchmark/diagnose-request.mjs +59 -36
  200. package/tools/benchmark/eval-common.mjs +3 -3
  201. package/tools/benchmark/evaluators.test.mjs +4 -4
  202. package/tools/benchmark/guard.test.mjs +35 -2
  203. package/tools/benchmark/guard.ts +20 -7
  204. package/tools/benchmark/planning-startup.test.mjs +30 -34
  205. package/tools/benchmark/runner.mjs +12 -7
  206. package/tools/benchmark/runner.test.mjs +23 -7
  207. package/tools/benchmark/upstream-error.test.mjs +16 -2
  208. package/tools/generate-owned-runtime-release.mjs +217 -0
  209. package/tools/installed-runtime-contract.mjs +16 -0
  210. package/tools/manual-runtime-manifest.mjs +39 -0
  211. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  212. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  213. package/tools/native-fixture.mjs +199 -40
  214. package/tools/native-glyph-helpers.py +127 -0
  215. package/tools/native-glyph-pty.py +144 -0
  216. package/tools/native-manual-projects.mjs +259 -0
  217. package/tools/native-manual.mjs +338 -0
  218. package/tools/native-model-picker-acceptance.py +306 -0
  219. package/tools/native-owned-runtime-env.sh +47 -0
  220. package/tools/native-paired-guards.mjs +282 -0
  221. package/tools/native-paired-manifest.mjs +72 -0
  222. package/tools/native-paired-orchestration.sh +51 -0
  223. package/tools/native-plugin-readiness.mjs +33 -0
  224. package/tools/native-plugin-readiness.test.mjs +53 -0
  225. package/tools/native-pty-invocation.mjs +26 -0
  226. package/tools/native-pty-runtime.mjs +83 -0
  227. package/tools/native-pty-smoke.py +147 -24
  228. package/tools/native-public-repo-shell-result.mjs +13 -0
  229. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  230. package/tools/native-runtime-identity.mjs +201 -0
  231. package/tools/native-slash-acceptance-env.py +38 -0
  232. package/tools/native-terminal-semantics.py +876 -0
  233. package/tools/owned-shell-observer/index.ts +16 -0
  234. package/tools/owned-shell-observer/package.json +1 -0
  235. package/tools/owned-visual-clear-env.py +48 -0
  236. package/tools/owned-visual-clear-model.py +21 -0
  237. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  238. package/tools/prepare-native-manual.mjs +122 -0
  239. package/tools/run-native-terminal-paired.sh +403 -0
  240. package/tools/runtime-token-env.mjs +8 -0
  241. package/tools/seed-native-switch-fixture.mjs +79 -0
  242. package/tools/test-native-controls.mjs +3 -1
  243. package/tools/test-native-daily-quests.mjs +65 -0
  244. package/tools/test-native-default-cli.mjs +106 -0
  245. package/tools/test-native-delivery-host.mjs +100 -18
  246. package/tools/test-native-evaluation.mjs +7 -3
  247. package/tools/test-native-glyph-pty.mjs +94 -0
  248. package/tools/test-native-host.mjs +83 -61
  249. package/tools/test-native-outcomes.mjs +3 -1
  250. package/tools/test-native-package.mjs +206 -38
  251. package/tools/test-native-performance.mjs +26 -0
  252. package/tools/test-native-product.mjs +90 -0
  253. package/tools/test-native-pty.mjs +34 -9
  254. package/tools/test-native-public-repo.mjs +224 -0
  255. package/tools/test-native-refresh-reentry.mjs +106 -0
  256. package/tools/test-native-report.mjs +27 -20
  257. package/tools/test-native-review.mjs +75 -0
  258. package/tools/test-native-runtime.mjs +120 -0
  259. package/tools/test-native-ship-models.mjs +176 -0
  260. package/tools/test-native-ship-walkthrough.mjs +96 -0
  261. package/tools/test-native-slash-commands.mjs +125 -0
  262. package/tools/test-native-slash-commands.py +242 -0
  263. package/tools/test-native-swe-host.mjs +224 -55
  264. package/tools/test-native-tasks.mjs +77 -0
  265. package/tools/test-native-terminal-performance.py +887 -0
  266. package/tools/test-native-terminal-readiness.sh +85 -0
  267. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  268. package/tools/test-native-visual-clear-installed.mjs +48 -0
  269. package/tools/test-owned-host-controls.mjs +238 -0
  270. package/tools/test-owned-opencode-runtime.mjs +35 -0
  271. package/tools/test-owned-visual-clear-env.py +120 -0
  272. package/tools/test-owned-visual-clear.py +373 -0
  273. package/tools/verify-draft-runtime-gate.mjs +138 -0
  274. package/tools/verify-owned-runtime-release.mjs +114 -0
  275. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,42 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export type NativeCliConfig = Record<string, unknown> & { theme?: { name?: string } | string };
5
+
6
+ /** Resolve OpenCode's native CLI profile config (not CTG's separate user profile). */
7
+ export function cliConfigPath(env = process.env, home = process.env.HOME ?? "") {
8
+ const configRoot = env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode");
9
+ return join(configRoot, "cli.json");
10
+ }
11
+
12
+ /** Native TUI themes are discovered from config/opencode/themes for this profile. */
13
+ export function cliConfigDirectory(env = process.env, home = process.env.HOME ?? "") {
14
+ return env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode");
15
+ }
16
+
17
+ export async function readCliConfig(path: string): Promise<NativeCliConfig> {
18
+ try {
19
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
20
+ return value && typeof value === "object" && !Array.isArray(value) ? value as NativeCliConfig : {};
21
+ } catch (error) {
22
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ /** Atomically preserve unrelated native CLI settings while selecting a theme. */
28
+ export async function writeNativeTheme(path: string, name: string) {
29
+ const config = await readCliConfig(path);
30
+ config.theme = typeof config.theme === "object" && config.theme !== null
31
+ ? { ...config.theme, name }
32
+ : { name };
33
+ await mkdir(dirname(path), { recursive: true });
34
+ const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
35
+ try {
36
+ await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
37
+ await rename(temp, path);
38
+ } catch (error) {
39
+ await import("node:fs/promises").then(({ rm }) => rm(temp, { force: true })).catch(() => undefined);
40
+ throw error;
41
+ }
42
+ }
@@ -0,0 +1,23 @@
1
+ import type { Context } from "@opencode/plugin/tui/context";
2
+ import type { JSX } from "solid-js";
3
+
4
+ /** Optional host extension: released runtimes retain slash-command navigation. */
5
+ export type NavigationInput = {
6
+ readonly width: number;
7
+ readonly compact: boolean;
8
+ readonly active: boolean;
9
+ readonly focused: boolean;
10
+ readonly sessionID?: string;
11
+ readonly focus: () => void;
12
+ readonly blur: () => void;
13
+ };
14
+
15
+ export type NativeNavigation = {
16
+ register(pane: { name: string; title: string; render(input: NavigationInput): JSX.Element }): () => void;
17
+ select(name: string): boolean;
18
+ current(): string;
19
+ };
20
+
21
+ export function nativeNavigation(context: Pick<Context, "ui">) {
22
+ return (context.ui as Context["ui"] & { navigation?: NativeNavigation }).navigation;
23
+ }
@@ -0,0 +1,31 @@
1
+ export interface NativeReviewOffer {
2
+ sessionID: string;
3
+ runKey: string;
4
+ writes: number;
5
+ start: () => Promise<boolean>;
6
+ }
7
+
8
+ export interface NativeReviewConsumerOptions {
9
+ mode: (sessionID: string) => string;
10
+ diff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>;
11
+ offer: (value: NativeReviewOffer) => Promise<unknown>;
12
+ kickoff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>;
13
+ isLoopActive?: (sessionID: string) => boolean;
14
+ setLoopActive?: (sessionID: string, active: boolean) => void;
15
+ signal?: AbortSignal;
16
+ }
17
+
18
+ export function sendNativeReviewKickoff(options: { client: any; sessionID: string; signal?: AbortSignal; prompt?: string }): Promise<void>;
19
+ export function nativeReviewMode(session: any, config: any, explicitMode?: string): string;
20
+ export function createNativeReviewConsumer(options: NativeReviewConsumerOptions): {
21
+ setLoopActive(sessionID: string, active: boolean): void;
22
+ onUserPromptAdmitted(sessionID: string, message?: { type?: string; metadata?: Record<string, unknown> }): void;
23
+ onInboxDelivered(event: any, message?: { type?: string; metadata?: Record<string, unknown> }): void;
24
+ onStarted(event: any): void;
25
+ onSucceeded(event: any): Promise<void>;
26
+ onFailed(event: any): Promise<void>;
27
+ onInterrupted(event: any): Promise<void>;
28
+ enqueueInboxDelivery(event: any, getMessage: (sessionID: string, messageID: string, signal: AbortSignal) => Promise<{ type?: string; metadata?: Record<string, unknown> } | undefined>): Promise<void>;
29
+ dispose(): void;
30
+ };
31
+ export function subscribeNativeReviewEvents(data: any, consumer: ReturnType<typeof createNativeReviewConsumer>, onError?: (error: unknown) => void, getMessage?: (sessionID: string, messageID: string, signal: AbortSignal) => Promise<{ type?: string; metadata?: Record<string, unknown> } | undefined>): Array<() => void>;
@@ -0,0 +1,154 @@
1
+ import { reviewKickoff } from "../../../../lib/review.mjs";
2
+ import { resolveReviewMode } from "../../../../lib/review.mjs";
3
+ import { SESSION_OPTIONS_KEY } from "../../../../lib/opencode/session-options.mjs";
4
+
5
+ /** Send the PR ↔ fresh-review ↔ fixes instruction to the working session. */
6
+ export async function sendNativeReviewKickoff({ client, sessionID, signal, prompt = reviewKickoff() }) {
7
+ await client.session.prompt({ sessionID, text: prompt, metadata: { "ctg.raw": true, "ctg.reviewKickoff": true } }, { signal });
8
+ }
9
+
10
+ /** Explicit launch mode persisted for the session wins over the profile default. */
11
+ export function nativeReviewMode(session, config, explicitMode) {
12
+ return resolveReviewMode(explicitMode ?? session?.metadata?.[SESSION_OPTIONS_KEY]?.review, config);
13
+ }
14
+
15
+ /** Correlate host executions and admit one current-run kickoff per session. */
16
+ /** @param {{ mode: (sessionID: string) => string, diff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, offer: (value: { sessionID: string; runKey: string; writes: number; start: () => Promise<boolean> }) => Promise<unknown>, kickoff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, isLoopActive?: (sessionID: string) => boolean, setLoopActive?: (sessionID: string, active: boolean) => void, signal?: AbortSignal }} options */
17
+ export function createNativeReviewConsumer({ mode, diff, offer, kickoff, isLoopActive = () => false, setLoopActive = () => {}, signal }) {
18
+ const runs = new Map();
19
+ const admitted = new Set();
20
+ const pending = new Set();
21
+ const loopSessions = new Set();
22
+ const admissionTails = new Map();
23
+ const loopGenerations = new Map();
24
+ const admissionAbort = new AbortController();
25
+ let sequence = 0;
26
+ let disposed = false;
27
+
28
+ async function finish(sessionID, succeeded) {
29
+ const run = runs.get(sessionID);
30
+ if (!run || disposed) return;
31
+ // Inbox delivery follows execution.started and precedes the terminal event.
32
+ // Wait for the exact delivered message lookup before deciding whether the
33
+ // restored review loop still suppresses this run.
34
+ const admissionTail = admissionTails.get(sessionID);
35
+ try { await admissionTail; } catch {}
36
+ if (admissionTails.get(sessionID) === admissionTail) admissionTails.delete(sessionID);
37
+ if (disposed || runs.get(sessionID) !== run || run.finished) return;
38
+ run.finished = true;
39
+ if (!succeeded || signal?.aborted || loopSessions.has(sessionID) || isLoopActive(sessionID)) return;
40
+
41
+ let baseline;
42
+ try { baseline = await run.baseline; }
43
+ catch { return; }
44
+ if (disposed || signal?.aborted || runs.get(sessionID) !== run) return;
45
+ let changes;
46
+ try { changes = await diff(sessionID, signal); }
47
+ catch { return; }
48
+ if (disposed || signal?.aborted || runs.get(sessionID) !== run) return;
49
+ if (!Array.isArray(changes) || !Array.isArray(baseline)) return;
50
+ const before = new Map(baseline.map((file) => [file.file, `${file.status}\0${file.patch}`]));
51
+ const writes = changes.filter((file) => file && (file.additions > 0 || file.deletions > 0 || file.status === "added" || file.status === "deleted") && before.get(file.file) !== `${file.status}\0${file.patch}`).length;
52
+ const runKey = `${sessionID}@${run.id}`;
53
+ const currentMode = mode(sessionID);
54
+ if (!(currentMode === "ask" || currentMode === "yes") || writes <= 0 || admitted.has(runKey) || (currentMode === "yes" && loopSessions.has(sessionID))) return;
55
+ admitted.add(runKey);
56
+
57
+ const start = async () => {
58
+ if (disposed || signal?.aborted || runs.get(sessionID) !== run || pending.has(sessionID) || loopSessions.has(sessionID) || isLoopActive(sessionID)) return false;
59
+ pending.add(sessionID);
60
+ loopSessions.add(sessionID);
61
+ setLoopActive(sessionID, true);
62
+ try {
63
+ await kickoff(sessionID, signal);
64
+ return true;
65
+ } catch (error) {
66
+ loopSessions.delete(sessionID);
67
+ setLoopActive(sessionID, false);
68
+ throw error;
69
+ } finally {
70
+ pending.delete(sessionID);
71
+ }
72
+ };
73
+
74
+ if (currentMode === "yes") {
75
+ await start();
76
+ return;
77
+ }
78
+ let offered = false;
79
+ await offer({ sessionID, runKey, writes, start: async () => {
80
+ if (offered) return false;
81
+ offered = true;
82
+ if (runs.get(sessionID) !== run) return false;
83
+ return start();
84
+ } });
85
+ }
86
+
87
+ return {
88
+ onUserPromptAdmitted(sessionID, message, expectedGeneration) {
89
+ if (!sessionID || message?.type !== "user" || message.metadata?.["ctg.reviewKickoff"] === true) return;
90
+ if (expectedGeneration !== undefined && (loopGenerations.get(sessionID) ?? 0) !== expectedGeneration) return;
91
+ loopSessions.delete(sessionID);
92
+ if (isLoopActive(sessionID)) setLoopActive(sessionID, false);
93
+ },
94
+ setLoopActive(sessionID, active) {
95
+ if (!sessionID) return;
96
+ if (active) {
97
+ loopSessions.add(sessionID);
98
+ loopGenerations.set(sessionID, (loopGenerations.get(sessionID) ?? 0) + 1);
99
+ }
100
+ else loopSessions.delete(sessionID);
101
+ setLoopActive(sessionID, active);
102
+ },
103
+ onStarted(event) {
104
+ const sessionID = event?.data?.sessionID;
105
+ if (!sessionID || disposed) return;
106
+ const id = event.id ?? `${event.created ?? Date.now()}-${++sequence}`;
107
+ // A rejection is handled here, not left as an unhandled promise if the run fails.
108
+ const baseline = Promise.resolve().then(() => diff(sessionID, signal)).catch(() => undefined);
109
+ runs.set(sessionID, { id, baseline, finished: false });
110
+ },
111
+ onSucceeded(event) { return finish(event?.data?.sessionID, true); },
112
+ onFailed(event) { return finish(event?.data?.sessionID, false); },
113
+ onInterrupted(event) { return finish(event?.data?.sessionID, false); },
114
+ onInboxDelivered(event, message) {
115
+ const sessionID = event?.data?.sessionID;
116
+ if (!sessionID || disposed) return;
117
+ this.onUserPromptAdmitted(sessionID, message);
118
+ },
119
+ enqueueInboxDelivery(event, getMessage) {
120
+ const sessionID = event?.data?.sessionID;
121
+ const messageID = event?.data?.inboxID;
122
+ if (!sessionID || !messageID || disposed) return Promise.resolve();
123
+ const expectedGeneration = loopGenerations.get(sessionID) ?? 0;
124
+ const previous = admissionTails.get(sessionID) ?? Promise.resolve();
125
+ const current = previous.catch(() => {}).then(async () => {
126
+ if (disposed) return;
127
+ const timeout = AbortSignal.timeout(5_000);
128
+ const lookupSignal = signal ? AbortSignal.any([signal, admissionAbort.signal, timeout]) : AbortSignal.any([admissionAbort.signal, timeout]);
129
+ lookupSignal.throwIfAborted();
130
+ const message = await Promise.race([
131
+ getMessage(sessionID, messageID, lookupSignal),
132
+ new Promise((resolve) => lookupSignal.addEventListener("abort", () => resolve(undefined), { once: true })),
133
+ ]);
134
+ if (!disposed && !lookupSignal.aborted) this.onUserPromptAdmitted(sessionID, message, expectedGeneration);
135
+ });
136
+ admissionTails.set(sessionID, current);
137
+ return current;
138
+ },
139
+ dispose() { disposed = true; admissionAbort.abort(); admissionTails.clear(); runs.clear(); pending.clear(); },
140
+ };
141
+ }
142
+
143
+ /** Wire host admission and execution events; returned disposers belong to plugin setup. */
144
+ export function subscribeNativeReviewEvents(data, consumer, onError = (_error) => {}, getMessage = async () => undefined) {
145
+ return [
146
+ data.on("session.inbox.delivered", (event) => {
147
+ return consumer.enqueueInboxDelivery(event, getMessage).catch(onError);
148
+ }),
149
+ data.on("session.execution.started", (event) => consumer.onStarted(event)),
150
+ data.on("session.execution.succeeded", (event) => consumer.onSucceeded(event).catch(onError)),
151
+ data.on("session.execution.failed", (event) => consumer.onFailed(event).catch(onError)),
152
+ data.on("session.execution.interrupted", (event) => consumer.onInterrupted(event).catch(onError)),
153
+ ];
154
+ }
@@ -0,0 +1,34 @@
1
+ /** Resolve session IDs independently of the host's title-oriented search filter. */
2
+ export async function findNativeResumeSession({ query, directory, client, pageSize = 100 }) {
3
+ const normalized = query.trim().toLowerCase();
4
+ if (!normalized) return undefined;
5
+ const isSessionID = /^ses(?:sion)?[_-]/i.test(query.trim());
6
+
7
+ if (isSessionID) {
8
+ try {
9
+ const exact = await client.session.get({ sessionID: query.trim() });
10
+ if (exact && !exact.parentID && exact.location.directory === directory) return exact;
11
+ } catch { /* non-exact IDs continue as prefix search */ }
12
+ }
13
+
14
+ const loadPages = async (search) => {
15
+ const sessions = [];
16
+ let cursor;
17
+ do {
18
+ const page = await client.session.list({
19
+ limit: pageSize, order: 'desc', directory,
20
+ ...(cursor ? { cursor } : {}), ...(search ? { search } : {}),
21
+ });
22
+ sessions.push(...page.data.filter((session) => !session.parentID));
23
+ cursor = page.cursor.next ?? undefined;
24
+ } while (cursor);
25
+ return sessions;
26
+ };
27
+
28
+ const unfiltered = await loadPages();
29
+ if (isSessionID) {
30
+ const prefix = unfiltered.find((session) => session.id.toLowerCase().startsWith(normalized));
31
+ if (prefix) return prefix;
32
+ }
33
+ return (await loadPages(query)).find((session) => (session.title ?? '').toLowerCase().includes(normalized));
34
+ }
@@ -0,0 +1,7 @@
1
+ import type { JsonValue } from "@opencode/client";
2
+ export type AutoCompactionAction = "on" | "off" | "toggle";
3
+ export interface SessionMetadataClient {
4
+ get(input: { sessionID: string }, options: { location: unknown; signal: AbortSignal }): Promise<{ metadata?: Record<string, JsonValue> }>;
5
+ update(input: { sessionID: string; metadata: Record<string, JsonValue> }, options: { location: unknown; signal: AbortSignal }): Promise<unknown>;
6
+ }
7
+ export function setSessionAutoCompaction(client: SessionMetadataClient, sessionID: string, location: unknown, action: AutoCompactionAction): Promise<boolean>;
@@ -0,0 +1,14 @@
1
+ /** Persist the host-owned per-session opt-out without disturbing neighbor metadata. */
2
+ export async function setSessionAutoCompaction(client, sessionID, location, action) {
3
+ if (action !== "toggle" && action !== "on" && action !== "off") throw new TypeError("Usage: /autocompact [on|off].");
4
+ const options = { location, signal: AbortSignal.timeout(15_000) };
5
+ const session = await client.get({ sessionID }, options);
6
+ const metadata = { ...session.metadata };
7
+ const enabled = metadata["ctg.autoCompaction"] !== false;
8
+ const next = action === "on" ? true : action === "off" ? false : !enabled;
9
+ if (next === enabled) return enabled;
10
+ if (next) delete metadata["ctg.autoCompaction"];
11
+ else metadata["ctg.autoCompaction"] = false;
12
+ await client.update({ sessionID, metadata }, { location, signal: AbortSignal.timeout(15_000) });
13
+ return next;
14
+ }
@@ -0,0 +1,3 @@
1
+ export declare function convertLegacyTheme(name: string, palette: Record<string, unknown>, lightPalette?: Record<string, unknown>): Record<string, unknown>;
2
+ export declare function provisionNativeThemes(profileConfigDir: string, names?: readonly string[]): Promise<{ directory: string; installed: string[] }>;
3
+ export declare function provisionDefaultNativeTheme(profileConfigDir: string): Promise<{ directory: string; installed: string[] }>;
@@ -0,0 +1,134 @@
1
+ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { flattenTheme, THEMES } from "../../../../lib/tui/theme.mjs";
5
+
6
+ const PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url));
7
+ const COLORS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "gray"];
8
+ const STEPS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
9
+
10
+ function mixHex(color, target, amount) {
11
+ const expand = (value) => {
12
+ const hex = value.slice(1);
13
+ const full = hex.length === 3 ? [...hex].map((digit) => digit + digit).join("") : hex;
14
+ return full.match(/[\da-f]{2}/gi)?.map((part) => Number.parseInt(part, 16)) ?? [128, 128, 128];
15
+ };
16
+ const source = expand(color);
17
+ const destination = expand(target);
18
+ return `#${source.map((channel, index) => Math.round(channel * (1 - amount) + destination[index] * amount).toString(16).padStart(2, "0")).join("")}`;
19
+ }
20
+
21
+ function buildPalette(palette) {
22
+ const fallback = "#808080";
23
+ const semantic = { red: palette.error, orange: palette.warning, yellow: palette.warning, green: palette.success, cyan: palette.info, blue: palette.info, purple: palette.primary ?? palette.secondary, gray: palette.textMuted };
24
+ const darkMode = Number.parseInt(palette.background?.slice(1, 3) ?? "80", 16) < 128;
25
+ const hue = Object.fromEntries(COLORS.map((color) => {
26
+ const source = typeof semantic[color] === "string" && /^#[\da-f]{6}$/i.test(semantic[color]) ? semantic[color] : fallback;
27
+ const scale = Object.fromEntries(STEPS.map((step) => {
28
+ const progress = (step - 100) / 800;
29
+ const towardLight = darkMode ? progress < 0.5 : progress >= 0.5;
30
+ const distance = Math.abs(progress - 0.5) / 0.5;
31
+ const amount = 0.78 * distance;
32
+ return [step, mixHex(source, towardLight ? "#ffffff" : "#000000", amount)];
33
+ }));
34
+ return [color, scale];
35
+ }));
36
+ hue.accent = "$hue.purple";
37
+ hue.interactive = "$hue.purple";
38
+ hue.neutral = "$hue.gray";
39
+ const token = (value) => {
40
+ if (typeof value !== "string" || !/^#[\da-f]{3,8}$/i.test(value)) return value;
41
+ return value;
42
+ };
43
+ const state = (base) => ({ base: token(base), $hovered: token(base), $pressed: token(base), $focused: token(base), $selected: token(base), $disabled: token(base) });
44
+ const feedback = (base) => ({ base: token(base), muted: token(base) });
45
+ return {
46
+ hue,
47
+ text: {
48
+ base: token(palette.text), muted: token(palette.textMuted), faint: token(palette.textMuted),
49
+ action: { primary: state(palette.primary), secondary: state(palette.secondary), destructive: state(palette.error) },
50
+ formfield: state(palette.text),
51
+ feedback: { info: feedback(palette.info), success: feedback(palette.success), warning: feedback(palette.warning), error: feedback(palette.error) },
52
+ },
53
+ background: {
54
+ base: token(palette.background),
55
+ raised: { base: token(palette.backgroundPanel), high: token(palette.backgroundElement), max: token(palette.border) },
56
+ action: { primary: state(palette.accent), secondary: state(palette.backgroundElement), destructive: state(palette.error) },
57
+ formfield: state(palette.background),
58
+ feedback: { info: { base: token(palette.info) }, success: { base: token(palette.success) }, warning: { base: token(palette.warning) }, error: { base: token(palette.error) } },
59
+ },
60
+ border: { base: token(palette.border) }, scrollbar: { base: token(palette.borderActive ?? palette.accent) },
61
+ diff: {
62
+ text: { added: token(palette.diffAdded), removed: token(palette.diffRemoved), context: token(palette.diffContext), hunkHeader: token(palette.diffHunkHeader) },
63
+ background: { added: token(palette.diffAddedBg), removed: token(palette.diffRemovedBg), context: token(palette.diffContextBg) },
64
+ highlight: { added: token(palette.diffHighlightAdded), removed: token(palette.diffHighlightRemoved) },
65
+ lineNumber: { text: token(palette.diffLineNumber), background: { added: token(palette.diffAddedLineNumberBg), removed: token(palette.diffRemovedLineNumberBg) } },
66
+ },
67
+ syntax: { comment: token(palette.syntaxComment), keyword: token(palette.syntaxKeyword), function: token(palette.syntaxFunction), variable: token(palette.syntaxVariable), string: token(palette.syntaxString), number: token(palette.syntaxNumber), type: token(palette.syntaxType), operator: token(palette.syntaxOperator), punctuation: token(palette.syntaxPunctuation) },
68
+ markdown: { text: token(palette.markdownText), heading: token(palette.markdownHeading), link: token(palette.markdownLink), linkText: token(palette.markdownLinkText), code: token(palette.markdownCode), blockQuote: token(palette.markdownBlockQuote), emphasis: token(palette.markdownEmph), strong: token(palette.markdownStrong), horizontalRule: token(palette.markdownHorizontalRule), listItem: token(palette.markdownListItem), listEnumeration: token(palette.markdownListEnumeration), image: token(palette.markdownImage), imageText: token(palette.markdownImageText), codeBlock: token(palette.markdownCodeBlock) },
69
+ };
70
+ }
71
+
72
+ /** Convert a legacy flat semantic palette into OpenCode's native theme JSON. */
73
+ export function convertLegacyTheme(name, palette, lightPalette) {
74
+ const dark = buildPalette(palette);
75
+ const light = lightPalette ? buildPalette(lightPalette) : undefined;
76
+ const { hue: darkHue, ...base } = dark;
77
+ const lightHue = light?.hue ?? (name === "paper" || name.includes("light") ? darkHue : undefined);
78
+ return {
79
+ $schema: "https://opencode.ai/theme.json",
80
+ base: { ...base, categorical: ["accent", "purple", "cyan", "green", "blue"] },
81
+ dark: { hue: darkHue },
82
+ ...(lightHue ? { light: { hue: lightHue } } : {}),
83
+ };
84
+ }
85
+
86
+ /** Copy selected Carthagent compatibility themes into one OpenCode profile. */
87
+ export async function provisionNativeThemes(profileConfigDir, names = Object.keys(THEMES)) {
88
+ const destination = join(profileConfigDir, "themes");
89
+ await mkdir(destination, { recursive: true });
90
+ const installed = [];
91
+ for (const name of names) {
92
+ const source = THEMES[name];
93
+ if (!source) throw new Error(`Unknown Carthagent theme: ${name}`);
94
+ const path = join(destination, `${name}.json`);
95
+ let exists = false;
96
+ try {
97
+ const existing = JSON.parse(await readFile(path, "utf8"));
98
+ exists = true;
99
+ if (existing.$schema === "https://opencode.ai/theme.json" && existing.dark) continue;
100
+ } catch (error) {
101
+ if (error.code !== "ENOENT") throw error;
102
+ }
103
+ if (exists) throw new Error(`Refusing to replace a non-native theme at ${path}`);
104
+ const document = name === "carthage"
105
+ ? JSON.parse(await readFile(join(PACKAGE_ROOT, "themes", "carthage.json"), "utf8"))
106
+ : convertLegacyTheme(name, flattenTheme(source, "dark"), ["solarized", "okabe", "contrast", "system"].includes(name) ? flattenTheme(source, "light") : undefined);
107
+ const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
108
+ try {
109
+ await writeFile(temp, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
110
+ await copyFile(temp, path, 1);
111
+ await rm(temp, { force: true });
112
+ } catch (error) {
113
+ await import("node:fs/promises").then(({ rm }) => rm(temp, { force: true })).catch(() => undefined);
114
+ throw error;
115
+ }
116
+ installed.push(name);
117
+ }
118
+ return { directory: destination, installed };
119
+ }
120
+
121
+ /** Launcher hook: install Carthagent's default theme before OpenCode scans themes. */
122
+ export async function provisionDefaultNativeTheme(profileConfigDir) {
123
+ const compatibilityNames = Object.keys(THEMES).filter((name) => !["opencode", "tokyonight"].includes(name));
124
+ const installed = [];
125
+ const conflicts = [];
126
+ for (const name of compatibilityNames) {
127
+ try { installed.push(...(await provisionNativeThemes(profileConfigDir, [name])).installed); }
128
+ catch (error) {
129
+ if (!error.message.startsWith("Refusing to replace a non-native theme at ")) throw error;
130
+ conflicts.push(name);
131
+ }
132
+ }
133
+ return { directory: join(profileConfigDir, "themes"), installed, conflicts };
134
+ }
@@ -0,0 +1,56 @@
1
+ import { voyagesErrorCode } from './voyages-state.mjs';
2
+
3
+ /** Read-only summaries only. Never retain recovery payloads, catalog or consent. */
4
+ export function createVoyagesNavigationState({ profile, daily, changed = (_state) => {}, timeoutSignal = () => AbortSignal.timeout(15_000) }) {
5
+ let generation = 0, active = false, disposed = false;
6
+ let request = new AbortController();
7
+ let state = { busy: false, error: '', profile: undefined, daily: undefined };
8
+ const emit = () => { if (!disposed) changed({ ...state }); };
9
+ const invalidate = () => {
10
+ generation++;
11
+ request.abort();
12
+ request = new AbortController();
13
+ state = { busy: false, error: '', profile: undefined, daily: undefined };
14
+ emit();
15
+ };
16
+ const refresh = async () => {
17
+ if (disposed || !active || state.busy) return;
18
+ const current = generation;
19
+ const signal = AbortSignal.any([request.signal, timeoutSignal()]);
20
+ const live = () => !disposed && active && generation === current && !signal.aborted;
21
+ state = { busy: true, error: '', profile: undefined, daily: undefined };
22
+ emit();
23
+ try {
24
+ const [account, assignments] = await Promise.all([profile(signal), daily(signal)]);
25
+ if (!live()) return;
26
+ state.profile = {
27
+ name: account.profile.name, shipName: account.profile.shipName,
28
+ coins: account.profile.coins, level: account.profile.level, totalXp: account.profile.totalXp,
29
+ };
30
+ state.daily = {
31
+ day: assignments.slate.day, endsAt: assignments.slate.endsAt,
32
+ items: assignments.slate.items.map((quest) => ({ id: quest.id, title: quest.definition.title, status: quest.status })),
33
+ };
34
+ } catch (error) {
35
+ if (!disposed && active && generation === current) {
36
+ const code = error?.type === 'dailyQuestError' ? error.data?.code : voyagesErrorCode(error);
37
+ state.error = typeof code === 'string' && /^[a-z_]{1,64}$/.test(code) ? code : 'voyages_request_failed';
38
+ }
39
+ } finally {
40
+ if (!disposed && active && generation === current) { state.busy = false; emit(); }
41
+ }
42
+ };
43
+ return {
44
+ refresh,
45
+ invalidate,
46
+ activate(next) {
47
+ if (disposed || active === next) return;
48
+ active = next;
49
+ invalidate();
50
+ if (active) return refresh();
51
+ },
52
+ contextChanged() { invalidate(); if (active) return refresh(); },
53
+ disconnect() { invalidate(); },
54
+ dispose() { active = false; invalidate(); disposed = true; request.abort(); },
55
+ };
56
+ }
@@ -0,0 +1,122 @@
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 { dailyQuestsClient, voyagesClient } from "./client.js";
7
+ import { nativeNavigation, type NavigationInput } from "./native-navigation.js";
8
+ import { createVoyagesNavigationState } from "./voyages-navigation-state.mjs";
9
+ import { voyagesEventAction } from "./voyages-state.mjs";
10
+
11
+ type Summary = {
12
+ busy: boolean; error: string;
13
+ profile?: { name: string; shipName: string; coins: number; level: number; totalXp: number };
14
+ daily?: { day: string; endsAt: string; items: { id: string; title: string; status: string }[] };
15
+ };
16
+
17
+ export function VoyagesNavigation(props: NavigationInput) {
18
+ const context = usePlugin();
19
+ const renderer = useRenderer();
20
+ const location = () => (props.sessionID ? context.data.session.get(props.sessionID)?.location : undefined) ?? context.location ?? context.data.location.default();
21
+ const [view, setView] = createSignal<Summary>({ busy: false, error: "" });
22
+ const [selection, setSelection] = createSignal("daily");
23
+ const profile = voyagesClient(context, location), daily = dailyQuestsClient(context, location);
24
+ const controller = createVoyagesNavigationState({ profile: profile.snapshot, daily: daily.snapshot, changed: setView });
25
+ let actions: SelectRenderable | undefined;
26
+ let scroll: ScrollBoxRenderable | undefined;
27
+ let scrollTop = 0;
28
+ let restoreTop: number | undefined;
29
+ const [viewportHeight, setViewportHeight] = createSignal(3);
30
+ createEffect(() => {
31
+ if (!props.active) { scrollTop = scroll?.scrollTop ?? scrollTop; return; }
32
+ if (!view().busy) { restoreTop = scrollTop; renderer.requestRender(); }
33
+ });
34
+ createEffect(() => { void controller.activate(props.active); });
35
+ let binding = JSON.stringify(location());
36
+ const resetSelection = () => { actions?.setSelectedIndex(0); setSelection("daily"); };
37
+ createEffect(() => {
38
+ const next = JSON.stringify(location());
39
+ if (binding === next) return;
40
+ binding = next;
41
+ resetSelection();
42
+ void controller.contextChanged();
43
+ });
44
+ onMount(() => {
45
+ // FRAME follows layout. Earlier restoration clamps to the loading extent.
46
+ const frame = () => {
47
+ if (!scroll || scroll.isDestroyed || !props.active) return;
48
+ setViewportHeight(Math.max(1, scroll.viewport.height));
49
+ if (restoreTop === undefined || view().busy) return;
50
+ scroll.scrollTop = restoreTop;
51
+ restoreTop = undefined;
52
+ };
53
+ renderer.on(CliRenderEvents.FRAME, frame);
54
+ onCleanup(() => renderer.off(CliRenderEvents.FRAME, frame));
55
+ const stop = context.data.listen(({ details }) => {
56
+ const changedLocation = binding !== JSON.stringify(location());
57
+ binding = JSON.stringify(location());
58
+ const action = changedLocation ? String(details.type) === "server.disconnected" ? "clear" : "refresh" : voyagesEventAction(details, location());
59
+ if (action === "ignore") return;
60
+ resetSelection();
61
+ if (action === "clear") controller.disconnect();
62
+ else void controller.contextChanged();
63
+ });
64
+ onCleanup(stop);
65
+ });
66
+ onCleanup(() => controller.dispose());
67
+ const open = (name: "voyages" | "daily-quests", extra: Record<string, string> = {}) => {
68
+ props.blur();
69
+ if (nativeNavigation(context)?.current() !== "voyages") return;
70
+ context.ui.router.navigate({ type: "plugin", name, data: { ...(props.sessionID ? { sessionID: props.sessionID } : {}), ...extra } });
71
+ };
72
+ const options = () => [
73
+ { name: props.compact ? "Q" : "Daily coding quests", description: "Assignments & assessments", value: "daily" },
74
+ ...(view().daily?.items.map((quest, index) => ({ name: props.compact ? String(index + 1) : quest.title, description: quest.status, value: `quest:${quest.id}` })) ?? []),
75
+ { name: props.compact ? "P" : "Profile & wallet", description: "Authoritative profile", value: "profile" },
76
+ { name: props.compact ? "E" : "Equipment & shop", description: "Preview before any purchase", value: "equipment" },
77
+ { name: props.compact ? "R" : "Refresh summaries", description: "Read-only; no evidence upload", value: "refresh" },
78
+ { name: props.compact ? "S" : "Sign in / reconnect", description: "Native Ship device sign-in", value: "signin" },
79
+ { name: props.compact ? "B" : "Return to coding", description: "Keep the selected session", value: "back" },
80
+ ];
81
+ const select = (value?: string) => {
82
+ if (!props.active || context.keymap.mode.current() !== "navigation" || !props.focused || !value) return;
83
+ if (value === "refresh") { void controller.refresh(); return; }
84
+ if (value === "signin") { controller.invalidate(); props.blur(); context.keymap.dispatch("provider.connect"); return; }
85
+ if (value === "back") {
86
+ props.blur();
87
+ nativeNavigation(context)?.select("projects");
88
+ context.ui.router.navigate(props.sessionID ? { type: "session", sessionID: props.sessionID } : { type: "home" });
89
+ return;
90
+ }
91
+ if (value === "profile" || value === "equipment") { open("voyages", { section: value }); return; }
92
+ open("daily-quests", value.startsWith("quest:") ? { questID: value.slice(6) } : {});
93
+ };
94
+ const focused = () => props.active && props.focused && context.keymap.mode.current() === "navigation";
95
+ return <box flexDirection="column" flexGrow={1} minHeight={0} onMouseDown={() => { if (props.active && context.keymap.mode.current() === "base") props.focus(); }}>
96
+ <scrollbox ref={scroll} flexGrow={1} minHeight={0} contentOptions={{ flexDirection: "column" }}>
97
+ <Show when={!props.compact}>
98
+ <Show when={view().profile}>{(p) => <box flexDirection="column" flexShrink={0} paddingX={1}>
99
+ <text fg={context.theme.text.base}>{p().name}</text>
100
+ <text fg={context.theme.text.muted}>{p().shipName}</text>
101
+ <text fg={context.theme.text.action.primary.base}>Lv {p().level} · {p().coins} coins</text>
102
+ <text fg={context.theme.text.muted}>XP {p().totalXp}</text>
103
+ </box>}</Show>
104
+ <Show when={view().busy}><text flexShrink={0} fg={context.theme.text.muted}> Loading summaries…</text></Show>
105
+ <Show when={view().error}><text flexShrink={0} fg={context.theme.text.feedback.warning.base}> {view().error}. Sign in or refresh.</text></Show>
106
+ <Show when={view().daily}><text flexShrink={0} fg={context.theme.text.muted}> UTC {view().daily?.day}</text></Show>
107
+ </Show>
108
+ <select ref={actions} 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) => {
109
+ if (!focused() || view().busy || !item) return;
110
+ setSelection(item.value);
111
+ if (actions) scroll?.scrollChildIntoView(actions.id);
112
+ }} onSelect={(_index, item) => select(item?.value)} />
113
+ <Show when={!props.compact}>
114
+ <text flexShrink={0} fg={context.theme.text.faint}> Badges & friends: no native views yet.</text>
115
+ <text flexShrink={0} fg={context.theme.text.faint}> Quests: no rewards. No automatic uploads.</text>
116
+ </Show>
117
+ </scrollbox>
118
+ <Show when={!props.compact}>
119
+ <text flexShrink={0} fg={context.theme.text.faint}>{focused() ? " ↑/↓ Enter · Esc coding" : " Focus pane to navigate"}</text>
120
+ </Show>
121
+ </box>;
122
+ }