@agentprojectcontext/apx 1.15.5 → 1.16.0

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 (222) hide show
  1. package/package.json +40 -5
  2. package/src/cli/commands/log.js +113 -0
  3. package/src/cli/commands/overlay.js +253 -0
  4. package/src/cli/commands/sys.js +88 -16
  5. package/src/cli/index.js +23 -1
  6. package/src/cli/terminal-chat/renderer.js +71 -56
  7. package/src/cli-ts/commands/agent.ts +173 -0
  8. package/src/cli-ts/commands/chat.ts +119 -0
  9. package/src/cli-ts/commands/daemon.ts +112 -0
  10. package/src/cli-ts/commands/exec.ts +109 -0
  11. package/src/cli-ts/commands/mcp.ts +235 -0
  12. package/src/cli-ts/commands/session.ts +224 -0
  13. package/src/cli-ts/commands/status.ts +61 -0
  14. package/src/cli-ts/http.ts +36 -0
  15. package/src/cli-ts/index.ts +73 -0
  16. package/src/cli-ts/ui.ts +107 -0
  17. package/src/core/logging.js +81 -0
  18. package/src/daemon/api.js +58 -0
  19. package/src/daemon/engines/anthropic.js +60 -1
  20. package/src/daemon/engines/index.js +2 -1
  21. package/src/daemon/engines/ollama.js +70 -3
  22. package/src/daemon/index.js +58 -0
  23. package/src/daemon/overlay-ws.js +40 -0
  24. package/src/daemon/plugins/index.js +2 -1
  25. package/src/daemon/plugins/overlay.js +177 -0
  26. package/src/daemon/plugins/telegram.js +15 -3
  27. package/src/daemon/super-agent.js +102 -19
  28. package/src/daemon/transcription.js +262 -59
  29. package/src/daemon/wakeup.js +14 -19
  30. package/src/daemon/whisper-server.py +57 -6
  31. package/src/overlay/index.html +44 -0
  32. package/src/overlay/main.js +480 -0
  33. package/src/overlay/package.json +3 -0
  34. package/src/overlay/preload.js +34 -0
  35. package/src/overlay/renderer.js +371 -0
  36. package/src/overlay/style.css +250 -0
  37. package/src/tui/_shims/cli-error.ts +6 -0
  38. package/src/tui/_shims/cli-logo.ts +18 -0
  39. package/src/tui/_shims/cli-ui.ts +1 -0
  40. package/src/tui/_shims/config-console-state.ts +7 -0
  41. package/src/tui/_shims/core-any.ts +30 -0
  42. package/src/tui/_shims/core-binary.ts +13 -0
  43. package/src/tui/_shims/core-flag.ts +3 -0
  44. package/src/tui/_shims/core-log.ts +14 -0
  45. package/src/tui/_shims/lsp-language.ts +1 -0
  46. package/src/tui/_shims/opencode-any.ts +135 -0
  47. package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
  48. package/src/tui/_shims/plugin-tui.ts +13 -0
  49. package/src/tui/_shims/provider-provider.ts +10 -0
  50. package/src/tui/_shims/session-retry.ts +1 -0
  51. package/src/tui/_shims/session-schema.ts +15 -0
  52. package/src/tui/_shims/session-session.ts +3 -0
  53. package/src/tui/_shims/snapshot.ts +4 -0
  54. package/src/tui/_shims/tool-any.ts +18 -0
  55. package/src/tui/_shims/util-error.ts +7 -0
  56. package/src/tui/_shims/util-filesystem.ts +79 -0
  57. package/src/tui/_shims/util-format.ts +7 -0
  58. package/src/tui/_shims/util-iife.ts +3 -0
  59. package/src/tui/_shims/util-locale.ts +10 -0
  60. package/src/tui/_shims/util-process.ts +38 -0
  61. package/src/tui/app.tsx +783 -0
  62. package/src/tui/asset/charge.wav +0 -0
  63. package/src/tui/asset/pulse-a.wav +0 -0
  64. package/src/tui/asset/pulse-b.wav +0 -0
  65. package/src/tui/asset/pulse-c.wav +0 -0
  66. package/src/tui/attach.ts +100 -0
  67. package/src/tui/component/bg-pulse-render.ts +436 -0
  68. package/src/tui/component/bg-pulse.tsx +99 -0
  69. package/src/tui/component/border.tsx +21 -0
  70. package/src/tui/component/dialog-agent.tsx +31 -0
  71. package/src/tui/component/dialog-console-org.tsx +103 -0
  72. package/src/tui/component/dialog-mcp.tsx +85 -0
  73. package/src/tui/component/dialog-model.tsx +175 -0
  74. package/src/tui/component/dialog-provider.tsx +456 -0
  75. package/src/tui/component/dialog-retry-action.tsx +160 -0
  76. package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
  77. package/src/tui/component/dialog-session-list.tsx +323 -0
  78. package/src/tui/component/dialog-session-rename.tsx +31 -0
  79. package/src/tui/component/dialog-skill.tsx +36 -0
  80. package/src/tui/component/dialog-stash.tsx +87 -0
  81. package/src/tui/component/dialog-status.tsx +168 -0
  82. package/src/tui/component/dialog-tag.tsx +44 -0
  83. package/src/tui/component/dialog-theme-list.tsx +50 -0
  84. package/src/tui/component/dialog-variant.tsx +39 -0
  85. package/src/tui/component/dialog-workspace-create.tsx +302 -0
  86. package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
  87. package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
  88. package/src/tui/component/error-component.tsx +92 -0
  89. package/src/tui/component/logo.tsx +896 -0
  90. package/src/tui/component/plugin-route-missing.tsx +14 -0
  91. package/src/tui/component/prompt/autocomplete.tsx +869 -0
  92. package/src/tui/component/prompt/cwd.ts +0 -0
  93. package/src/tui/component/prompt/frecency.tsx +90 -0
  94. package/src/tui/component/prompt/history.tsx +108 -0
  95. package/src/tui/component/prompt/index.tsx +1809 -0
  96. package/src/tui/component/prompt/part.ts +16 -0
  97. package/src/tui/component/prompt/stash.tsx +101 -0
  98. package/src/tui/component/prompt/traits.ts +35 -0
  99. package/src/tui/component/spinner.tsx +24 -0
  100. package/src/tui/component/startup-loading.tsx +63 -0
  101. package/src/tui/component/todo-item.tsx +32 -0
  102. package/src/tui/component/use-connected.tsx +9 -0
  103. package/src/tui/component/workspace-label.tsx +19 -0
  104. package/src/tui/config/cwd.ts +5 -0
  105. package/src/tui/config/keybind.ts +432 -0
  106. package/src/tui/config/tui-migrate.ts +154 -0
  107. package/src/tui/config/tui-schema.ts +34 -0
  108. package/src/tui/config/tui.ts +46 -0
  109. package/src/tui/context/aggregate-failures.ts +34 -0
  110. package/src/tui/context/args.tsx +15 -0
  111. package/src/tui/context/command-palette.tsx +163 -0
  112. package/src/tui/context/directory.ts +15 -0
  113. package/src/tui/context/editor-zed.ts +283 -0
  114. package/src/tui/context/editor.ts +468 -0
  115. package/src/tui/context/event-apx.ts +22 -0
  116. package/src/tui/context/event.ts +6 -0
  117. package/src/tui/context/exit.tsx +60 -0
  118. package/src/tui/context/helper.tsx +25 -0
  119. package/src/tui/context/kv.tsx +81 -0
  120. package/src/tui/context/local.tsx +608 -0
  121. package/src/tui/context/path-format.tsx +39 -0
  122. package/src/tui/context/project-apx.tsx +48 -0
  123. package/src/tui/context/project.tsx +7 -0
  124. package/src/tui/context/prompt.tsx +18 -0
  125. package/src/tui/context/route.tsx +52 -0
  126. package/src/tui/context/sdk-apx.tsx +185 -0
  127. package/src/tui/context/sdk.tsx +6 -0
  128. package/src/tui/context/sync-apx.tsx +178 -0
  129. package/src/tui/context/sync-v2.tsx +16 -0
  130. package/src/tui/context/sync.tsx +118 -0
  131. package/src/tui/context/theme/aura.json +69 -0
  132. package/src/tui/context/theme/ayu.json +80 -0
  133. package/src/tui/context/theme/carbonfox.json +248 -0
  134. package/src/tui/context/theme/catppuccin-frappe.json +230 -0
  135. package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
  136. package/src/tui/context/theme/catppuccin.json +112 -0
  137. package/src/tui/context/theme/cobalt2.json +225 -0
  138. package/src/tui/context/theme/cursor.json +249 -0
  139. package/src/tui/context/theme/dracula.json +219 -0
  140. package/src/tui/context/theme/everforest.json +241 -0
  141. package/src/tui/context/theme/flexoki.json +237 -0
  142. package/src/tui/context/theme/github.json +233 -0
  143. package/src/tui/context/theme/gruvbox.json +242 -0
  144. package/src/tui/context/theme/kanagawa.json +77 -0
  145. package/src/tui/context/theme/lucent-orng.json +234 -0
  146. package/src/tui/context/theme/material.json +235 -0
  147. package/src/tui/context/theme/matrix.json +77 -0
  148. package/src/tui/context/theme/mercury.json +252 -0
  149. package/src/tui/context/theme/monokai.json +221 -0
  150. package/src/tui/context/theme/nightowl.json +221 -0
  151. package/src/tui/context/theme/nord.json +223 -0
  152. package/src/tui/context/theme/one-dark.json +84 -0
  153. package/src/tui/context/theme/opencode.json +245 -0
  154. package/src/tui/context/theme/orng.json +249 -0
  155. package/src/tui/context/theme/osaka-jade.json +93 -0
  156. package/src/tui/context/theme/palenight.json +222 -0
  157. package/src/tui/context/theme/rosepine.json +234 -0
  158. package/src/tui/context/theme/solarized.json +223 -0
  159. package/src/tui/context/theme/synthwave84.json +226 -0
  160. package/src/tui/context/theme/tokyonight.json +243 -0
  161. package/src/tui/context/theme/vercel.json +245 -0
  162. package/src/tui/context/theme/vesper.json +218 -0
  163. package/src/tui/context/theme/zenburn.json +223 -0
  164. package/src/tui/context/theme.tsx +1247 -0
  165. package/src/tui/context/tui-config.tsx +9 -0
  166. package/src/tui/event.ts +16 -0
  167. package/src/tui/feature-plugins/home/footer.tsx +94 -0
  168. package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
  169. package/src/tui/feature-plugins/home/tips.tsx +59 -0
  170. package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
  171. package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
  172. package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
  173. package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
  174. package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
  175. package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
  176. package/src/tui/feature-plugins/system/plugins.tsx +269 -0
  177. package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
  178. package/src/tui/feature-plugins/system/which-key.tsx +608 -0
  179. package/src/tui/keymap.tsx +166 -0
  180. package/src/tui/layer.ts +6 -0
  181. package/src/tui/plugin/api.tsx +381 -0
  182. package/src/tui/plugin/command-shim.ts +109 -0
  183. package/src/tui/plugin/internal.ts +33 -0
  184. package/src/tui/plugin/runtime.ts +1069 -0
  185. package/src/tui/plugin/slots.tsx +60 -0
  186. package/src/tui/routes/home.tsx +96 -0
  187. package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
  188. package/src/tui/routes/session/dialog-message.tsx +108 -0
  189. package/src/tui/routes/session/dialog-subagent.tsx +26 -0
  190. package/src/tui/routes/session/dialog-timeline.tsx +47 -0
  191. package/src/tui/routes/session/footer.tsx +91 -0
  192. package/src/tui/routes/session/index.tsx +188 -0
  193. package/src/tui/routes/session/permission.tsx +722 -0
  194. package/src/tui/routes/session/question.tsx +490 -0
  195. package/src/tui/routes/session/sidebar.tsx +102 -0
  196. package/src/tui/routes/session/subagent-footer.tsx +133 -0
  197. package/src/tui/run.ts +84 -0
  198. package/src/tui/thread.ts +261 -0
  199. package/src/tui/tsconfig.json +40 -0
  200. package/src/tui/ui/dialog-alert.tsx +66 -0
  201. package/src/tui/ui/dialog-confirm.tsx +108 -0
  202. package/src/tui/ui/dialog-export-options.tsx +217 -0
  203. package/src/tui/ui/dialog-help.tsx +40 -0
  204. package/src/tui/ui/dialog-prompt.tsx +101 -0
  205. package/src/tui/ui/dialog-select.tsx +553 -0
  206. package/src/tui/ui/dialog.tsx +211 -0
  207. package/src/tui/ui/link.tsx +34 -0
  208. package/src/tui/ui/spinner.ts +368 -0
  209. package/src/tui/ui/toast.tsx +111 -0
  210. package/src/tui/util/clipboard.ts +217 -0
  211. package/src/tui/util/editor.ts +37 -0
  212. package/src/tui/util/model.ts +23 -0
  213. package/src/tui/util/provider-origin.ts +7 -0
  214. package/src/tui/util/revert-diff.ts +18 -0
  215. package/src/tui/util/scroll.ts +25 -0
  216. package/src/tui/util/selection.ts +65 -0
  217. package/src/tui/util/signal.ts +41 -0
  218. package/src/tui/util/sound.ts +156 -0
  219. package/src/tui/util/transcript.ts +112 -0
  220. package/src/tui/validate-session.ts +29 -0
  221. package/src/tui/win32.ts +130 -0
  222. package/src/tui/worker.ts +104 -0
@@ -0,0 +1,133 @@
1
+ import { createMemo, createSignal, Show } from "solid-js"
2
+ import { useRouteData } from "@tui/context/route"
3
+ import { useSync } from "@tui/context/sync"
4
+ import { useTheme } from "@tui/context/theme"
5
+ import { SplitBorder } from "@tui/component/border"
6
+ import type { AssistantMessage } from "@opencode-ai/sdk/v2"
7
+ import { Locale } from "@/util/locale"
8
+ import { useTerminalDimensions } from "@opentui/solid"
9
+ import { useCommandPalette } from "../../context/command-palette"
10
+ import { useCommandShortcut } from "../../keymap"
11
+
12
+ export function SubagentFooter() {
13
+ const route = useRouteData("session")
14
+ const sync = useSync()
15
+ const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
16
+ const session = createMemo(() => sync.session.get(route.sessionID))
17
+
18
+ const subagentInfo = createMemo(() => {
19
+ const s = session()
20
+ if (!s) return { label: "Subagent", index: 0, total: 0 }
21
+ const agentMatch = s.title.match(/@(\w+) subagent/)
22
+ const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
23
+
24
+ if (!s.parentID) return { label, index: 0, total: 0 }
25
+
26
+ const siblings = sync.data.session
27
+ .filter((x) => x.parentID === s.parentID)
28
+ .toSorted((a, b) => a.time.created - b.time.created)
29
+ const index = siblings.findIndex((x) => x.id === s.id)
30
+
31
+ return { label, index: index + 1, total: siblings.length }
32
+ })
33
+
34
+ const usage = createMemo(() => {
35
+ const msg = messages()
36
+ const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
37
+ if (!last) return
38
+
39
+ const tokens =
40
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
41
+ if (tokens <= 0) return
42
+
43
+ const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
44
+ const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
45
+ const cost = session()?.cost ?? 0
46
+
47
+ const money = new Intl.NumberFormat("en-US", {
48
+ style: "currency",
49
+ currency: "USD",
50
+ })
51
+
52
+ return {
53
+ context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
54
+ cost: cost > 0 ? money.format(cost) : undefined,
55
+ }
56
+ })
57
+
58
+ const { theme } = useTheme()
59
+ const command = useCommandPalette()
60
+ const parentShortcut = useCommandShortcut("session.parent")
61
+ const previousShortcut = useCommandShortcut("session.child.previous")
62
+ const nextShortcut = useCommandShortcut("session.child.next")
63
+ const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
64
+ useTerminalDimensions()
65
+
66
+ return (
67
+ <box flexShrink={0}>
68
+ <box
69
+ paddingTop={1}
70
+ paddingBottom={1}
71
+ paddingLeft={2}
72
+ paddingRight={1}
73
+ {...SplitBorder}
74
+ border={["left"]}
75
+ borderColor={theme.border}
76
+ flexShrink={0}
77
+ backgroundColor={theme.backgroundPanel}
78
+ >
79
+ <box flexDirection="row" justifyContent="space-between" gap={1}>
80
+ <box flexDirection="row" gap={1}>
81
+ <text fg={theme.text}>
82
+ <b>{subagentInfo().label}</b>
83
+ </text>
84
+ <Show when={subagentInfo().total > 0}>
85
+ <text style={{ fg: theme.textMuted }}>
86
+ ({subagentInfo().index} of {subagentInfo().total})
87
+ </text>
88
+ </Show>
89
+ <Show when={usage()}>
90
+ {(item) => (
91
+ <text fg={theme.textMuted} wrapMode="none">
92
+ {[item().context, item().cost].filter(Boolean).join(" · ")}
93
+ </text>
94
+ )}
95
+ </Show>
96
+ </box>
97
+ <box flexDirection="row" gap={2}>
98
+ <box
99
+ onMouseOver={() => setHover("parent")}
100
+ onMouseOut={() => setHover(null)}
101
+ onMouseUp={() => command.run("session.parent")}
102
+ backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
103
+ >
104
+ <text fg={theme.text}>
105
+ Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
106
+ </text>
107
+ </box>
108
+ <box
109
+ onMouseOver={() => setHover("prev")}
110
+ onMouseOut={() => setHover(null)}
111
+ onMouseUp={() => command.run("session.child.previous")}
112
+ backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
113
+ >
114
+ <text fg={theme.text}>
115
+ Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
116
+ </text>
117
+ </box>
118
+ <box
119
+ onMouseOver={() => setHover("next")}
120
+ onMouseOut={() => setHover(null)}
121
+ onMouseUp={() => command.run("session.child.next")}
122
+ backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
123
+ >
124
+ <text fg={theme.text}>
125
+ Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
126
+ </text>
127
+ </box>
128
+ </box>
129
+ </box>
130
+ </box>
131
+ </box>
132
+ )
133
+ }
package/src/tui/run.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * APX TUI entry point.
3
+ * Usage: bun --preload @opentui/solid/preload src/tui/run.ts --pid <projectId> [--agent <name>] [--model <model>]
4
+ */
5
+ import { tui } from "./app.tsx"
6
+ import type { TuiConfig } from "./config/tui.ts"
7
+
8
+ // ─── CLI argument parsing ────────────────────────────────────────────────────
9
+ const args = process.argv.slice(2)
10
+ function getFlag(flag: string): string | undefined {
11
+ const i = args.indexOf(flag)
12
+ return i !== -1 ? args[i + 1] : undefined
13
+ }
14
+ function hasFlag(flag: string): boolean {
15
+ return args.includes(flag)
16
+ }
17
+
18
+ const pid = getFlag("--pid") ?? ""
19
+ const agent = getFlag("--agent")
20
+ const model = getFlag("--model")
21
+ const promptText = getFlag("--prompt")
22
+ const sessionID = getFlag("--session")
23
+ const continueSession = hasFlag("--continue") || hasFlag("-c")
24
+
25
+ if (!pid) {
26
+ process.stderr.write("APX TUI requires --pid <projectId>\n")
27
+ process.exit(1)
28
+ }
29
+
30
+ const baseUrl = process.env.APX_URL ?? "http://127.0.0.1:7430"
31
+
32
+ // ─── Minimal keybind lookup (no-op bindings, defaults are loaded by opentui) ─
33
+ function createNoopKeybinds(): TuiConfig.Resolved["keybinds"] {
34
+ return {
35
+ get(_command: string) {
36
+ return []
37
+ },
38
+ gather(_name: string, _commands: readonly string[]) {
39
+ return []
40
+ },
41
+ }
42
+ }
43
+
44
+ // ─── Minimal resolved TUI config ─────────────────────────────────────────────
45
+ let config: TuiConfig.Resolved
46
+
47
+ try {
48
+ // Attempt to load the real config (may fail if opencode-core deps missing)
49
+ const { TuiConfig: TuiConfigModule } = await import("./config/tui.ts")
50
+ config = await TuiConfigModule.get()
51
+ } catch {
52
+ // Fall back to safe minimal config
53
+ config = {
54
+ theme: undefined,
55
+ keybinds: createNoopKeybinds(),
56
+ plugin: [],
57
+ plugin_enabled: {},
58
+ leader_timeout: 2000,
59
+ scroll_speed: undefined,
60
+ scroll_acceleration: undefined,
61
+ diff_style: undefined,
62
+ mouse: true,
63
+ plugin_origins: [],
64
+ } as unknown as TuiConfig.Resolved
65
+ }
66
+
67
+ // ─── Launch TUI ──────────────────────────────────────────────────────────────
68
+ await tui({
69
+ url: baseUrl,
70
+ pid,
71
+ agent,
72
+ model,
73
+ args: {
74
+ prompt: promptText,
75
+ continue: continueSession,
76
+ sessionID,
77
+ fork: false,
78
+ agent,
79
+ model,
80
+ },
81
+ config,
82
+ })
83
+
84
+ process.exit(0)
@@ -0,0 +1,261 @@
1
+ import { cmd } from "@/cli/cmd/cmd"
2
+ import { Rpc } from "@/util/rpc"
3
+ import { type rpc } from "./worker"
4
+ import path from "path"
5
+ import { fileURLToPath } from "url"
6
+ import { UI } from "@/cli/ui"
7
+ import * as Log from "@opencode-ai/core/util/log"
8
+ import { errorMessage } from "@/util/error"
9
+ import { withTimeout } from "@/util/timeout"
10
+ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
11
+ import { Filesystem } from "@/util/filesystem"
12
+ import type { GlobalEvent } from "@opencode-ai/sdk/v2"
13
+ import type { EventSource } from "./context/sdk"
14
+ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
15
+ import { writeHeapSnapshot } from "v8"
16
+ import { TuiConfig } from "./config/tui"
17
+ import {
18
+ OPENCODE_PROCESS_ROLE,
19
+ OPENCODE_RUN_ID,
20
+ ensureRunID,
21
+ sanitizedProcessEnv,
22
+ } from "@opencode-ai/core/util/opencode-process"
23
+ import { validateSession } from "./validate-session"
24
+
25
+ declare global {
26
+ const OPENCODE_WORKER_PATH: string
27
+ }
28
+
29
+ type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
30
+
31
+ function createWorkerFetch(client: RpcClient): typeof fetch {
32
+ const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
33
+ const request = new Request(input, init)
34
+ const body = request.body ? await request.text() : undefined
35
+ const result = await client.call("fetch", {
36
+ url: request.url,
37
+ method: request.method,
38
+ headers: Object.fromEntries(request.headers.entries()),
39
+ body,
40
+ })
41
+ return new Response(result.body, {
42
+ status: result.status,
43
+ headers: result.headers,
44
+ })
45
+ }
46
+ return fn as typeof fetch
47
+ }
48
+
49
+ function createEventSource(client: RpcClient): EventSource {
50
+ return {
51
+ subscribe: async (handler) => {
52
+ return client.on<GlobalEvent>("global.event", (e) => {
53
+ handler(e)
54
+ })
55
+ },
56
+ }
57
+ }
58
+
59
+ async function target() {
60
+ if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
61
+ const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
62
+ if (await Filesystem.exists(fileURLToPath(dist))) return dist
63
+ return new URL("./worker.ts", import.meta.url)
64
+ }
65
+
66
+ async function input(value?: string) {
67
+ const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
68
+ if (!value) return piped
69
+ if (!piped) return value
70
+ return piped + "\n" + value
71
+ }
72
+
73
+ export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
74
+ const root = Filesystem.resolve(envPWD ?? cwd)
75
+ if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
76
+ return Filesystem.resolve(cwd)
77
+ }
78
+
79
+ export const TuiThreadCommand = cmd({
80
+ command: "$0 [project]",
81
+ describe: "start opencode tui",
82
+ builder: (yargs) =>
83
+ withNetworkOptions(yargs)
84
+ .positional("project", {
85
+ type: "string",
86
+ describe: "path to start opencode in",
87
+ })
88
+ .option("model", {
89
+ type: "string",
90
+ alias: ["m"],
91
+ describe: "model to use in the format of provider/model",
92
+ })
93
+ .option("continue", {
94
+ alias: ["c"],
95
+ describe: "continue the last session",
96
+ type: "boolean",
97
+ })
98
+ .option("session", {
99
+ alias: ["s"],
100
+ type: "string",
101
+ describe: "session id to continue",
102
+ })
103
+ .option("fork", {
104
+ type: "boolean",
105
+ describe: "fork the session when continuing (use with --continue or --session)",
106
+ })
107
+ .option("prompt", {
108
+ type: "string",
109
+ describe: "prompt to use",
110
+ })
111
+ .option("agent", {
112
+ type: "string",
113
+ describe: "agent to use",
114
+ }),
115
+ handler: async (args) => {
116
+ // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
117
+ // (Important when running under `bun run` wrappers on Windows.)
118
+ const unguard = win32InstallCtrlCGuard()
119
+ try {
120
+ // Must be the very first thing — disables CTRL_C_EVENT before any Worker
121
+ // spawn or async work so the OS cannot kill the process group.
122
+ win32DisableProcessedInput()
123
+
124
+ if (args.fork && !args.continue && !args.session) {
125
+ UI.error("--fork requires --continue or --session")
126
+ process.exitCode = 1
127
+ return
128
+ }
129
+
130
+ // Resolve relative --project paths from PWD, then use the real cwd after
131
+ // chdir so the thread and worker share the same directory key.
132
+ const next = resolveThreadDirectory(args.project)
133
+ const file = await target()
134
+ try {
135
+ process.chdir(next)
136
+ } catch {
137
+ UI.error("Failed to change directory to " + next)
138
+ return
139
+ }
140
+ const cwd = Filesystem.resolve(process.cwd())
141
+ const env = sanitizedProcessEnv({
142
+ [OPENCODE_PROCESS_ROLE]: "worker",
143
+ [OPENCODE_RUN_ID]: ensureRunID(),
144
+ })
145
+
146
+ const worker = new Worker(file, {
147
+ env,
148
+ })
149
+ worker.onerror = (e) => {
150
+ Log.Default.error("thread error", {
151
+ message: e.message,
152
+ filename: e.filename,
153
+ lineno: e.lineno,
154
+ colno: e.colno,
155
+ error: e.error,
156
+ })
157
+ }
158
+
159
+ const client = Rpc.client<typeof rpc>(worker)
160
+ const error = (e: unknown) => {
161
+ Log.Default.error("process error", { error: errorMessage(e) })
162
+ }
163
+ const reload = () => {
164
+ client.call("reload", undefined).catch((err) => {
165
+ Log.Default.warn("worker reload failed", {
166
+ error: errorMessage(err),
167
+ })
168
+ })
169
+ }
170
+ process.on("uncaughtException", error)
171
+ process.on("unhandledRejection", error)
172
+ process.on("SIGUSR2", reload)
173
+
174
+ let stopped = false
175
+ const stop = async () => {
176
+ if (stopped) return
177
+ stopped = true
178
+ process.off("uncaughtException", error)
179
+ process.off("unhandledRejection", error)
180
+ process.off("SIGUSR2", reload)
181
+ await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
182
+ Log.Default.warn("worker shutdown failed", {
183
+ error: errorMessage(error),
184
+ })
185
+ })
186
+ worker.terminate()
187
+ }
188
+
189
+ const prompt = await input(args.prompt)
190
+ const config = await TuiConfig.get()
191
+
192
+ const network = resolveNetworkOptionsNoConfig(args)
193
+ const external =
194
+ process.argv.includes("--port") ||
195
+ process.argv.includes("--hostname") ||
196
+ process.argv.includes("--mdns") ||
197
+ network.mdns ||
198
+ network.port !== 0 ||
199
+ network.hostname !== "127.0.0.1"
200
+
201
+ const transport = external
202
+ ? {
203
+ url: (await client.call("server", network)).url,
204
+ fetch: undefined,
205
+ events: undefined,
206
+ }
207
+ : {
208
+ url: "http://opencode.internal",
209
+ fetch: createWorkerFetch(client),
210
+ events: createEventSource(client),
211
+ }
212
+
213
+ try {
214
+ await validateSession({
215
+ url: transport.url,
216
+ sessionID: args.session,
217
+ directory: cwd,
218
+ fetch: transport.fetch,
219
+ })
220
+ } catch (error) {
221
+ UI.error(errorMessage(error))
222
+ process.exitCode = 1
223
+ return
224
+ }
225
+
226
+ setTimeout(() => {
227
+ client.call("checkUpgrade", { directory: cwd }).catch(() => {})
228
+ }, 1000).unref?.()
229
+
230
+ try {
231
+ const { tui } = await import("./app")
232
+ await tui({
233
+ url: transport.url,
234
+ async onSnapshot() {
235
+ const tui = writeHeapSnapshot("tui.heapsnapshot")
236
+ const server = await client.call("snapshot", undefined)
237
+ return [tui, server]
238
+ },
239
+ config,
240
+ directory: cwd,
241
+ fetch: transport.fetch,
242
+ events: transport.events,
243
+ args: {
244
+ continue: args.continue,
245
+ sessionID: args.session,
246
+ agent: args.agent,
247
+ model: args.model,
248
+ prompt,
249
+ fork: args.fork,
250
+ },
251
+ })
252
+ } finally {
253
+ await stop()
254
+ }
255
+ } finally {
256
+ unguard?.()
257
+ }
258
+ process.exit(0)
259
+ },
260
+ })
261
+ // scratch
@@ -0,0 +1,40 @@
1
+ {
2
+ "extends": "../../../tsconfig.cli.json",
3
+ "compilerOptions": {
4
+ "jsx": "preserve",
5
+ "jsxImportSource": "@opentui/solid",
6
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
7
+ "noEmit": true,
8
+ "types": [],
9
+ "paths": {
10
+ "@tui/*": ["./*"],
11
+ "@/cli/cmd/tui/*": ["./*"],
12
+ "@opencode-ai/sdk/v2": ["./_shims/opencode-sdk-v2.ts"],
13
+ "@opencode-ai/core/flag/flag": ["./_shims/core-flag.ts"],
14
+ "@opencode-ai/core/util/binary": ["./_shims/core-binary.ts"],
15
+ "@opencode-ai/core/util/log": ["./_shims/core-log.ts"],
16
+ "@opencode-ai/core/*": ["./_shims/core-any.ts"],
17
+ "@opencode-ai/plugin/tui": ["./_shims/plugin-tui.ts"],
18
+ "@opencode-ai/plugin/*": ["./_shims/opencode-any.ts"],
19
+ "@/session/session": ["./_shims/session-session.ts"],
20
+ "@/session/retry": ["./_shims/session-retry.ts"],
21
+ "@/session/schema": ["./_shims/session-schema.ts"],
22
+ "@/provider/provider": ["./_shims/provider-provider.ts"],
23
+ "@/snapshot": ["./_shims/snapshot.ts"],
24
+ "@/config/console-state": ["./_shims/config-console-state.ts"],
25
+ "@/cli/error": ["./_shims/cli-error.ts"],
26
+ "@/cli/logo": ["./_shims/cli-logo.ts"],
27
+ "@/cli/ui.ts": ["./_shims/cli-ui.ts"],
28
+ "@/cli/ui": ["./_shims/cli-ui.ts"],
29
+ "@/util/locale": ["./_shims/util-locale.ts"],
30
+ "@/util/error": ["./_shims/util-error.ts"],
31
+ "@/util/filesystem": ["./_shims/util-filesystem.ts"],
32
+ "@/util/iife": ["./_shims/util-iife.ts"],
33
+ "@/util/format": ["./_shims/util-format.ts"],
34
+ "@/util/process": ["./_shims/util-process.ts"],
35
+ "@/lsp/language": ["./_shims/lsp-language.ts"],
36
+ "@/tool/*": ["./_shims/tool-any.ts"],
37
+ "@/*": ["./_shims/opencode-any.ts"]
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useTheme } from "../context/theme"
3
+ import { useDialog, type DialogContext } from "./dialog"
4
+ import { useBindings } from "../keymap"
5
+
6
+ export type DialogAlertProps = {
7
+ title: string
8
+ message: string
9
+ onConfirm?: () => void
10
+ }
11
+
12
+ export function DialogAlert(props: DialogAlertProps) {
13
+ const dialog = useDialog()
14
+ const { theme } = useTheme()
15
+
16
+ useBindings(() => ({
17
+ bindings: [
18
+ {
19
+ key: "return",
20
+ desc: "Confirm alert",
21
+ group: "Dialog",
22
+ cmd: () => {
23
+ props.onConfirm?.()
24
+ dialog.clear()
25
+ },
26
+ },
27
+ ],
28
+ }))
29
+ return (
30
+ <box paddingLeft={2} paddingRight={2} gap={1}>
31
+ <box flexDirection="row" justifyContent="space-between">
32
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
33
+ {props.title}
34
+ </text>
35
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
36
+ esc
37
+ </text>
38
+ </box>
39
+ <box paddingBottom={1}>
40
+ <text fg={theme.textMuted}>{props.message}</text>
41
+ </box>
42
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
43
+ <box
44
+ paddingLeft={3}
45
+ paddingRight={3}
46
+ backgroundColor={theme.primary}
47
+ onMouseUp={() => {
48
+ props.onConfirm?.()
49
+ dialog.clear()
50
+ }}
51
+ >
52
+ <text fg={theme.selectedListItemText}>ok</text>
53
+ </box>
54
+ </box>
55
+ </box>
56
+ )
57
+ }
58
+
59
+ DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
60
+ return new Promise<void>((resolve) => {
61
+ dialog.replace(
62
+ () => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
63
+ () => resolve(),
64
+ )
65
+ })
66
+ }
@@ -0,0 +1,108 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useTheme } from "../context/theme"
3
+ import { useDialog, type DialogContext } from "./dialog"
4
+ import { createStore } from "solid-js/store"
5
+ import { For } from "solid-js"
6
+ import { Locale } from "@/util/locale"
7
+ import { useBindings } from "../keymap"
8
+
9
+ export type DialogConfirmProps = {
10
+ title: string
11
+ message: string
12
+ onConfirm?: () => void
13
+ onCancel?: () => void
14
+ label?: string
15
+ }
16
+
17
+ export type DialogConfirmResult = boolean | undefined
18
+
19
+ export function DialogConfirm(props: DialogConfirmProps) {
20
+ const dialog = useDialog()
21
+ const { theme } = useTheme()
22
+ const [store, setStore] = createStore({
23
+ active: "confirm" as "confirm" | "cancel",
24
+ })
25
+
26
+ useBindings(() => ({
27
+ bindings: [
28
+ {
29
+ key: "return",
30
+ desc: "Confirm dialog selection",
31
+ group: "Dialog",
32
+ cmd: () => {
33
+ if (store.active === "confirm") props.onConfirm?.()
34
+ if (store.active === "cancel") props.onCancel?.()
35
+ dialog.clear()
36
+ },
37
+ },
38
+ {
39
+ key: "left",
40
+ desc: "Previous dialog option",
41
+ group: "Dialog",
42
+ cmd: () => {
43
+ setStore("active", store.active === "confirm" ? "cancel" : "confirm")
44
+ },
45
+ },
46
+ {
47
+ key: "right",
48
+ desc: "Next dialog option",
49
+ group: "Dialog",
50
+ cmd: () => {
51
+ setStore("active", store.active === "confirm" ? "cancel" : "confirm")
52
+ },
53
+ },
54
+ ],
55
+ }))
56
+ return (
57
+ <box paddingLeft={2} paddingRight={2} gap={1}>
58
+ <box flexDirection="row" justifyContent="space-between">
59
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
60
+ {props.title}
61
+ </text>
62
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
63
+ esc
64
+ </text>
65
+ </box>
66
+ <box paddingBottom={1}>
67
+ <text fg={theme.textMuted}>{props.message}</text>
68
+ </box>
69
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
70
+ <For each={["cancel", "confirm"] as const}>
71
+ {(key) => (
72
+ <box
73
+ paddingLeft={1}
74
+ paddingRight={1}
75
+ backgroundColor={key === store.active ? theme.primary : undefined}
76
+ onMouseUp={() => {
77
+ if (key === "confirm") props.onConfirm?.()
78
+ if (key === "cancel") props.onCancel?.()
79
+ dialog.clear()
80
+ }}
81
+ >
82
+ <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
83
+ {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
84
+ </text>
85
+ </box>
86
+ )}
87
+ </For>
88
+ </box>
89
+ </box>
90
+ )
91
+ }
92
+
93
+ DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
94
+ return new Promise<DialogConfirmResult>((resolve) => {
95
+ dialog.replace(
96
+ () => (
97
+ <DialogConfirm
98
+ title={title}
99
+ message={message}
100
+ onConfirm={() => resolve(true)}
101
+ onCancel={() => resolve(false)}
102
+ label={label}
103
+ />
104
+ ),
105
+ () => resolve(undefined),
106
+ )
107
+ })
108
+ }