Compare commits

..
Author SHA1 Message Date
opencode d7a6e1daaf release: v1.15.5 2026-05-18 20:57:02 +00:00
Kit LangtonandGitHub b396b71c6f fix(ui): guard reasoning renderer against undefined text (#28222) 2026-05-18 16:04:07 -04:00
Aiden ClineandGitHub d8efc575fa refactor(session): extract prompt tool resolution (#28204) 2026-05-18 14:50:31 -05:00
opencode-agent[bot] e1fbed8fb6 chore: update nix node_modules hashes 2026-05-18 19:12:18 +00:00
James LongandGitHub 12ae22378f fix(plugin): ask in tools from plugins returns promise instead of effect (#28217) 2026-05-18 18:57:29 +00:00
opencode-agent[bot] a88b436eec chore: generate 2026-05-18 18:42:55 +00:00
Kit LangtonandGitHub dbe36851bc Preview native LLM runtime stack (#27114) 2026-05-18 14:41:36 -04:00
James LongandGitHub ff9d7cab5c fix(core): fix file references in workspaces (#28209) 2026-05-18 18:23:35 +00:00
Kit LangtonandGitHub 88681d389b Migrate provider lookup tests to instance fixtures
Migrate provider env precedence, model lookup, and default model tests to Effect-aware instance fixtures while keeping behavior unchanged.
2026-05-18 18:01:53 +00:00
Kit LangtonandGitHub ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit LangtonandGitHub 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit LangtonandGitHub 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
Kit LangtonandGitHub b039702e8c feat(tui): add syntax highlighting for elixir, fsharp, r, make, vim, xml, agda (#28198) 2026-05-18 13:08:08 -04:00
opencode-agent[bot] 094886c848 chore: generate 2026-05-18 17:03:44 +00:00
Shoubhit DashandGitHub dc0297f4ac refactor(session): extract reference prompt helpers (#28197) 2026-05-18 22:32:18 +05:30
Kit LangtonandGitHub 3ab67f3280 Stabilize watcher test readiness (#28194) 2026-05-18 12:51:46 -04:00
Shoubhit DashandGitHub a2e6bd503b refactor(reference): split materialization state (#28190) 2026-05-18 22:14:33 +05:30
Aiden ClineandGitHub 05ac345696 refactor(session): move prompt reminders out of core loop (#28082) 2026-05-18 11:36:38 -05:00
opencode-agent[bot] e3feca09b3 chore: generate 2026-05-18 16:20:46 +00:00
Kit LangtonandGitHub 896ad7b884 Speed up targeted opencode tests
Reduce avoidable setup costs in slow opencode tests while preserving reviewed coverage and recording the benchmark evidence for follow-up test-suite work.
2026-05-18 16:18:29 +00:00
opencode-agent[bot] 0a945219a9 chore: generate 2026-05-18 16:17:46 +00:00
Shoubhit DashandGitHub 94828eb44b refactor(repository): type cache failures (#28188) 2026-05-18 21:46:18 +05:30
Shoubhit DashandGitHub 96192495ae refactor(repository): add cache service (#28184) 2026-05-18 21:25:38 +05:30
opencode-agent[bot] f7b5576bcc chore: generate 2026-05-18 15:39:33 +00:00
Kit LangtonandGitHub cb35493242 fix(bus): acquire PubSub subscription eagerly to close /event race (#27959) 2026-05-18 11:38:05 -04:00
Shoubhit DashandGitHub 5bfd7fd16c refactor(repository): clarify reference domain (#28182) 2026-05-18 21:04:44 +05:30
opencode-agent[bot] 2932b41e64 chore: generate 2026-05-18 15:14:13 +00:00
Shoubhit DashandGitHub eb389c58eb refactor(reference): normalize config entries (#28178) 2026-05-18 20:42:41 +05:30
Shoubhit DashandGitHub 54ff0a669b test(reference): cover configured reference contracts (#28170) 2026-05-18 20:09:27 +05:30
opencode-agent[bot] e56999fd36 chore: generate 2026-05-18 12:31:37 +00:00
Simon KleeandGitHub 1124315267 run: refresh prompt layout after paste (#28164)
Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
2026-05-18 12:30:20 +00:00
opencode-agent[bot] 2bf3f3041f chore: update nix node_modules hashes 2026-05-18 12:03:51 +00:00
SebastianandGitHub 6e4db5666a upgrade opentui to 0.2.14 (#28090) 2026-05-18 13:44:08 +02:00
Shoubhit DashandGitHub 564cde393e fix(tui): copy pasted prompt content (#28156) 2026-05-18 17:02:34 +05:30
opencode-agent[bot] c813927bb6 chore: generate 2026-05-18 11:07:52 +00:00
Simon KleeandGitHub 5970c12d90 run: replay session history on interactive resume (#26880) 2026-05-18 13:06:27 +02:00
opencode-agent[bot] 116a4e33ba chore: generate 2026-05-18 11:06:05 +00:00
Shoubhit DashandGitHub 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan AllanandGitHub 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
Brendan AllanandGitHub 418c0ea04a feat(desktop): add notification permission for renderer (#28119) 2026-05-18 15:43:28 +08:00
opencode-agent[bot] 4312e5df05 chore: generate 2026-05-18 07:21:25 +00:00
Brendan AllanandGitHub f80f3e33ec desktop: add free limit + go usage exceeded dialogs to match tui (#27677) 2026-05-18 15:20:08 +08:00
opencode-agent[bot] 479206449e chore: generate 2026-05-18 06:41:17 +00:00
6849059b00 fix(app): hide prompt placeholder for whitespace input (#28101)
Co-authored-by: ShrootBuck <ShrootBuck@users.noreply.github.com>
2026-05-18 14:39:51 +08:00
opencode-agent[bot] 43df145cd2 chore: update nix node_modules hashes 2026-05-18 03:59:28 +00:00
Frank 28a0bf6c8f sync 2026-05-17 23:43:23 -04:00
opencode-agent[bot] 30e4edffd5 chore: generate 2026-05-18 03:42:06 +00:00
5452ab6db7 perf(app): virtualize session timeline rows (#26949)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-05-18 03:40:52 +00:00
DaxandGitHub e85119aa64 Load models.dev snapshot from build global (#28077) 2026-05-17 21:59:44 -04:00
黑墨水鱼andGitHub 71b27a1b0f fix: sync PWA status bar theme-color with app color scheme (#28006) 2026-05-18 00:12:02 +00:00
opencode-agent[bot] 32f37d86f0 chore: update nix node_modules hashes 2026-05-17 23:40:17 +00:00
opencode-agent[bot] b14ea406ba chore: generate 2026-05-17 23:25:50 +00:00
Luke ParkerandGitHub de846ca57d chore: Upgrade Bun to the final non-rust version (#27648) 2026-05-18 09:24:26 +10:00
Luke ParkerandGitHub f06b78751e fix(desktop): install the latest available update (#27953) 2026-05-18 09:23:32 +10:00
opencode-agent[bot] 969d0f48b2 chore: generate 2026-05-17 23:03:12 +00:00
Luke ParkerandGitHub fc19dcc70c fix: sort v2 session list by updated time (#27954) 2026-05-18 09:01:55 +10:00
opencode-agent[bot] 49c6b46afc chore: update nix node_modules hashes 2026-05-17 22:39:36 +00:00
SebastianandGitHub f97e115ee2 dialog prompt submit keybind + opentui event sink (#27945) 2026-05-18 00:23:19 +02:00
Dax Raad e92b1fe7d7 core: let models layer infer its own type so layer composition no longer requires matching explicit requirements 2026-05-17 17:37:11 -04:00
253 changed files with 10674 additions and 176865 deletions
+1 -6
View File
@@ -2,12 +2,7 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"mcp": {
"opencode": {
"type": "remote",
"url": "http://127.0.0.1:43110/mcp"
}
},
"mcp": {},
"tools": {
"github-triage": false,
"github-pr-search": false,
+645 -862
View File
File diff suppressed because it is too large Load Diff
-301
View File
@@ -1,301 +0,0 @@
# Effect Service Dependency Graph — Simulated Routes
Generated for `createSimulatedRoutes` in `packages/opencode/src/server/routes/instance/httpapi/server.ts`.
## Notation
- `→ X` means "yields `X.Service` from its `Effect.gen` body at layer init (a true `RIn` of `.layer`)"
- `(lazy)` means "uses `InstanceState.context` or similar at call time, not at layer construction"
- `(opt)` means "uses `Effect.serviceOption(X)` — not strictly required"
- `(internal)` means "satisfied internally by the `.layer` itself via `Layer.provide(...)`, NOT a residual requirement"
## Service → Dependencies (current, post-rebase)
```
─── External / Platform ─────────────────────────────────────────
NodePath (no app deps) provides Path.Path
FetchHttpClient (no app deps) provides HttpClient.HttpClient
HttpServer.layerServices (no app deps)
ChildProcessSpawner (from SimulationSpawner) (no app deps)
─── Leaf services (no app deps) ─────────────────────────────────
Global (no app deps)
Env (no app deps — uses InstanceState.make, no Service yields)
Bus (no app deps — uses InstanceState.make, no Service yields)
SyncEvent → RuntimeFlags, Bus (NEW — previously listed as leaf/lazy)
AccountRepo (no app deps — pure DB closures)
PtyTicket (no app deps — Cache only)
Truncate → AppFileSystem
─── Middleware/route layers (no app service deps) ───────────────
errorLayer
compressionLayer → HttpServerRequest (builtin)
corsVaryFix
fenceLayer → HttpServerRequest (builtin)
simulationShareNextLayer provides ShareNext (Layer.succeed override)
─── Simulation overrides ────────────────────────────────────────
SimulationFileSystem provides AppFileSystem (does NOT provide FileSystem.FileSystem;
tier0 also merges FileSystem.layerNoop({}) for that tag)
SimulationSpawner provides ChildProcessSpawner (Layer.succeed; no deps)
SimulationNetwork provides SimulationNetwork.Service + HttpClient.HttpClient
(httpClientLayer composed inside `layer(options)`)
SimulationGit → AppFileSystem (overrides Git tag)
SimulationProvider → Simulation (overrides Provider tag)
Simulation → AppFileSystem, SimulationNetwork
─── Core services ───────────────────────────────────────────────
EffectFlock → Global, AppFileSystem
Auth → AppFileSystem
McpAuth → AppFileSystem
Account → AccountRepo, HttpClient
Npm → AppFileSystem, Global, FileSystem.FileSystem, EffectFlock
Config → AppFileSystem, Auth, Account, Env, Npm
Permission → Bus
Plugin → Bus, Config, RuntimeFlags (NEW: RuntimeFlags)
Discovery → AppFileSystem, Path, HttpClient
Skill → Discovery, Config, Bus, AppFileSystem, Global,
RuntimeFlags (NEW: RuntimeFlags)
SystemPrompt → Skill
─── File / git ──────────────────────────────────────────────────
Ripgrep → AppFileSystem, HttpClient, ChildProcessSpawner
File → AppFileSystem, Ripgrep, Git, Scope
FileWatcher → Config, Git
Format → Config, AppProcess, RuntimeFlags (CHANGED: was ChildProcessSpawner; now AppProcess + RuntimeFlags)
Snapshot → AppFileSystem, AppProcess, Config (CHANGED: was ChildProcessSpawner)
Storage → AppFileSystem, Git
Vcs → Git, Bus, Scope
Worktree → Scope, AppFileSystem, Path, AppProcess,
Git, Project, InstanceStore (CHANGED: AppProcess instead of ChildProcessSpawner)
Project → AppFileSystem, Path, ChildProcessSpawner,
Bus, RuntimeFlags (NEW: RuntimeFlags)
─── Provider / LSP / MCP ────────────────────────────────────────
ModelsDev → AppFileSystem, HttpClient
ProviderAuth → Auth, Plugin
LSP → Config, RuntimeFlags (NEW: RuntimeFlags)
McpAuth → AppFileSystem
MCP → ChildProcessSpawner, McpAuth, Bus, Config
─── Session graph ───────────────────────────────────────────────
Todo → Bus
Question → Bus
SessionStatus → Bus
SessionRunState → BackgroundJob, SessionStatus (NEW: BackgroundJob)
Instruction → Config, AppFileSystem, Global, HttpClient,
RuntimeFlags (NEW: RuntimeFlags)
Session → BackgroundJob, Bus, Storage, SyncEvent,
RuntimeFlags (NEW: BackgroundJob, RuntimeFlags)
SessionSummary → Session, Snapshot, Storage, Bus
SessionRevert → Session, Snapshot, Storage, Bus,
SessionSummary, SessionRunState, SyncEvent
LLM → Auth, Config, Provider, Plugin, RuntimeFlags (CHANGED: Permission satisfied internally;
(Permission satisfied internally via .layer) RuntimeFlags is new)
Agent → Config, Auth, Plugin, Skill, Provider,
RuntimeFlags (NEW: RuntimeFlags)
Command → Config, MCP, Skill
SessionProcessor → Session, Config, Bus, Snapshot, Agent, LLM,
Permission, Plugin, SessionSummary,
SessionStatus, Image, EventV2Bridge,
RuntimeFlags, Scope (NEW: Image, EventV2Bridge, RuntimeFlags)
SessionCompaction → Bus, Config, Session, Agent, Plugin,
SessionProcessor, Provider, EventV2Bridge,
RuntimeFlags (NEW: EventV2Bridge, RuntimeFlags)
SessionPrompt → Bus, SessionStatus, Session, Agent, Provider,
SessionProcessor, SessionCompaction, Plugin,
Command, Config, Permission, AppFileSystem,
MCP, LSP, ToolRegistry, Truncate,
ChildProcessSpawner, Scope, Instruction,
SessionRunState, SessionRevert,
SessionSummary, SystemPrompt, LLM,
Image, Reference, EventV2Bridge,
RuntimeFlags (NEW: Image, Reference, EventV2Bridge, RuntimeFlags)
ToolRegistry → Config, Plugin, Question, Todo, Agent, Skill,
Session, SessionStatus, BackgroundJob,
Provider, Git, Reference, LSP, Instruction,
AppFileSystem, Bus, HttpClient,
ChildProcessSpawner, Ripgrep, Format,
Truncate, RuntimeFlags (NEW: BackgroundJob, Reference, RuntimeFlags)
─── Share / Workspace ───────────────────────────────────────────
ShareNext (provided by simulationShareNextLayer in sim)
SessionShare → Config, Session, ShareNext, Scope, SyncEvent,
RuntimeFlags (NEW: RuntimeFlags)
Workspace → Auth, Session, SessionPrompt, HttpClient,
SyncEvent, Vcs, AppFileSystem, RuntimeFlags (NEW: AppFileSystem (explicit), RuntimeFlags)
─── Misc ────────────────────────────────────────────────────────
Installation → HttpClient, AppProcess (CHANGED: AppProcess instead of ChildProcessSpawner)
Pty → Config, Bus, Plugin
─── Instance lifecycle ──────────────────────────────────────────
InstanceBootstrap → Config, File, FileWatcher, Format, LSP, Plugin,
Project, Reference, ShareNext, Snapshot, Vcs (NEW: Reference)
InstanceStore → Project, InstanceBootstrap, Scope
Observability (no app deps; provides Logger + tracer)
```
## NEW dependencies introduced since previous graph
The rebase pulled in several new cross-cutting services that need to be
satisfied somewhere in tier0/tier1 of the simulated chain. They are NOT yet
listed in the `Tier*Services` unions or merged into any tier in `server.ts`:
```
RuntimeFlags.Service — yielded by ~17 services (Plugin, Skill, Project,
Session, SyncEvent, Format, LSP, Instruction,
Agent, LLM, SessionShare, SessionProcessor,
SessionCompaction, ToolRegistry, SessionPrompt,
Workspace, SessionRunState).
Source: `@/effect/runtime-flags`.
AppProcess.Service — yielded by Installation, Format, Snapshot,
Worktree (and prod Git, but sim uses SimulationGit).
Source: presumed `@opencode-ai/core/app-process`
or similar — needs `AppProcess.defaultLayer`.
BackgroundJob.Service — yielded by Session, SessionRunState, ToolRegistry.
Needs `BackgroundJob.defaultLayer`.
Image.Service — yielded by SessionProcessor, SessionPrompt.
EventV2Bridge.Service — yielded by SessionProcessor, SessionCompaction,
SessionPrompt. Source: `@/event-v2-bridge` (already
imported in server.ts but never added to a tier).
Reference.Service — yielded by ToolRegistry, SessionPrompt,
InstanceBootstrap.
```
## Dependency Tiers (topological order)
Roughly, build order from leaves to roots:
```
Tier 0 (no deps):
Global, Env, NodePath, AccountRepo, PtyTicket, Bus, SyncEvent,
AppFileSystem (sim), ChildProcessSpawner (sim), HttpClient (sim),
SimulationNetwork, FileSystem.layerNoop, simulationShareNextLayer
+ (NEW REQUIRED) RuntimeFlags, AppProcess, BackgroundJob, Image,
EventV2Bridge, Reference
(SyncEvent now depends on RuntimeFlags + Bus, so it's actually tier 1.)
Tier 1:
Auth, Truncate, EffectFlock, Permission, Todo, Question,
SessionStatus, McpAuth, Discovery, SimulationGit, Ripgrep, Account,
SyncEvent (needs RuntimeFlags + Bus)
Tier 2:
Npm, ModelsDev, Project, Installation, Storage, Vcs, SessionRunState
Tier 3:
Config, File, Simulation, Session
Tier 4:
Plugin, FileWatcher, Format, Snapshot, LSP, MCP, Skill, Instruction,
SimulationProvider (= Provider)
Tier 5:
Pty, ProviderAuth, SessionSummary, Agent, Command, LLM, SystemPrompt
Tier 6:
SessionRevert, SessionProcessor, SessionShare
Tier 7:
SessionCompaction, ToolRegistry
Tier 8:
SessionPrompt
Tier 9:
Workspace, InstanceBootstrap
Tier 10:
InstanceStore
Tier 11:
Worktree
```
## Potential cycles / hazards
```
Worktree → InstanceStore → InstanceBootstrap → Project → (back to Worktree?)
- InstanceBootstrap requires Project (yes)
- Project does NOT require Worktree directly
- Worktree requires InstanceStore at layer init
→ Worktree must be built AFTER InstanceStore.
SimulationProvider provides Provider tag, depends on Simulation.
Many downstream services depend on Provider — those resolve to
SimulationProvider in this layer chain.
SimulationGit provides Git tag, used by File, FileWatcher,
Storage, Vcs, Worktree, ToolRegistry tools.
LLM.layer pipes Layer.provide(Permission.defaultLayer) internally.
So LLM's residual requirements no longer include Permission, BUT the
sim chain still provides Permission.layer separately (correct — used by
SessionProcessor, SessionPrompt directly).
```
## Why the simulated chain fails today
The current `server.ts` `Tier0Services` union lists:
```
AppFileSystem, FileSystem.FileSystem, ChildProcessSpawner, HttpClient,
SimulationNetwork, Path, Global, Env, Bus, AccountRepo, ShareNext,
SyncEvent, PtyTicket
```
But the actual `Layer.mergeAll(...)` body at tier0 has residual requirements
beyond that union. The TS error says
`Layer<..., never, Service | Service>` — those two unresolved `Service`s
are members of the NEW dependencies table above.
The most likely culprits, in order of leakage:
1. **`SyncEvent.layer`** now yields `RuntimeFlags` and `Bus`. `Bus` is in tier0
already, but `RuntimeFlags` is not provided anywhere in `createSimulatedRoutes`.
So `SyncEvent` leaks `RuntimeFlags` into tier0's `RIn`.
2. Downstream tiers also leak `RuntimeFlags`, `AppProcess`, `BackgroundJob`,
`Image`, `EventV2Bridge`, `Reference` — these cascade through every higher
tier as `Service | Service | ...` in the error type.
## Fix strategy
The minimal change to make tier0 type-check:
1. Add `RuntimeFlags.defaultLayer` to tier0 and `RuntimeFlags.Service` to
`Tier0Services`. This satisfies `SyncEvent`'s new dep and unblocks every
service that yields `RuntimeFlags`.
2. Add `AppProcess.defaultLayer` (or a simulated equivalent) to tier0 and
`AppProcess.Service` to `Tier0Services`. Needed by `Installation`, `Format`,
`Snapshot`, `Worktree`.
3. Add `BackgroundJob.defaultLayer` to tier0 and `BackgroundJob.Service` to
`Tier0Services`. Needed by `Session`, `SessionRunState`, `ToolRegistry`.
4. Add `Image.defaultLayer` to tier0 (or wherever it fits) and `Image.Service`
to `Tier0Services`. Needed by `SessionProcessor`, `SessionPrompt`.
5. Add `EventV2Bridge.defaultLayer` to tier0 and `EventV2Bridge.Service` to
`Tier0Services`. (Note: `EventV2Bridge` is already imported in `server.ts`
for the production routes but is not in any simulated tier.)
6. Add `Reference.defaultLayer` to a tier that satisfies its deps (it's a leaf
wrt the listed graph above) and `Reference.Service` to the corresponding
tier union. Needed by `ToolRegistry`, `SessionPrompt`, `InstanceBootstrap`.
Production routes (`createProductionRoutes`) already include
`RuntimeFlags.defaultLayer` and `EventV2Bridge.defaultLayer` in the flat
`Layer.provide([...])` list — they were simply never carried over to the
simulated chain after the rebase.
## See also
- `dependency-graph.html` — interactive visualization of the same data.
-3
View File
@@ -223,8 +223,6 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
////////////////
// CONSOLE
////////////////
@@ -274,7 +272,6 @@ new sst.cloudflare.x.SolidStart("Console", {
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
]
: []),
gatewayKv,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
"aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
"aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
"x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
}
}
+6 -6
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
@@ -31,13 +31,13 @@
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.12",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -74,7 +74,7 @@
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"virtua": "0.49.1",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
-1
View File
@@ -10,7 +10,6 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.5",
"description": "",
"type": "module",
"exports": {
+4
View File
@@ -16,6 +16,10 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
// Update theme-color meta tag to match app color scheme
var metas = document.querySelectorAll("meta[name='theme-color']")
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + mode)
@@ -0,0 +1,44 @@
import { usePlatform } from "@/context/platform"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { JSX } from "solid-js"
export type DialogGoUpsellProps = {
title: string
description: JSX.Element
link?: string
actionLabel: string
onClose?: (dontShowAgain?: boolean) => void
}
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog()
const platform = usePlatform()
const runAction = () => {
if (props.link) platform.openLink(props.link)
props.onClose?.()
dialog.close()
}
const dismiss = () => {
props.onClose?.(true)
dialog.close()
}
return (
<Dialog title={props.title} description={props.description} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}>
Don't show again
</Button>
<Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel}
</Button>
</div>
</div>
</Dialog>
)
}
+3 -3
View File
@@ -99,8 +99,6 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const queryOptions = useQueryOptions()
@@ -860,7 +858,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "")
const shouldReset =
textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0
if (shouldReset) {
closePopover()
@@ -41,6 +41,7 @@ export function trimSessions(
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
roots.sort(compareSessionRecent)
const children = all.filter((s) => !!s.parentID)
const base = roots.slice(0, limit)
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+66 -201
View File
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useSearchParams } from "@solidjs/router"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -64,6 +64,7 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string }
@@ -75,7 +76,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +85,74 @@ type SessionHistoryWindowInput = {
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
shift: false,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
let afterVisible = beforeVisible
let added = 0
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
let added = 0
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break
}
const afterVisible = input.visibleUserMessages().length
if (opts?.prefetch) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
if (growth > 0) {
scheduleShiftReset()
return
}
if (turnStart() !== start) return
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
@@ -292,27 +161,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
turnStart,
setTurnStart,
renderedUserMessages,
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
@@ -333,6 +193,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -737,6 +598,7 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollMark = 0
let messageMark = 0
@@ -1403,9 +1265,8 @@ export default function Page() {
},
)
const historyWindow = createSessionHistoryWindow({
const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1288,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
if (!historyMore()) return
void historyWindow.loadAndReveal()
void historyLoader.loadAndReveal()
})
}
@@ -1439,15 +1300,14 @@ export default function Page() {
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
if (!more) return
fill()
},
{ defer: true },
@@ -1749,15 +1609,14 @@ export default function Page() {
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume,
})
@@ -1787,6 +1646,8 @@ export default function Page() {
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
useUsageExceededDialogs()
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
@@ -1830,20 +1691,23 @@ export default function Page() {
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1853,8 +1717,11 @@ export default function Page() {
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1863,14 +1730,12 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -0,0 +1,368 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
CommentStrip: {
userMessageID: string
previousUserMessage: boolean
}
UserMessage: {
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
BottomSpacer: {}
}
export namespace TimelineRow {
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
previousUserMessage: boolean
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow =
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
| BottomSpacer
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
case "BottomSpacer":
return "bottom-spacer"
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
assistantMessages: AssistantMessage[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
? [
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
{ type: "interrupted" as const },
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
previousUserMessage,
}),
)
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
previousUserMessage: comments.length === 0 && previousUserMessage,
}),
)
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
let assistantGroupIndex = 0
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
return
}
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
}),
)
assistantGroupIndex += 1
})
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
const diffs = (userMessage.summary?.diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
if (result.some((item) => item.file === diff.file)) return result
result.push(diff)
return result
}, [])
.reverse()
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data?.message
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
}),
)
}
return rows
}
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
: undefined,
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { useSDK } from "@/context/sdk"
import { Persist, persisted } from "@/utils/persist"
import { SessionStatus } from "@opencode-ai/sdk/v2"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useSessionLayout } from "./session-layout"
import { useDialog } from "@opencode-ai/ui/context"
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"
import { useI18n } from "@opencode-ai/ui/context"
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
} as const
}
if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
} as const
}
}
export function useUsageExceededDialogs() {
const sdk = useSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
createStore({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
}),
)
onCleanup(
sdk.event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status
if (!action) return
if (dialog.active) return
const keys = goUpsellKeys(evt.properties.status)
if (!keys) return
const seen = goUpsellState[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return
if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("../../components/dialog-connect-provider").then((x) =>
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
)
}
}}
/>
))
} else if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
}}
/>
))
}
}),
)
}
@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage?: (id: string) => void
scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined
}) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
let clearing = false
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
queue(() => {
seek(message.id, behavior)
})
updateHash(message.id)
return
}
input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
if (!input.sessionID() || !input.messagesReady()) return
visibleUserMessages()
input.turnStart()
let targetId = input.pendingMessage()
if (!targetId) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,6 +1,7 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
return
if (Resource.App.stage !== "production") return
if (sessionId === "") return
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.13
ARG BUN_VERSION=1.3.14
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-3
View File
@@ -6,7 +6,6 @@ function truthy(key: string) {
}
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const OPENCODE_SIMULATION = truthy("OPENCODE_SIMULATION")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const Flag = {
@@ -29,8 +28,6 @@ export const Flag = {
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_SIMULATION,
OPENCODE_SIMULATION_BACKEND: OPENCODE_SIMULATION || truthy("OPENCODE_SIMULATION_BACKEND"),
// Experimental
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
@@ -104,7 +104,8 @@ export const Provider = Schema.Struct({
})
export type Provider = Schema.Schema.Type<typeof Provider>
export const Catalog = Schema.Record(Schema.String, Provider)
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
@@ -113,9 +114,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -158,12 +157,9 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
Effect.map((v) => v as Record<string, Provider> | undefined),
)
// Bundled at build time; absent in dev — `tryPromise` covers both.
const loadSnapshot = Effect.tryPromise({
// @ts-ignore — generated at build time, may not exist in dev
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
catch: () => undefined,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
@@ -224,4 +220,4 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
)
export * as ModelsDev from "./models"
export * as ModelsDev from "./models-dev"
-2
View File
@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
import { DateTime, Effect } from "effect"
import { Catalog } from "../catalog"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models"
import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
-38
View File
@@ -54,29 +54,6 @@ export interface Options {
level?: Level
}
export interface Entry {
readonly time: string
readonly level: Level
readonly tags: Record<string, unknown>
readonly message: string
}
const MAX_ENTRIES = 5_000
const memory: Entry[] = []
function record(entry: Entry) {
memory.push(entry)
if (memory.length > MAX_ENTRIES) memory.splice(0, memory.length - MAX_ENTRIES)
}
export function entries(): Entry[] {
return memory.slice()
}
export function clearEntries() {
memory.length = 0
}
let logpath = ""
export function file() {
return logpath
@@ -162,39 +139,24 @@ export function create(tags?: Record<string, any>) {
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
function capture(level: Level, message: any, extra?: Record<string, any>) {
const text = message instanceof Error ? formatError(message) : message === undefined ? "" : String(message)
record({
time: new Date().toISOString(),
level,
tags: { ...tags, ...extra },
message: text,
})
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
capture("DEBUG", message, extra)
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
capture("INFO", message, extra)
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
capture("ERROR", message, extra)
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
capture("WARN", message, extra)
write("WARN " + build(message, extra))
}
},
+3 -3
View File
@@ -4,7 +4,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
@@ -136,14 +136,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(Object.keys(result).length).toBeGreaterThan(0)
expect(result).toEqual({})
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.4",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+4 -12
View File
@@ -6,8 +6,6 @@ import { initLogging } from "./logging"
const logger = initLogging()
const { autoUpdater } = pkg
let downloadedUpdateVersion: string | undefined
export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
autoUpdater.logger = logger
@@ -26,12 +24,6 @@ export function setupAutoUpdater() {
export async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedUpdateVersion) {
logger.log("returning cached downloaded update", {
version: downloadedUpdateVersion,
})
return { updateAvailable: true, version: downloadedUpdateVersion }
}
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
@@ -57,7 +49,6 @@ export async function checkUpdate() {
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
logger.log("update download completed", { version })
downloadedUpdateVersion = version
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
@@ -66,14 +57,15 @@ export async function checkUpdate() {
}
export async function installUpdate(killSidecar: () => Promise<void>) {
if (!downloadedUpdateVersion) {
const result = await checkUpdate()
if (!result.updateAvailable) {
logger.log("install update skipped", {
reason: "no downloaded update ready",
reason: result.failed ? "update check failed" : "no update available",
})
return
}
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
version: result.version ?? null,
})
await killSidecar()
autoUpdater.quitAndInstall()
+7 -5
View File
@@ -9,6 +9,8 @@ const rendererRoot = join(root, "../renderer")
const rendererProtocol = "oc"
const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
protocol.registerSchemesAsPrivileged([
{
@@ -109,7 +111,7 @@ export function createMainWindow() {
},
})
allowClipboardWrite(win)
allowRendererPermissions(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@@ -162,7 +164,7 @@ export function createLoadingWindow() {
},
})
allowClipboardWrite(win)
allowRendererPermissions(win)
loadWindow(win, "loading.html")
@@ -199,16 +201,16 @@ function loadWindow(win: BrowserWindow, html: string) {
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
function allowClipboardWrite(win: BrowserWindow) {
function allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
permission === clipboardWritePermission &&
rendererPermissions.has(permission) &&
isTrustedRendererUrl(details.requestingUrl) &&
webContents.id === win.webContents.id,
)
})
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
if (permission !== clipboardWritePermission) return false
if (!rendererPermissions.has(permission)) return false
if (webContents && webContents.id !== win.webContents.id) return false
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
})
-1
View File
@@ -9,7 +9,6 @@
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
@@ -9,7 +9,6 @@
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -25,7 +25,6 @@ export default createHandler(() => (
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
{assets}
</head>
<body class="antialiased overscroll-none text-12-regular">
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.4"
version = "1.15.5"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -296,7 +296,6 @@ declare module "sst" {
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"Stat": cloudflare.Service
"ZenData": cloudflare.R2Bucket
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+2
View File
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
-2
View File
@@ -3,8 +3,6 @@ dist
dist-*
gen
app.log
src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
.artifacts
-19
View File
@@ -1,19 +0,0 @@
Simulation script runner.
Usage:
bun run.ts <script.json> [options]
Options:
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
--chunk <n> Actions per step batch (default 3)
--max-steps <n> Hard cap on step calls (default unlimited)
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
--message-includes <s> Only stop when message includes substring
--service-includes <s> Only stop when tag.service includes substring
--reset Reset sim state + restart TUI before load
--no-reset Skip reset (default)
--keep-going Don't stop on errors; continue to end
--quiet Suppress per-batch progress
--json Emit JSON summary at the end
--check-every <n> Check logs every N batches (default 1)
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -10,6 +10,8 @@
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
@@ -38,6 +40,7 @@
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -61,7 +64,6 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"just-bash": "3.0.1",
"prettier": "3.6.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
@@ -100,6 +102,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+76
View File
@@ -296,5 +296,81 @@ export default {
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}
@@ -0,0 +1,52 @@
// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md.
// Use this for periodic sanity checks; use profile-test-files.ts for discovery.
// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test
const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0)
const runs = Number(Bun.env.BENCH_RUNS ?? 1)
const timings: number[] = []
if (!Number.isInteger(warmups) || warmups < 0) {
console.error("BENCH_WARMUPS must be a non-negative integer")
process.exit(1)
}
if (!Number.isInteger(runs) || runs < 1) {
console.error("BENCH_RUNS must be a positive integer")
process.exit(1)
}
for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) {
const measured = index >= warmups
const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}`
const start = performance.now()
console.log(`bench:test ${label}`)
const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], {
cwd: import.meta.dir + "/..",
stdout: "inherit",
stderr: "inherit",
env: Bun.env,
})
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(`bench:test failed during ${label} with exit code ${exitCode}`)
process.exit(exitCode)
}
const seconds = (performance.now() - start) / 1000
console.log(`bench:test ${label} ${seconds.toFixed(3)}s`)
if (measured) timings.push(seconds)
}
const sorted = timings.toSorted((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length
const best = sorted[0] ?? median
const worst = sorted.at(-1) ?? median
console.log(
`bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`,
)
console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`)
console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`)
console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`)
+2 -1
View File
@@ -11,7 +11,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
await import("./generate.ts")
const generated = await import("./generate.ts")
// Load migrations from migration directories
const migrationDirs = (
@@ -52,6 +52,7 @@ await Bun.build({
external: ["jsonc-parser", "@lydell/node-pty"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_MODELS_DEV: generated.modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
},
files: {
+2 -1
View File
@@ -12,7 +12,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
await import("./generate.ts")
const generated = await import("./generate.ts")
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
@@ -218,6 +218,7 @@ for (const item of targets) {
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_MODELS_DEV: generated.modelsData,
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
+2 -11
View File
@@ -8,16 +8,7 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "../core/src/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "../core/src/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
console.log("Loaded models.dev snapshot")
@@ -0,0 +1,42 @@
// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md
// for the benchmark notes, kept wins, and discarded experiments.
// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}"
const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0)
const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000"
const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true }))
.then((files) => files.toSorted())
.then((files) => (limit > 0 ? files.slice(0, limit) : files))
const results = []
for (const file of await files) {
const start = performance.now()
const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], {
cwd: import.meta.dir + "/..",
stdout: "pipe",
stderr: "pipe",
env: Bun.env,
})
const [output, error, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const seconds = (performance.now() - start) / 1000
results.push({ file, seconds, exitCode })
console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`)
if (exitCode !== 0) console.log((output + error).trim())
}
const sorted = results.toSorted((a, b) => b.seconds - a.seconds)
console.log("\nSlowest test files:")
for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) {
console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`)
}
if (sorted[0]) {
console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`)
console.log(`METRIC profiled_test_files=${results.length}`)
}
if (results.some((result) => result.exitCode !== 0)) process.exit(1)
@@ -1,40 +0,0 @@
# Property-Based TUI Testing Working Notes
## Mock LLM Provider
- `SessionPrompt` gets model metadata through `Provider.Service.getModel(...)`.
- Actual generation is routed through `LLM.Service` / `streamText(...)`, so the first mock should return an AI SDK `LanguageModelV3` from the provider service.
- The normal `Provider.layer` builds providers from config/models.dev/plugin state. For first pass, the simulated graph can replace `Provider.Service` with a smaller simulation provider service instead of trying to flow through provider config.
- Control state should own an ordered LLM script queue. The provider/model should consume from that queue when the AI SDK calls the language model.
- First version can support text-only output. Tool calls and stream chunk fidelity can come next.
- Missing script should fail loudly with a typed simulation error, not silently return an empty assistant message.
- Implemented `SimulationProvider.layer`, replacing `Provider.Service` in `createSimulatedRoutes`.
- The provider exposes provider `simulation` and model `mock`.
- `doGenerate` and `doStream` both consume one queued script through `Simulation.Service.nextLLM()`.
- Current script support: `text`, `thinking` (treated as text for now), and `error`.
- Snapshot currently records `llmQueued` and `llmConsumed`, not per-step details yet.
## OpenTUI Fake Renderer
- OpenTUI Solid exposes `testRender(...)` from `@opentui/solid`.
- The lower-level core API is `createTestRenderer(...)` from `@opentui/core/testing`.
- `createTestRenderer(...)` returns `renderer`, `mockInput`, `mockMouse`, `renderOnce`, `captureCharFrame`, `captureSpans`, and `resize`.
- `captureCharFrame()` is the simple screen-buffer string API used heavily in OpenTUI snapshots.
- `captureSpans()` returns structured lines/spans plus cursor position, which is a better starting point for visible element discovery than parsing raw characters.
- `mockInput` supports interactions like `typeText`, `pressEnter`, and `pressArrow`.
- Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`.
- `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`.
- Backend route assembly checks `OPENCODE_SIMULATION_BACKEND`; `OPENCODE_SIMULATION` implies this flag.
- `OPENCODE_SIMULATION_BACKEND=1` can run a real frontend against a simulated backend.
- `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer.
## OpenTUI Action APIs
- Interactable discovery can walk `renderer.root.getChildren()` recursively.
- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets.
- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission.
- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id.
- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`.
- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API.
- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing.
- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`.
@@ -1,388 +0,0 @@
# Property-Based TUI Testing
Status: first-pass implementation plan.
The goal is to drive the TUI against the real opencode app/backend while replacing external effects with deterministic simulation boundaries. The first pass should produce the smallest end-to-end system that can run the real app in a deterministic simulation environment and assert only that the app does not crash.
## Scope
Build these pieces first:
- Mock `AppFileSystem.Service` layer.
- Mock `FetchHttpClient` layer with schema-generated responses through `toArbitrary()`.
- Backend simulation control endpoint.
- Mock LLM provider controlled by the endpoint.
- OpenTUI fake renderer/screen-buffer/interactable-element access.
- Basic action generator that drives the TUI forward.
- Simulation-only embedded MCP server that lets agents observe and drive the TUI.
## Non-Goals
- No semantic graph yet.
- No advanced properties beyond no-crash.
- No fake clock/timer control yet.
- No shrinking yet.
- No broad replacement of app services.
## Decisions
- Load the normal app by default.
- Keep overrides narrow and explicit.
- The first core overrides are `AppFileSystem.Service` and `FetchHttpClient.layer`.
- Do not replace `Provider.Service`, `SessionPrompt.Service`, `ToolRegistry.Service`, or the route tree wholesale unless we prove a narrow seam is impossible.
- Use a backend control endpoint for LLM scripts and simulation state.
- Force `OPENCODE_DB=:memory:` before any code imports `storage/db.ts`.
- Run local simulation under `sandbox-exec` using the old branch setup as the starting point.
- Use `sandbox-exec` as the safety boundary, not as the normal simulated I/O mechanism.
- First built-in property: the app does not crash.
- Only two simulation flags exist: `OPENCODE_SIMULATION` and `OPENCODE_SIMULATION_BACKEND`.
- `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over stdio and implies backend simulation.
- `OPENCODE_SIMULATION_BACKEND` without `OPENCODE_SIMULATION` starts the frontend-side simulation MCP server over loopback HTTP; in the TUI it shows the URL in the home screen.
## Target End-To-End Flow
1. Start opencode through the simulation runner.
2. Runner sets `OPENCODE_DB=:memory:` before backend modules load.
3. Runner installs the mock filesystem and mock HTTP client as narrow core overrides.
4. Runner starts under `sandbox-exec` with host writes denied and external network denied.
5. Runner mounts the TUI with a fake OpenTUI renderer instead of a real terminal.
6. Test calls the simulation endpoint to seed filesystem/network/LLM state.
7. Action generator performs one TUI action.
8. Backend handles real app requests and uses endpoint-provided LLM scripts.
9. Runner waits for quiescence.
10. Built-in no-crash property checks TUI and backend errors.
## Mock AppFileSystem
Goal: backend-visible project/config/state files live in memory and never hit the host filesystem.
Implementation shape:
- Add `packages/opencode/src/testing/simulation/filesystem.ts`.
- Implement an in-memory filesystem that can back `AppFileSystem.Service`.
- Seed it from JSON fixtures supplied through the simulation endpoint or runner config.
- Serialize it into replay traces.
- Fail unsupported operations with typed simulation errors instead of silently falling back to host FS.
- Enable the full simulation runner with `OPENCODE_SIMULATION`.
- Enable only backend simulation with `OPENCODE_SIMULATION_BACKEND`.
- `OPENCODE_SIMULATION` implies `OPENCODE_SIMULATION_BACKEND`.
- Use a fixed virtual root, not `process.cwd()`, so host paths are denied by default.
- Use the old branch's Bun preload/plugin redirection only for code paths that bypass `AppFileSystem.Service`.
- Let `sandbox-exec` catch any remaining direct `fs`, `Bun.file`, or process-level filesystem access.
Required capabilities:
- Files and directories.
- Text and binary content.
- Deterministic `stat` metadata.
- Deterministic path resolution for workspace root, cwd, home, config, state, and temp.
- Reads and writes used by tools and config loading.
- Directory listing and recursive traversal for glob/grep equivalents.
- Snapshot/diff support or enough primitives for existing snapshot code to work.
Direct bypass candidates identified so far:
- `tool/read.ts` uses `createReadStream` directly for text line reads.
- `patch/index.ts` uses `fs/promises` and `readFileSync` directly.
- `storage/db.ts` uses sync `fs` APIs and must be protected by forcing `OPENCODE_DB=:memory:` before import.
- `lsp/server.ts`, `util/filesystem.ts`, `file/watcher.ts`, and several CLI/TUI utilities use direct host filesystem APIs.
- These should be redirected only when needed; otherwise `sandbox-exec` should catch leaks.
Todos:
- [x] Inspect `AppFileSystem.Service` interface and all methods used by backend code.
- [x] List direct `@/util/filesystem`, `fs`, and `Bun.file` bypasses that matter in simulation mode.
- [x] Define mock filesystem data model and fixture JSON format.
- [x] Implement the `AppFileSystem.Service` layer.
- [x] Add typed errors for unsupported operations and host-FS escapes.
- [x] Add activation path from startup through `OPENCODE_SIMULATION` / `OPENCODE_SIMULATION_BACKEND`.
- [x] Add a tiny fixture that includes `opencode.json`, a workspace root, and a few files.
- [ ] Verify read/glob/grep/write/edit use the mock filesystem.
- [ ] Verify sandbox denies host writes when a bypass is introduced.
## Mock FetchHttpClient
Goal: no backend code makes external network calls. Calls either return generated deterministic mock data or fail with a typed simulation error.
Implementation shape:
- Add `packages/opencode/src/testing/simulation/network.ts`.
- Provide a narrow replacement for `FetchHttpClient.layer` / `HttpClient.HttpClient` in simulation startup.
- Allow loopback only when needed for local app/TUI communication.
- Deny all non-loopback network by default.
- Add a response registry controlled by the simulation endpoint.
- For registered schemas, generate deterministic data with `toArbitrary()` and the run seed.
Schema inference problem:
- Raw HTTP requests do not always carry the desired response schema.
- First implementation should find where schema information exists for each network call path.
- If the schema is not available from the raw `HttpClient` call, add a small registry keyed by request matcher and schema.
- The endpoint can register `{ matcher, schema, seedOffset }`, and the mock client can call `toArbitrary(schema)` to generate the response.
- Unknown requests should fail loudly instead of returning generic data.
Network call families found in the first inventory:
- Effect `HttpClient` with schemas close to the call site:
- `account/account.ts`: opencode account/device/auth/org/user/config APIs. Response schemas are local (`TokenRefresh`, `Org`, `User`, `RemoteConfig`, `DeviceAuth`, `DeviceToken`).
- `provider/models.ts`: `${OPENCODE_MODELS_URL || "https://models.dev"}/api.json`. Response schema is `Record<string, Provider>` but currently parsed after `res.text`; register this URL to the provider catalog schema.
- `share/share-next.ts`: share create/sync/remove. Create response schema is `ShareSchema`; sync/remove can be empty/status-only.
- `skill/discovery.ts`: skill index response schema is `Index`; skill file downloads are raw bytes/text.
- `session/instruction.ts`: configured remote instruction URLs return text.
- `tool/mcp-websearch.ts`, `tool/websearch.ts`, and `tool/codesearch.ts`: MCP-style tool calls to Exa/Parallel. Request schemas are local; response shape is MCP JSON-RPC/SSE with `McpResult`.
- `tool/webfetch.ts`: arbitrary user URL returns raw text/html/image bytes, so it needs explicit registration by URL/content type rather than generic schema generation.
- Effect `HttpClient` that should usually be disabled in first-pass simulation:
- `installation/index.ts`: update/install metadata.
- `file/ripgrep.ts`: ripgrep binary download.
- UI and workspace proxy paths: allow only explicitly registered workspace URLs or loopback/local app traffic.
- Raw `fetch` paths:
- `config/config.ts`: well-known and remote config fetches. The schema is loose config JSON; register by configured URL if tests need this path.
- `lsp/server.ts`: language-server release/download fetches. Disable by config in simulation or deny unless explicitly registered.
- plugin auth/provider helpers (`plugin/codex.ts`, `plugin/github-copilot/*`, CLI commands): not part of first-pass TUI smoke unless explicitly exercised.
- Provider SDK calls:
- Most model traffic happens inside AI SDK provider packages, not directly through Effect `HttpClient`.
- First pass should avoid mocking arbitrary provider SDK HTTP. Instead, register a local mock provider/model through the normal provider path and deny provider SDK fetches unless explicitly registered.
- Remote MCP servers:
- Config `mcp.<name>.url` is the registration point. When the app is given a remote MCP URL, the simulation network should register that URL as an MCP protocol endpoint for that named server.
- The schema is not a single app schema; it is the MCP JSON-RPC/SSE protocol plus configured tool/resource/prompt definitions. The mock network should handle MCP protocol methods for registered MCP URLs and generate tool/list/call responses from simulation state.
- The MCP SDK transport may bypass Effect `HttpClient`, so this likely needs either transport-level injection if the SDK supports custom fetch, or the old preload/global `fetch` redirection for registered MCP URLs only.
Registration model:
- `SimulationNetwork.Service` owns a registry keyed by method + URL matcher.
- Registry entries should include a `source`/`kind` so failures explain why a URL was allowed or denied.
- Rough implementation exists at `packages/opencode/src/testing/simulation/network.ts`.
- Current rough registry supports exact URL, regex URL, or predicate matchers, optional method filters, parsed request bodies, static responses, dynamic response functions, and full handlers.
- `SimulationNetworkRoutes` imports known schemas from the services that own HTTP call sites and registers schema-backed routes for hardcoded/configurable URL families.
- Configurable/client-provided URLs should be registered through route-family helpers, e.g. `account(baseUrl)`, `models(baseUrl)`, `share(baseUrl)`, `skills(baseUrl)`, and `installation(registryUrl)`.
- Some production schemas are too broad for `Schema.toArbitrary()` today, such as provider catalog fields containing arbitrary mutable JSON. For those cases, the first-pass route can use a narrower generated schema whose values still decode under the production schema.
- Supported entry kinds for the first pass:
- `jsonSchema`: generate JSON from an Effect `Schema` via `toArbitrary()`.
- `text`: return deterministic text/html/markdown content for exact URLs.
- `bytes`: return deterministic binary content for exact URLs.
- `status`: return empty/status-only responses.
- `handler`: inspect method, URL, headers, and parsed body to build a custom response.
- `mcp`: handle JSON-RPC/SSE MCP protocol for a configured MCP server URL.
- `loopback`: allow local app/TUI traffic only.
- Prefer explicit registration at configuration/control boundaries over guessing from arbitrary URLs:
- Account/server URL registrations come from account/auth setup.
- MCP URL registrations come from `config.mcp`.
- Web fetch/search URLs come from the simulation control endpoint or generated tool action.
- Provider model responses come from the mock provider script registry, not generic provider SDK HTTP.
- Unknown non-loopback URLs fail with a typed simulation network error.
Layering caveat:
- Several `defaultLayer`s still provide `FetchHttpClient.layer` internally (`Account`, `ModelsDev`, `ToolRegistry`, `ShareNext`, `SkillDiscovery`, `Instruction`, `Installation`, `Ripgrep`, `Workspace`). A top-level `HttpClient.HttpClient` mock does not necessarily affect those self-contained default layers.
- First-pass startup wiring must either use non-default service layers and provide `SimulationNetwork.layer` once, or make these default layers explicitly mock-aware.
- The same caveat already exists for `AppFileSystem.defaultLayer` in some default layers, so the final simulation startup needs an explicit “normal app with narrow mock boundaries” layer assembly rather than blindly using all default layers.
Todos:
- [x] Locate all backend uses of `HttpClient.HttpClient`, raw `fetch`, provider SDK fetches, webfetch/websearch/share/update paths.
- [x] Classify first-pass network call families into schema-generated, text/bytes, MCP protocol, loopback, and denied.
- [x] Decide where `toArbitrary()` lives or which package exports it.
- [x] Define rough request matcher shape: exact URL, regex URL, or predicate.
- [x] Add method-aware matching and parsed request body support.
- [x] Define rough schema registration shape for generated responses.
- [x] Add schema-backed route helpers for hardcoded and configurable URL families.
- [ ] Define final schema registration shape for generated responses.
- [ ] Define MCP URL registration from `config.mcp.<name>.url` to an MCP protocol handler.
- [x] Implement rough seeded response generation with `Schema.toArbitrary()`.
- [x] Add loopback allowlist handling.
- [x] Add typed simulation error for unregistered non-loopback request.
- [ ] Verify sandbox also blocks external network if mock client is bypassed.
## Control Endpoint And Mock LLM Provider
Goal: tests control backend behavior through an endpoint, and the model follows endpoint-provided scripts through the real prompt/session pipeline.
Implementation shape:
- Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`.
- Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`.
- Keep the route inaccessible unless simulation mode is explicitly enabled.
- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving.
- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot.
- Register/configure a local mock provider/model through the normal provider path.
- The simulated route graph replaces `Provider.Service` with `SimulationProvider.layer`.
- The mock model reads queued scripts from simulation control state.
- Current mock provider supports text/thinking/error actions for the first step only. Tool calls and multi-round step selection are still pending.
- No JSON-in-prompt fallback.
- Missing script means typed simulation error.
Initial endpoints:
- `POST /experimental/simulation/reset`
- `POST /experimental/simulation/filesystem/seed`
- `POST /experimental/simulation/network/register`
- `POST /experimental/simulation/llm/enqueue`
- `GET /experimental/simulation/snapshot`
Initial LLM script:
```ts
type LLMScriptAction =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_call"; name: string; input: Record<string, unknown> }
| { type: "list_tools" }
| { type: "error"; message: string }
type LLMScript = {
steps: LLMScriptAction[][]
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
}
```
Keep the old useful rule: step `0` runs before tool results, step `N` runs after `N` tool-result rounds.
Todos:
- [x] Define simulation mode activation flag/env.
- [x] Add simulation control state and reset semantics.
- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot.
- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet.
- [x] Implement mock provider/model on the normal provider path.
- [x] Make missing scripts fail with a typed simulation error.
- [x] Record consumed script count in simulation snapshot.
- [ ] Support tool-call script actions.
- [ ] Support multi-step script selection after tool result rounds.
- [ ] Verify `session.prompt_async` exercises real `SessionPrompt` and `SessionProcessor`.
## OpenTUI Fake Renderer And Interactable Elements
Goal: run the TUI without a real terminal, inspect the screen buffer, and discover/act on interactable elements.
Known starting points:
- Current TUI creates a real renderer in `packages/opencode/src/cli/cmd/tui/app.tsx` through `createCliRenderer(...)`.
- Existing tests use `@opentui/solid` `testRender(...)`.
- Existing tests use `@opentui/core/testing` `createTestRenderer(...)` for renderer snapshots.
Implementation shape:
- Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer.
- Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`.
- `OPENCODE_SIMULATION_BACKEND` leaves the frontend real but makes backend route assembly use simulated services.
- Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner.
- In simulation MCP modes, the TUI side starts an MCP server documented in `simulation-mcp-server.md`.
- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`.
- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution.
- Do not render to a real terminal in simulation mode.
- Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements.
- Investigate OpenTUI APIs for reading the screen buffer from the fake renderer.
- If OpenTUI does not expose enough semantic information, add a small TUI semantic registry later. Do not block first pass on a full registry.
Todos:
- [x] Inspect `@opentui/core/testing` `createTestRenderer` capabilities.
- [x] Inspect `@opentui/solid` `testRender` capabilities.
- [x] Determine how to get a screen buffer string/snapshot from the fake renderer.
- [x] Determine first structured capture API for interactable discovery: `captureSpans()`.
- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers.
- [x] Add a minimal renderer factory override to `tui(...)` or app startup.
- [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness.
- [ ] Verify TUI starts in fake renderer with no real terminal output.
- [ ] Verify screen buffer can be captured after a render.
## Simulation MCP Server
Goal: let agents discover, inspect, and drive the simulated TUI through MCP without adding production remote-control behavior.
Design document:
- `packages/opencode/specs/simulation-mcp-server.md`
Implementation shape:
- Start in one of two modes: local stdio (`OPENCODE_SIMULATION=1`) or remote loopback HTTP (`OPENCODE_SIMULATION_BACKEND=1` without `OPENCODE_SIMULATION`).
- Live in the TUI/frontend process so it can access the OpenTUI renderer.
- Use stdio for the local agent-launched MCP mode.
- Bind remote streamable HTTP MCP servers to `127.0.0.1` on an ephemeral port.
- Print the URL to stdout for remote headless mode.
- Show the URL at the bottom of the home screen for remote visible TUI mode.
- Expose screen/spans/UI-state tools and resources.
- Execute UI driving through `SimulationActions.execute(...)`, not a second action path.
- Proxy filesystem/network/LLM/reset/snapshot operations to the backend simulation control endpoint.
Todos:
- [x] Add design doc and first-pass todo list.
- [x] Implement TUI-side MCP server startup and shutdown.
- [x] Add local stdio mode.
- [x] Add remote loopback mode.
- [x] Print remote headless URL to stdout.
- [x] Show remote visible TUI URL on the home screen.
- [x] Expose observation tools/resources.
- [x] Expose generated action execution tools.
- [x] Expose backend control proxy tools.
- [x] Add a smoke test that connects with the MCP client and calls one observation tool.
## Basic Action Generator
Goal: drive the TUI forward with generated actions and assert only that the app does not crash.
Implementation shape:
- Add a seeded action generator under `packages/opencode/test/property` or `packages/opencode/src/testing/simulation` depending on whether it needs production imports.
- Start with a tiny action set: submit prompt, key command, paste/type text, click/select visible interactable.
- Prefer OpenTUI/fake-renderer interactions over direct component refs where possible.
- Allow direct prompt ref use for the very first smoke path if OpenTUI interaction APIs are not ready.
- After each action, wait for basic quiescence.
- Built-in property is only `app.does-not-crash`.
Initial no-crash check:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Todos:
- [ ] Define `UIAction` union for the first pass.
- [ ] Implement seeded RNG for action selection.
- [ ] Generate ordinary prompt text and enqueue matching LLM scripts through the control endpoint.
- [ ] Execute actions through fake renderer/OpenTUI APIs where available.
- [ ] Add temporary prompt-ref execution path if needed for first smoke.
- [ ] Wait for quiescence after each action.
- [ ] Capture screen buffer and backend snapshot after each action.
- [ ] Check only `app.does-not-crash`.
- [ ] Persist a simple replay trace with seed, filesystem fixture, network registrations, LLM scripts, actions, and observations.
## First Milestone
The first milestone is one deterministic run that:
- Starts under `sandbox-exec`.
- Uses `OPENCODE_DB=:memory:`.
- Seeds the mock filesystem.
- Mounts the TUI using a fake renderer.
- Enqueues an LLM script through the control endpoint.
- Submits an ordinary prompt through the TUI.
- Receives a mocked model response through the real session pipeline.
- Captures a screen buffer.
- Starts the simulation MCP server in the selected transport mode.
- Passes the no-crash property.
## First-Pass Todos
- [x] Mock filesystem layer works.
- [ ] Mock FetchHttpClient works for registered schemas and fails unknown network. Rough static registry is implemented; schema generation remains.
- [ ] Control endpoint can seed filesystem, register network schemas, enqueue LLM scripts, and snapshot state.
- [ ] Mock provider/model consumes endpoint scripts through the real LLM path.
- [ ] TUI runs with fake renderer.
- [ ] Runner can inspect screen buffer.
- [x] Simulation MCP server exposes screen/UI/actions/control to agents.
- [ ] Runner can identify at least one interactable path to submit a prompt.
- [ ] Basic action generator executes multiple deterministic steps.
- [ ] No-crash property runs after each step.
- [ ] Replay trace is written outside the sandbox.
@@ -1,113 +0,0 @@
# Simulation MCP Server
Status: first-pass implementation plan.
The simulation MCP server gives agents a simulation-only control surface for the TUI. It lives in the TUI process because only the frontend has direct access to the OpenTUI renderer, captured screen buffer, focused editor, interactable elements, and input/mouse drivers.
## Goals
- Support local stdio mode for agents that launch opencode as an MCP server.
- Support remote loopback HTTP mode for users that want to run the server themselves.
- Expose current TUI state to agents: screen text, structured spans, interactable elements, focused editor, and generated actions.
- Let agents drive the UI through the same `SimulationActions` execution path used by property tests.
- Proxy backend simulation control operations through the existing `/experimental/simulation/*` endpoint.
## Non-Goals
- Do not start this server outside simulation modes.
- Do not add a general remote-control API to production TUI mode.
- Do not replace the property-test action generator; MCP should call the same action generator.
- Do not expose arbitrary host filesystem or network access.
## Location
- Server module: `packages/opencode/src/cli/cmd/tui/simulation-mcp.ts`.
- Startup: `packages/opencode/src/cli/cmd/tui/thread.ts`, next to fake renderer creation.
- Action/state source: `packages/opencode/src/testing/simulation/actions.ts`.
- Backend mutation source: existing simulation control endpoints.
## Modes
Local stdio mode:
- Enabled by `OPENCODE_SIMULATION=1`.
- Uses an OpenTUI test renderer and stdio MCP transport.
- Prints nothing to stdout except MCP protocol messages.
- Intended for agent MCP configs where the agent launches `opencode` as a local command.
Remote headless mode:
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set.
- Uses an OpenTUI test renderer and streamable HTTP MCP transport.
- Prints the running MCP URL to stdout once.
- Intended for users or harnesses that want to start opencode and connect to the URL manually.
Remote visible TUI mode:
- Enabled by `OPENCODE_SIMULATION_BACKEND=1` when `OPENCODE_SIMULATION` is not set and the user is running the TUI.
- Uses the normal visible TUI renderer and streamable HTTP MCP transport.
- Does not print the URL to stdout because stdout belongs to the TUI.
- Shows `Simulation mode MCP: <url>` at the bottom of the home screen.
## Transport
- Local stdio mode uses `StdioServerTransport`.
- Remote modes use streamable HTTP over loopback.
- Remote modes bind host `127.0.0.1`.
- Remote port is ephemeral by default and configurable through `OPENCODE_SIMULATION_MCP_PORT`.
## Initial Tools
Observation:
- `simulation_screen_get`: return the current captured character frame.
- `simulation_spans_get`: return OpenTUI captured spans.
- `simulation_ui_state_get`: return elements, available generated actions, and focus state.
Driving:
- `simulation_action_execute`: execute one generated action and render once.
- `simulation_action_sequence_execute`: execute a bounded sequence and return the final state.
- `simulation_render_once`: force one render and return the screen/state.
Backend control proxy:
- `simulation_control_reset`
- `simulation_control_filesystem_seed`
- `simulation_control_network_register`
- `simulation_control_llm_enqueue`
- `simulation_control_snapshot`
## Initial Resources
- `simulation://screen`
- `simulation://spans`
- `simulation://ui-state`
- `simulation://backend-snapshot`
## Initial Prompt
- `simulation-driver`: short instructions for agents to inspect state, choose available generated actions, drive the UI, then inspect again.
## Safety
- Guard startup with `OPENCODE_SIMULATION` or `OPENCODE_SIMULATION_BACKEND`.
- Bind to loopback only.
- Close the MCP server before destroying the renderer.
- Keep backend state changes routed through the existing simulation control endpoint.
## Todos
- [x] Add this design document.
- [x] Implement a first-pass TUI-side MCP server.
- [x] Support local stdio mode.
- [x] Support remote loopback mode.
- [x] Print remote headless URL to stdout.
- [x] Show remote background TUI URL on the home screen.
- [x] Expose screen/spans/UI-state observation tools.
- [x] Expose action execution tools using `SimulationActions.execute`.
- [x] Expose backend control proxy tools.
- [x] Add an automated smoke test that starts the MCP server and calls `tools/list` plus one observation tool.
- [ ] Add richer action generation with generated text and bounded sequence traces.
- [ ] Add trace capture for every MCP-driven action.
- [ ] Add protocol-level docs for external agent authors.
@@ -1,103 +0,0 @@
# 02 Semantic Discovery
Status: speculative. Refine before implementation.
This phase starts after the first-pass action generator can drive the TUI and assert that the app does not crash.
## Goal
Build a semantic map of TUI states, available actions, backend requests, and backend state changes. This lets later runs focus on workflows instead of random screen poking.
## UI Semantics
We need a way to know what the runner can interact with on the current screen.
Preferred order:
- Use OpenTUI render tree/fake renderer APIs if they expose interactable elements.
- Add a small TUI semantic registry only for missing metadata.
- Avoid large per-component instrumentation at first.
Potential semantic element shape:
```ts
type SemanticElement = {
id: string
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
label: string
enabled: boolean
visible: boolean
state?: Record<string, unknown>
bounds?: { x: number; y: number; width: number; height: number }
actions: SemanticAction[]
}
```
## Backend Mapping
Every generated UI action should have an action ID. TUI requests should include simulation headers so backend observations can be correlated.
Headers:
- `x-opencode-simulation-run`
- `x-opencode-simulation-action`
- `x-opencode-simulation-step`
Record requests and events with enough metadata to answer:
- Which UI actions produced which backend requests?
- Which backend domains changed?
- Which TUI states became reachable?
- Which generated path caused the crash if a crash happens?
Backend domains to consider later:
- `session`
- `message`
- `part`
- `permission`
- `question`
- `todo`
- `tool`
- `mcp`
- `filesystem`
- `network`
- `status`
## Graph Shape
The graph should abstract states rather than storing every concrete buffer.
```ts
type SemanticState = {
id: string
route: string
dialog?: string
elementSignature: string
backendSignature?: string
}
type SemanticTransition = {
id: string
from: string
to: string
action: UIAction
uiChanged: string[]
backendRequests: BackendRequestRecord[]
backendEvents: BackendEventRecord[]
failures: SimulationFailure[]
}
```
## Todos
- [ ] Reassess OpenTUI APIs after first-pass fake renderer work.
- [ ] Decide whether a TUI semantic registry is needed.
- [ ] Add action IDs to generated actions.
- [ ] Add action headers to TUI fetch wrapper.
- [ ] Record backend request spans.
- [ ] Record backend events and changed domains.
- [ ] Define normalized UI state signatures.
- [ ] Build first UI transition graph artifact.
- [ ] Build first backend endpoint/domain graph artifact.
- [ ] Use graph to bias action generation toward a selected workflow.
@@ -1,99 +0,0 @@
# 03 Properties And Replay
Status: speculative. Refine before implementation.
The first pass only checks that the app does not crash. Add more properties only after the basic runner and traces are stable.
## Property API
Properties should be ordinary TypeScript functions registered with the runner.
```ts
type Property = {
name: string
domains: string[]
check: (ctx: PropertyContext) => Promise<void>
}
```
The `domains` field lets the runner skip checks when unrelated state changed.
First pass property:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Later candidate properties:
- No non-loopback network call.
- Session eventually becomes idle after prompt-like actions.
- No pending tool call remains after idle.
- Every TUI-visible session message has valid message/part schemas.
- Permission/question overlays correspond to backend pending requests.
- Replay trace can be parsed and rerun.
- Text should not flicker across stable frames.
- Dialog focus should remain valid.
- Route state and visible route agree.
- Backend DB invariants hold after endpoint groups.
- Tool call lifecycle events are balanced.
- No generated action sequence can strand a session in busy state.
## Failure Reports
Failure reports should be human-readable and point to the smallest useful context.
Report fields:
- Failed property name.
- Seed and action index.
- Minimal replay command.
- Last N UI actions.
- Backend requests/events caused by the failing action.
- Visible TUI buffer before and after.
- Relevant session/message/tool IDs.
## Replay Trace
Trace fields:
- Seed and run configuration.
- Mock filesystem fixture and workspace/config path mapping.
- Mock network schema registrations.
- Simulation control calls.
- LLM scripts consumed.
- UI action sequence.
- HTTP request records.
- Backend events.
- UI observations before/after each action.
- Property checks and failure details.
- Normalization version.
## Shrinking
Shrinking should come after exact replay is reliable.
Candidate shrink steps:
- Delete contiguous chunks of actions.
- Reduce generated prompt text.
- Reduce LLM scripts to fewer actions/steps.
- Prefer semantic action shrinking over raw key shrinking.
- Preserve control calls needed to reproduce backend state.
## Todos
- [ ] Keep first pass to `app.does-not-crash` only.
- [ ] Define trace JSON schema after first runner exists.
- [ ] Write replay command that reruns an exact trace.
- [ ] Add readable failure report formatter.
- [ ] Add network property after mock network is stable.
- [ ] Add session/tool lifecycle properties after backend mapping is stable.
- [ ] Add shrinker only after replay is deterministic.
@@ -1,52 +0,0 @@
# 04 DST Hardening
Status: speculative. Refine before implementation.
This phase moves from seeded generation plus replay toward deterministic simulation testing. Do not start here; first get the app running under the first-pass simulation environment.
## Stage 1: Record And Normalize
- Seed RNG for the runner.
- Normalize timestamps and generated IDs in traces.
- Record timer registrations and delayed events where easy.
- Use quiescence waits instead of fake time.
## Stage 2: Deterministic Data Sources
- Add deterministic ID generation behind a narrow simulation mode if normalization becomes too noisy.
- Replace `Math.random()` usage in simulation-facing paths with seeded RNG.
- Keep provider, filesystem, and network deterministic through the first-pass simulation boundaries.
## Stage 3: Controlled Clock
- Move high-impact backend `Date.now()` call sites to Effect clock/time services where practical.
- Add a simulation clock service.
- Let the runner advance logical time.
## Stage 4: Controlled Timers And Event Loop
- Wrap TUI timer use through a scheduler service where practical.
- Expose SDK event batching timers to the harness.
- Let the runner advance timers as part of quiescence.
## Stage 5: Async Interleaving Exploration
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
- Replay exact interleavings from traces.
## Differential Runs
Later, reuse the useful idea from the old branch's differential runner:
- Run the same trace against two app versions or two configurations.
- Normalize volatile fields.
- Report semantic diffs instead of timestamp/ID noise.
## Todos
- [ ] Define which nondeterminism remains after first-pass replay.
- [ ] Decide whether deterministic IDs are needed or trace normalization is enough.
- [ ] Identify highest-impact `Date.now()` call sites.
- [ ] Design a minimal simulation clock only if needed.
- [ ] Design timer control only after fake renderer/action runner behavior is stable.
- [ ] Add differential runner after trace replay is reliable.
@@ -1,69 +0,0 @@
# 05 Reference Notes
Status: reference material. Keep this short and update as implementation discovers new seams.
## Current TUI Map
- `packages/opencode/src/cli/cmd/tui/thread.ts`: starts the TUI worker and in-process transport.
- `packages/opencode/src/cli/cmd/tui/app.tsx`: creates the OpenTUI renderer/keymap and renders the Solid app.
- `packages/opencode/src/cli/cmd/tui/context/sdk.tsx`: SDK client, custom fetch, event source, event batching.
- `packages/opencode/src/cli/cmd/tui/context/sync.tsx`: projects backend events into TUI state.
- `packages/opencode/src/cli/cmd/tui/context/route.tsx`: route state.
- `packages/opencode/src/cli/cmd/tui/context/prompt.tsx`: current prompt ref.
- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`: prompt input and submit path.
- `packages/opencode/src/cli/cmd/tui/keymap.tsx`: base keymap registration and `useBindings` exports.
- `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`: useful model for harness context exposure.
## Current Backend Map
- `packages/opencode/src/server/server.ts`: exposes `Server.Default().app.request(...)`.
- `packages/opencode/src/server/routes/instance/httpapi/server.ts`: route tree and production layers.
- `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`: prompt/prompt_async/session endpoints.
- `packages/opencode/src/session/prompt.ts`: real prompt loop, tool resolution, LLM orchestration.
- `packages/opencode/src/session/llm.ts`: provider language model seam and `streamText(...)` call.
- `packages/opencode/src/provider/provider.ts`: normal provider discovery/loading path.
- `packages/opencode/src/mcp/index.ts`: MCP network/process seam.
- `packages/opencode/src/tool/registry.ts`: built-in and plugin tool registry.
- `packages/opencode/src/storage/db.ts`: `OPENCODE_DB` and `:memory:` support.
- `packages/opencode/src/id/id.ts`: timestamp/random ID generation.
## Prior Branch Notes
Branch: `jlongster/fuzz-backend`.
Useful ideas to reuse:
- Mock AI SDK provider emitted real language-model stream chunks.
- Compact LLM script action format worked well.
- Step selection by counting tool-result rounds worked well.
- HTTP/SSE backend runner waited for `session.status` idle.
- Tool discovery and schema-shaped fake input generation were useful.
- TUI runner used internal prompt ref to submit scripted prompts.
- Differential runner normalized volatile fields and compared runs.
- SQLite was forced to `:memory:`.
- `sandbox-exec` denied external network and host filesystem access.
- Bun preload/plugin direction can catch imports that bypass service boundaries.
Things to avoid:
- No JSON-in-prompt protocol or fallback.
- No unseeded `Math.random()` in generated actions.
- No partial mock filesystem that silently falls back to host FS.
- No broad replacement of app service graph when a narrow override works.
## Old Sandbox Setup
Starting files on prior branch:
- `packages/opencode/src/provider/sdk/mock/sandbox.sb`
- `packages/opencode/src/provider/sdk/mock/run`
Important behavior:
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
- `(allow default)` so the process can boot.
- `(deny network*)` with localhost re-allowed.
- `(deny file-write*)`.
- Deny reads from `$HOME/.local` and `$HOME/.config`.
Adapt this setup into the new simulation runner layout rather than inventing a new sandbox policy first.
@@ -1,881 +0,0 @@
# Property-Based TUI And Backend Testing Plan
Status: rough architectural draft.
This document sketches an incremental path for property-based, deterministic-simulation-style testing of the opencode TUI against a real opencode backend, without hitting external network services.
## Goals
- Drive the TUI as the primary user surface.
- Exercise the real backend request, session, message, tool, permission, and event pipelines.
- Replace external effects with deterministic local services.
- Record enough information to replay failures.
- Build a semantic model of UI actions, backend requests, and state transitions over time.
- Start with a small useful runner, then grow toward deterministic simulation testing.
## Non-Goals For The First Pass
- Full fake-clock replacement for every `setTimeout`, `Date.now`, and animation path.
- Exhaustive exploration of every visual TUI state.
- Web app testing.
- Real external LLM/provider, MCP, webfetch, websearch, update, or share network calls.
## Current Code Map
### TUI
- TUI startup is centered in `packages/opencode/src/cli/cmd/tui/thread.ts` and `packages/opencode/src/cli/cmd/tui/app.tsx`.
- `TuiThreadCommand` starts a worker, builds an in-process fetch/event transport when possible, and calls `tui(...)`.
- `tui(...)` creates the OpenTUI `CliRenderer`, creates the keymap, and renders the Solid app.
- `SDKProvider` in `context/sdk.tsx` owns SDK creation, custom fetch injection, event subscription, event batching, retry, and timers.
- `SyncProvider` in `context/sync.tsx` projects backend events into TUI state: sessions, messages, parts, permissions, questions, todos, diffs, MCP, formatter, LSP, and VCS.
- `RouteProvider` in `context/route.tsx` owns route state.
- `PromptRefProvider` in `context/prompt.tsx` exposes the current prompt ref.
- The main prompt is `component/prompt/index.tsx`. It exposes `set`, `reset`, and `submit` through `PromptRef`, and its submit path eventually calls SDK session APIs.
- `keymap.tsx` centralizes base keymap registration and re-exports `useBindings`; app and prompt commands are registered through this layer.
- `plugin/api.tsx` already centralizes access to renderer, route, keymap, state, SDK client, dialog, KV, and event APIs. This is a useful model for a test harness API.
### Backend
- `packages/opencode/src/server/server.ts` exposes `Server.Default().app.request(...)`, which is useful for in-process HTTP tests.
- `packages/opencode/src/server/routes/instance/httpapi/server.ts` assembles all routes and provides production service layers.
- `createRoutes(...)` currently provides concrete production layers inside the route builder, including `Provider.defaultLayer`, `MCP.defaultLayer`, `ToolRegistry.defaultLayer`, `AppFileSystem.defaultLayer`, and `FetchHttpClient.layer`.
- `groups/session.ts` and `handlers/session.ts` define the important session HTTP surface: create, prompt, prompt_async, command, shell, abort, permission response, message reads, revert, and update paths.
- `SessionPrompt.Service` in `session/prompt.ts` creates user messages, resolves prompt parts, resolves tools, loops over LLM/tool calls, and writes messages/parts.
- `LLM.Service` in `session/llm.ts` is the main provider seam. It calls `Provider.Service.getLanguage(...)` and then `streamText(...)`.
- `Provider.Service` in `provider/provider.ts` can dynamically load provider SDKs and may install packages or use network. Simulation should use the normal provider path with a local mock provider/model and sandbox/network guards, not wholesale service replacement.
- `MCP.Service` in `mcp/index.ts` can open remote HTTP/SSE connections or local child processes. Simulation should keep normal app startup and disable/configure MCP by default; only add a narrow MCP control seam when a test needs MCP states.
- `ToolRegistry.Service` in `tool/registry.ts` exposes built-in and plugin tools. Filesystem tools should run against the mock filesystem in simulation mode; process/network tools must be disabled or replaced.
- `Database.Path` in `storage/db.ts` is controlled by `OPENCODE_DB` and supports `:memory:`. Tests already reset/close DB state.
- `Identifier` in `id/id.ts`, many `Date.now()` calls, and some `Math.random()` use are determinism hazards.
### Previous `jlongster/fuzz-backend` Branch
Useful ideas:
- A mock AI SDK provider emitted real language-model stream chunks.
- The old branch showed that a compact scripted action format works, but scripts must be supplied through simulation control APIs instead of user prompt text.
- Actions included `text`, `thinking`, `tool_call`, `list_tools`, and `error`.
- Step selection by counting tool-result rounds after the last user message was a good fit for model/tool loops.
- The runner drove the backend through HTTP plus SSE, waited for `session.status` to become idle, and then inspected messages.
- `/experimental/tool` discovery plus schema-based fake input generation was a useful generation seed.
- The TUI runner used an internal component to select the mock model, set prompt text through `PromptRef`, submit, and wait for idle.
- The differential runner normalized volatile fields and compared runs.
- The runner forced SQLite to `:memory:` so each run started with a clean in-process database.
- The runner used macOS `sandbox-exec` to deny external network and host filesystem access around the whole app process.
- The branch included a mock filesystem direction; the concept is correct and should be made complete enough for backend tools and app services instead of relying on real workspace files.
Ideas to avoid or rework:
- Do not hardcode the mock provider into normal provider discovery.
- Do not use unseeded `Math.random()`.
- Do not make the user-visible prompt text carry hidden control instructions at all.
- Do not implement a partial mock filesystem and assume all filesystem effects are covered; the backend mock filesystem must be a first-class simulation service with explicit unsupported-operation failures.
## Core Design Decision: Endpoint Control, Not Prompt Control
The primary harness should control backend behavior through a test-only simulation control endpoint or in-process control service. The TUI should then submit ordinary prompt text through the normal UI.
This is better than embedding control data in the prompt because:
- It keeps prompt contents realistic, so prompt UI behavior can be tested independently from backend scripting.
- It keeps transcripts and message history understandable.
- It works for non-prompt workflows like command palette actions, session summarization, permission flows, shell mode, model switching, and future MCP controls.
- It lets the runner prepare backend state before the next UI action.
- It gives us a natural place to force future backend state, such as MCP state, tool results, filesystem state, provider errors, and pending permission/question state.
- It makes replay traces explicit: `control.enqueueLLM(...)`, then `ui.submitPrompt(...)`.
There should be no JSON-in-prompt fallback. If no endpoint-enqueued script matches a model request, the mock LLM should fail with a clear simulation error. This keeps user-visible prompt text realistic and makes replay traces explicit.
## High-Level Architecture
The system has five layers:
1. Simulation backend services.
2. TUI driver and observation harness.
3. Semantic UI and backend graph builder.
4. Property runner, generator, replay, and shrinker.
5. Later DST controls for clock, timers, schedulers, and async ordering.
The initial runner loop should look like this:
```text
seed -> start isolated backend -> mount TUI -> observe state
repeat N times:
choose next UI action from current semantic state
optionally enqueue backend script/control data
execute the UI action
wait for quiescence
record UI/backend/network/event observations
run relevant properties
update semantic graph
on failure:
persist replay trace and human-readable report
```
## Simulation Backend Services
### Production App With Narrow Overrides
The runner should load the normal app by default. Avoid building a separate test route tree or installing a broad graph of mock services. The goal is to run production wiring and only override the few core effect boundaries that must be deterministic.
The first required override is `AppFileSystem.Service`, so backend-visible files come from the in-memory mock filesystem. Other overrides should be added only when the app cannot be controlled through configuration, the simulation control endpoint, or the sandbox policy.
Possible narrow shape:
```ts
// Conceptual API, not final names.
export function createRoutes(input?: {
cors?: CorsOptions
overrides?: {
appFileSystem?: Layer.Layer<AppFileSystem.Service>
}
}) {
return productionRoutesWithProductionServices(input)
}
```
The important part is not the exact type. The important part is that simulation mode should not need to re-provide provider, MCP, tool registry, network, or most backend services. It should load the whole app and make the smallest viable changes, starting with the filesystem boundary.
### Simulation Control State
Add simulation-only control state that owns deterministic run state. This is not a replacement for app services; it is the small state store used by control endpoints and the mock provider.
Proposed source location:
- `packages/opencode/src/testing/simulation/service.ts`
- `packages/opencode/src/testing/simulation/provider.ts`
- `packages/opencode/src/testing/simulation/filesystem.ts`
- `packages/opencode/src/testing/simulation/httpapi.ts`
- `packages/opencode/src/testing/simulation/network.ts`
- `packages/opencode/src/testing/simulation/runner.ts`
The service should be instance-scoped where possible and keyed by a `runID`.
Core responsibilities:
- Hold seeded RNG state.
- Hold queued LLM scripts.
- Hold mock filesystem state.
- Record UI action IDs, backend request IDs, events, tool calls, and state changes.
- Enforce network policy.
- Provide snapshots for replay/failure reports.
- Reset state between runs.
Conceptual control API:
```ts
type SimulationControl = {
reset(input: { runID: string; seed: string }): Effect.Effect<void>
enqueueLLM(input: { runID: string; match?: LLMScriptMatch; script: LLMScript }): Effect.Effect<void>
snapshot(input: { runID: string }): Effect.Effect<SimulationSnapshot>
recordAction(input: UIActionRecord): Effect.Effect<void>
recordRequest(input: BackendRequestRecord): Effect.Effect<void>
recordEvent(input: BackendEventRecord): Effect.Effect<void>
}
```
### Control Endpoint
Add a gated endpoint under the instance HTTP API, probably `/experimental/simulation/*`.
Suggested endpoints:
- `POST /experimental/simulation/reset`
- `POST /experimental/simulation/llm/enqueue`
- `GET /experimental/simulation/snapshot`
- `POST /experimental/simulation/action/start`
- `POST /experimental/simulation/action/end`
Access should be impossible in normal production use unless explicit simulation mode is enabled. If the endpoint is added to the typed HttpApi surface, regenerate the JS SDK with `./packages/sdk/js/script/build.ts`. The runner can also call the endpoint with raw fetch to avoid making this a public user-facing API.
### Mock LLM Provider
The main LLM mock should be a real provider/model path, not a replacement for `SessionPrompt` or a wholesale replacement of `Provider.Service`.
Preferred seam:
- Register or configure a local simulation provider/model through the normal provider system.
- Implement its language model with an AI SDK-compatible mock language model adapted to the current AI SDK version.
- Let the existing `LLM.Service` call `streamText(...)`, process tools, and emit normal stream events.
This preserves more of the real backend path than replacing `LLM.Service` or `Provider.Service` directly.
The mock model should read the next script from `Simulation.Service` using request context:
- `runID`
- `sessionID`
- `messageID` or last user message ID
- model/provider ID
- tool round number
If no endpoint-enqueued script matches, the mock model should fail with a typed simulation error that includes the run ID, session ID, model, and tool round. Silent default responses and prompt parsing would hide missing runner setup.
Script action schema:
```ts
type LLMScriptAction =
| { type: "text"; content: string }
| { type: "thinking"; content: string }
| { type: "tool_call"; name: string; input: Record<string, unknown> }
| { type: "list_tools" }
| { type: "error"; message: string }
type LLMScript = {
steps: LLMScriptAction[][]
usage?: { inputTokens: number; outputTokens: number; totalTokens: number }
finish?: "stop" | "tool-calls" | "error" | "length" | "unknown"
}
```
Keep the old rule that step `0` runs before tool results and step `N` runs after `N` tool-result rounds. The endpoint-backed service should also record which script step was consumed so replay reports are explicit.
### Database, Filesystem, Tools, MCP, And Network
Initial policy:
- Force `OPENCODE_DB=:memory:` for the simulation backend process. This should be a hard simulation-mode invariant, not a per-test preference.
- Run the app/backend under macOS `sandbox-exec` by default for local simulation runs, following the old branch's technique: deny external network, deny host filesystem writes, and only allow the minimum paths needed to boot the process and communicate over loopback/in-process transports.
- Add a first-class backend mock filesystem and provide it by overriding `AppFileSystem.Service` instead of relying on a real temp workspace for app-visible files.
- Use the normal provider system with a local simulation provider/model; do not replace `Provider.Service` unless a later implementation proves a small seam is unavoidable.
- Keep MCP on the normal app path and disable/configure it by default so it starts no network or child processes. Add narrow MCP controls later only for tests that explicitly target MCP states.
- Use sandbox/network guards to reject non-loopback network. Only override `HttpClient.HttpClient` if a minimal core override is needed to make failures typed and observable.
- Disable or fake webfetch, websearch, share, update, repo clone, and other external tools through normal config/tool policy where possible.
- Run read/glob/grep/write/edit against the mock filesystem, not the host filesystem.
- Treat bash/shell execution as opt-in and fake it by default, because real process execution bypasses the mock filesystem and sandbox policy is the last line of defense.
Later policy:
- Add deterministic fake child process and shell tools.
- Add deterministic fake LSP/file-watcher events.
The mock filesystem should be authoritative for backend-visible project files. The host filesystem should only be used for runner artifacts, bundled source/config needed to start the app, and sandbox-allowed runtime plumbing. Any app path that escapes the mock filesystem should fail with a typed simulation error so missing coverage is obvious.
### In-Memory Database
Simulation mode should set the database to memory globally for the backend process:
```text
OPENCODE_DB=:memory:
```
This must happen before any import path evaluates `storage/db.ts`, because `Database.Path` is computed at module load. The simulation bootstrap should own process startup so this cannot be missed. Each run should start from an empty DB and seed any required sessions/state through public services or simulation controls.
### Sandbox Isolation
The local runner should reuse the old branch's `sandbox-exec` setup on macOS as the starting implementation. Specifically, adapt `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/sandbox.sb` and `jlongster/fuzz-backend:packages/opencode/src/provider/sdk/mock/run` into the new simulation runner layout.
The old setup did the right first-order thing:
- `sandbox-exec -f ... -D HOME=$HOME bun --preload ... src/index.ts serve`
- Start from `(allow default)` so the process can boot.
- Deny all network with `(deny network*)`.
- Re-allow localhost network for local server/TUI communication.
- Deny all filesystem writes with `(deny file-write*)`.
- Deny reads from sensitive user config/state directories like `$HOME/.local` and `$HOME/.config`.
The sandbox is not the primary abstraction for deterministic behavior; it is the safety boundary that proves missed hooks cannot touch the host filesystem or external network.
Initial sandbox policy should stay close to the old branch:
- Deny outbound network except loopback when a real listener is used.
- Deny host filesystem writes inside the sandbox. The parent runner can write trace artifacts outside the sandbox after collecting them over stdout, HTTP, or another explicit control channel.
- Deny reads from user config/state locations unless explicitly mounted as test fixtures.
- Fail fast when a denied operation happens so the trace records a simulation escape.
The mock filesystem and network guards should still exist inside the app. `sandbox-exec` catches leaks; it should not be the mechanism that normal simulated I/O depends on.
### Backend Mock Filesystem
The mock filesystem should be implemented as a real simulation service, not as test fixture files on disk.
Responsibilities:
- Store files, directories, symlinks if needed, executable bits if needed, mtimes, and binary/text content in memory.
- Provide deterministic path resolution for workspace root, current directory, home, config, state, and temp paths.
- Expose operations needed by `AppFileSystem.Service`, read/write/edit tools, glob/grep/ripgrep equivalents, config reads, snapshot/diff, and prompt file attachment resolution.
- Emit deterministic file change/snapshot events when writes happen.
- Make unsupported operations fail explicitly with typed simulation errors.
- Support seeding initial file trees from JSON fixtures and serializing filesystem state into replay traces.
Implementation should prefer a narrow `AppFileSystem.Service` override first. If file/ripgrep services or filesystem tools bypass that boundary, make the smallest targeted change to route them through the mock filesystem rather than replacing the whole tool registry. If some backend paths still import `@/util/filesystem` or direct host filesystem APIs, use the same Bun preload/plugin technique from the old branch to redirect those imports in simulation mode. The sandbox should then catch any remaining direct `fs`, `Bun.file`, or child-process access that was not routed through the mock filesystem.
### Backend Quiescence
The first useful quiescence definition should be pragmatic:
- No session has `session.status.type === "busy"`.
- The TUI sync queue has flushed.
- The runner has seen all events produced by the current action.
- The renderer has completed at least one frame after the last event.
- No pending simulation-controlled LLM stream or tool call remains.
This is not full DST yet. It is enough to avoid racing the next action against obvious async work.
## TUI Driver And Observation Harness
### Harness Injection
Do not add another hardcoded environment-runner component like the old `Mock` component. Instead, extend `tui(...)`/`App` with an optional test harness hook.
Conceptual shape:
```ts
export function tui(input: {
url: string
args: Args
config: TuiConfig.Resolved
fetch?: typeof fetch
events?: EventSource
testing?: TuiHarness.Input
})
```
The `App` can mount a tiny `TuiHarnessProbe` only when `testing` is provided. The probe exposes the same kinds of context that `plugin/api.tsx` already gathers:
- route
- keymap
- prompt ref
- sync state
- SDK client
- renderer
- dialog state
- KV state
- event bus
- local model/agent state
This gives tests a stable internal API without coupling to a specific visible component.
### Driver Modes
Start with two modes:
- Semantic in-process mode: uses context APIs, keymap commands, prompt refs, SDK fetch wrappers, and renderer snapshots. This is the main property runner.
- Terminal/PTY mode: later, spawn the real binary in a PTY, inject bytes, and read terminal snapshots. This catches lower-level terminal regressions but is slower.
The semantic mode should still exercise OpenTUI rendering, Solid state, keymap registration, SDK calls, backend routes, SSE/events, and prompt submit flows.
### Action Types
Initial action types:
```ts
type UIAction =
| { type: "command"; command: string }
| { type: "prompt.set"; text: string }
| { type: "prompt.submit"; text?: string; llm?: LLMScript }
| { type: "key"; key: string; modifiers?: string[] }
| { type: "paste"; text: string }
| { type: "click"; elementID: string }
| { type: "wait"; condition: "idle" | "frame"; timeoutMs: number }
```
The runner should prefer semantic commands first. Raw key and mouse actions are useful, but command-level actions are easier to shrink and replay.
### Observation Types
Every action should record before/after observations:
```ts
type UIObservation = {
route: unknown
dialogDepth: number
focusedElement?: string
semanticElements: SemanticElement[]
bufferHash?: string
visibleText?: string
syncSummary: SyncSummary
errors: SimulationError[]
}
```
Initial `visibleText` can come from renderer/test-render snapshots where available. Later PTY mode should capture the terminal buffer directly.
### TUI State Changers To Track
Frontend state can change because of:
- Keyboard events.
- Mouse events.
- Paste and IME submit deferrals.
- Terminal resize and theme detection.
- SDK HTTP responses.
- SDK event stream messages.
- Timers used for batching, focus, prompt submit, animations, retry, and placeholders.
- Local KV, prompt history, prompt stash, model recents/favorites.
- Plugin registration, routes, slots, commands, events, and toasts.
- Clipboard/selection flows.
- Process signals and terminal suspend/resume.
The first runner does not need full control over all of these. It should record them when they occur and gradually move high-impact sources under simulation control.
## Semantic UI Graph
### Semantic Registry
Add a TUI semantic registry that components can use to announce interactive elements and available actions.
Conceptual element shape:
```ts
type SemanticElement = {
id: string
role: "prompt" | "command" | "dialog" | "dialog-option" | "permission" | "question" | "message" | "route"
label: string
enabled: boolean
visible: boolean
state?: Record<string, unknown>
bounds?: { x: number; y: number; width: number; height: number }
actions: SemanticAction[]
}
```
First components to instrument:
- `Prompt` for text input, submit, shell mode, slash commands, file/agent attachments.
- App commands registered in `app.tsx`.
- Prompt commands registered in `component/prompt/index.tsx`.
- `DialogSelect` for option movement/filter/select.
- Permission and question overlays.
- Route state in `RouteProvider`.
- Session message parts, especially tool and error parts.
This should be additive metadata. It should not change rendering behavior.
### Graph Shape
The graph should abstract states instead of storing every concrete UI snapshot.
```ts
type SemanticState = {
id: string
route: string
dialog?: string
elementSignature: string
backendSignature?: string
}
type SemanticTransition = {
id: string
from: string
to: string
action: UIAction
uiChanged: string[]
backendRequests: BackendRequestRecord[]
backendEvents: BackendEventRecord[]
coverage: string[]
failures: SimulationFailure[]
}
```
State hashing should initially normalize volatile IDs/timestamps. As deterministic IDs/clocks land, less normalization will be needed.
### Discovery Pass
The discovery runner randomly chooses from currently available semantic actions, executes them, observes transitions, and writes a graph artifact.
Suggested output:
- `.opencode/simulation/ui-graph.json`
- `.opencode/simulation/backend-graph.json`
- `.opencode/simulation/runs/<runID>.jsonl`
The graph is not expected to be perfect. It should answer practical questions:
- What actions are available from each abstract UI state?
- Which actions produce backend requests?
- Which actions open dialogs, create sessions, request permissions, create tool parts, or show errors?
- Which action sequences reach the prompt, session, permission, question, model selection, MCP, and session list states?
### Directed Runner
After discovery, the directed runner should use the graph to bias generation toward requested targets.
Examples:
- "Focus prompt submit with tool calls."
- "Exercise permission approve/reject flows."
- "Exercise session list and route changes."
- "Exercise backend prompt_async and SessionPrompt loop states."
The directed runner can plan a route through the graph to a target state, then run generated variants from there.
## Mapping UI Actions To Backend Requests
Every generated action should have an `actionID`.
The TUI fetch wrapper should add headers:
- `x-opencode-simulation-run`
- `x-opencode-simulation-action`
- `x-opencode-simulation-step`
The backend should record request spans:
```ts
type BackendRequestRecord = {
runID: string
actionID?: string
requestID: string
method: string
path: string
endpoint?: string
status: number
startedAt: number
endedAt: number
}
```
Async work needs explicit correlation. For example, `prompt_async` returns before the session run finishes. The handler should attach the current `actionID` to the created user message/session run in `Simulation.Service`, so later LLM/tool/session events can be attributed to the same UI action.
Backend events should also be recorded:
```ts
type BackendEventRecord = {
runID: string
actionID?: string
eventID: string
type: string
sessionID?: string
messageID?: string
domains: string[]
}
```
This gives the graph the important edge information: UI action -> HTTP request -> backend state/event changes -> TUI sync changes.
## Backend Semantic Analysis
Backend semantic analysis should start from cheap instrumentation:
- HTTP endpoint entry/exit.
- Bus/SyncEvent publications.
- Session status changes.
- Message and part writes.
- Permission and question asks/replies.
- Tool start/finish/error.
- LLM stream start/finish/error.
The first backend state domains:
- `session`
- `message`
- `part`
- `permission`
- `question`
- `todo`
- `tool`
- `mcp`
- `filesystem`
- `network`
- `status`
Later, add DB snapshots or table-level hashes for deeper invariants. Do not read and diff the whole database after every action until we know it is needed.
## Property API
Properties should be ordinary TypeScript functions registered with the runner.
Conceptual API:
```ts
type Property = {
name: string
domains: string[]
check: (ctx: PropertyContext) => Promise<void>
}
```
`domains` lets the runner skip checks when unrelated state changed.
Example properties:
```ts
property({
name: "app.does-not-crash",
domains: ["tui", "backend"],
async check(ctx) {
ctx.expect(ctx.tui.errors).toEqual([])
ctx.expect(ctx.backend.errors).toEqual([])
},
})
```
Built-in properties for pass one:
- The app does not crash. This includes uncaught TUI render errors and unhandled backend failures caused by the generated action.
Later properties:
- No non-loopback network call.
- Session eventually becomes idle after prompt-like actions.
- No pending tool call remains after idle.
- Every TUI-visible session message has valid message/part schemas.
- Permission/question overlays correspond to backend pending requests.
- Replay trace can be parsed and rerun.
- Text should not flicker across stable frames.
- Dialog focus should remain valid.
- Route state and visible route agree.
- Backend DB invariants hold after every endpoint group.
- Tool call lifecycle events are balanced.
- No generated action sequence can strand a session in busy state.
## Failure Reports And Replay
On failure, persist a trace and a concise report.
Trace should include:
- Seed and run configuration.
- Initial mock filesystem fixture and workspace/config path mapping.
- Simulation control calls.
- UI action sequence.
- LLM scripts consumed.
- HTTP request records.
- Backend events.
- UI observations before/after each action.
- Property checks and failure details.
- Normalization version.
Human report should include:
- Failed property name.
- Seed and action index.
- Minimal replay command.
- The last N UI actions.
- The backend requests/events caused by the failing action.
- Visible TUI text/buffer before and after.
- Any session/message/tool IDs relevant to the failure.
Initial replay can simply rerun the exact trace. Shrinking can come later.
Shrinking plan:
- Delete contiguous chunks of actions.
- Reduce generated prompt text.
- Reduce LLM scripts to fewer actions/steps.
- Prefer semantic action shrinking over raw key shrinking.
- Preserve explicit control calls needed to reproduce backend state.
## DST Roadmap
Full deterministic simulation testing requires more than seeded random actions. It requires control over time and async scheduling. Build this gradually.
### Stage 1: Record And Normalize
- Seed RNG for the runner.
- Normalize timestamps and generated IDs in traces.
- Record timer registrations and delayed events where easy.
- Use quiescence waits instead of fake time.
### Stage 2: Deterministic Data Sources
- Add deterministic ID generation behind an injectable service or simulation mode.
- Replace `Math.random()` usage in TUI placeholders/tests with seeded RNG in simulation mode.
- Replace provider, MCP, network, and unsafe tools with deterministic services.
### Stage 3: Controlled Clock
- Move high-impact backend `Date.now()` call sites to Effect clock/time services.
- Add a simulation clock service.
- Let the runner advance logical time.
### Stage 4: Controlled Timers And Event Loop
- Wrap TUI timer use through a scheduler service where practical.
- Expose SDK event batching timers to the harness.
- Let the runner advance timers as part of quiescence.
### Stage 5: Async Interleaving Exploration
- Randomize or systematically vary ordering of queued events, LLM chunks, tool completions, and sync flushes.
- Replay exact interleavings from traces.
## Implementation Passes
### Pass 1: Backend-Only Deterministic Prompt Runner
Deliverables:
- Local mock LLM provider/model registered through the normal provider path.
- Simulation control state and raw or typed control endpoint.
- Sandboxed backend runner that loads the normal app and applies only narrow core overrides, starting with `AppFileSystem.Service`.
- Process bootstrap that forces `OPENCODE_DB=:memory:` before backend modules load.
- Initial backend mock filesystem service with seeded fixture support.
- macOS `sandbox-exec` runner wrapper that denies external network and host filesystem access.
- Seeded generation of LLM scripts based on available tools.
- Replay trace for backend-only prompt runs.
Scope:
- Create session.
- Seed mock filesystem contents.
- Enqueue LLM script.
- Call `prompt_async`.
- Wait for idle over events.
- Assert basic backend properties.
Validation:
- Run 10 to 100 generated backend prompt cases without external network.
- Prove filesystem reads/writes hit the mock filesystem and not the host filesystem.
- Prove tool-call, text, reasoning, and error scripts hit the real `SessionPrompt` and `SessionProcessor` path.
### Pass 2: TUI Prompt Smoke Runner
Deliverables:
- Optional `testing` hook in `tui(...)`/`App`.
- In-process TUI harness exposing prompt ref, route, sync, keymap, SDK, and renderer.
- Fetch/event wrappers that add simulation action headers and record requests/events.
- A runner action that enqueues LLM script, sets prompt text through TUI, submits, waits for idle, and checks no crash.
Scope:
- Prompt input and submit only.
- Normal text and one tool-call script.
- Real backend, fake external services.
Validation:
- Run TUI -> prompt -> backend -> LLM script -> tool/result -> TUI message display.
- Persist and replay a trace.
### Pass 3: Semantic UI Registry
Deliverables:
- `TuiSemanticProvider` and registry API.
- Instrument prompt, app commands, prompt commands, route state, dialog select, permission, and question components.
- Snapshot current semantic elements from the harness.
- Random semantic action generator.
Scope:
- Commands, prompt text/submit, dialog option select, permission approve/reject, question answer/reject.
Validation:
- Generate random semantic actions for a fixed number of steps.
- Build a small UI transition graph.
- Replay any generated sequence.
### Pass 4: Directed Property Runner
Deliverables:
- Property registration API.
- Domain-based property filtering.
- Built-in no-crash/no-network/session-idle/tool-lifecycle properties.
- Directed generation targets based on semantic graph.
Scope:
- User asks for a focus area and iteration/depth count.
- Runner biases actions toward graph paths related to that area.
Validation:
- `prompt submit with tool calls` target produces many prompt/tool/session variants.
- `permission flows` target reaches permission UI and exercises approve/reject.
### Pass 5: Backend Graph And Endpoint Mapping
Deliverables:
- Request and event correlation by action ID.
- Backend domain change records.
- Endpoint/action graph export.
- Basic backend state signatures.
Scope:
- Session, message, part, permission, question, todo, tool, and status domains.
Validation:
- Given a UI action, report which backend endpoints and state domains changed.
- Given a backend endpoint/domain, report UI actions that reached it.
### Pass 6: Determinism Hardening
Deliverables:
- Seeded RNG everywhere in the runner.
- Deterministic ID/time mode for high-impact backend paths.
- Timer registration recording.
- More complete network/process guards.
- Complete backend mock filesystem coverage for configured filesystem tools and app services.
Scope:
- Reduce trace normalization.
- Make failures replay reliably across machines.
Validation:
- Same seed and trace produce same observations modulo approved volatile fields.
### Pass 7: Shrinking And Differential Runs
Deliverables:
- Action sequence shrinker.
- Prompt/script shrinker.
- Dual-run differential runner similar in spirit to the old branch.
- Stable normalization rules for diff output.
Scope:
- Compare current branch against baseline or two configurations.
Validation:
- Induced failure shrinks to a short reproducible action trace.
- Differential runner reports meaningful semantic diffs, not timestamp/ID noise.
## Suggested Initial File Layout
```text
packages/opencode/src/testing/simulation/service.ts
packages/opencode/src/testing/simulation/provider.ts
packages/opencode/src/testing/simulation/filesystem.ts
packages/opencode/src/testing/simulation/httpapi.ts
packages/opencode/src/testing/simulation/network.ts
packages/opencode/src/testing/simulation/mcp.ts
packages/opencode/src/testing/simulation/tool-registry.ts
packages/opencode/src/testing/simulation/sandbox.sb
packages/opencode/src/testing/simulation/run.ts
packages/opencode/src/cli/cmd/tui/testing/harness.tsx
packages/opencode/src/cli/cmd/tui/testing/semantic.tsx
packages/opencode/test/property/backend-runner.test.ts
packages/opencode/test/property/tui-runner.test.ts
packages/opencode/test/property/properties.ts
packages/opencode/test/property/generator.ts
```
If we want the harness code completely out of production bundles, keep more of it under `test/property`. The server route, TUI optional hook, and any simulation-gated services that the app imports need to live under `src`.
## Open Questions To Resolve During Implementation
- Should the simulation endpoint be a typed HttpApi route that regenerates SDK, or an internal raw route used only by the runner?
- Should the first mock model target the current AI SDK provider interface directly, or temporarily fake `LLM.Service` while the provider mock is adapted?
- How much renderer tree metadata does OpenTUI expose for stable bounds and visible text snapshots?
- Which tools should be enabled by default in generation: read/glob/grep/todo only, or write/edit against the mock filesystem too?
- Where should trace artifacts live by default so they are easy to inspect but not accidentally committed?
## Recommended Starting Point
Start with Pass 1 and Pass 2.
The smallest useful end-to-end test is:
1. Start an isolated backend under `sandbox-exec` with `OPENCODE_DB=:memory:`, simulation provider, fake MCP, guarded network, and seeded mock filesystem.
2. Mount the TUI with a harness hook and in-process fetch/event transport.
3. Enqueue an LLM script through simulation control.
4. Set the prompt to ordinary text like `hello` and submit through `PromptRef`.
5. Wait for session idle and TUI sync.
6. Assert no TUI/backend errors and that the expected assistant text/tool part appears.
7. Persist a replay trace containing the seed, control call, UI action, requests, events, and observations.
This gives immediate value while leaving room for semantic graph discovery, directed properties, failure shrinking, and true DST controls later.
+11 -11
View File
@@ -65,24 +65,24 @@ export type ActiveOrg = {
org: Org
}
export class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
export const DurationFromSeconds = Schema.Number.pipe(
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
export class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
export class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
@@ -90,14 +90,14 @@ export class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
interval: DurationFromSeconds,
}) {}
export class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
export class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
@@ -110,22 +110,22 @@ export class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceToke
}
}
export const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
export class User extends Schema.Class<User>("User")({
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
export class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
export class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
export class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
+29 -21
View File
@@ -37,8 +37,16 @@ export interface Interface {
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
// acquired in the caller's Scope at `yield*` time. Any publish after the
// yield is delivered, even if stream consumption starts later. The previous
// Stream-returning shape acquired the subscription lazily on first pull,
// opening a race window during which publishes were lost — see
// test/bus/bus-effect.test.ts RACE tests.
readonly subscribe: <D extends BusEvent.Definition>(
def: D,
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
@@ -109,26 +117,26 @@ export const layer = Layer.effect(
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
const subscribe = <D extends BusEvent.Definition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
return Stream.fromSubscription(subscription)
})
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
+1 -1
View File
@@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
+1 -1
View File
@@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@opencode-ai/core/models"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
+30
View File
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
type: "boolean",
describe: "show thinking blocks",
})
.option("replay", {
type: "boolean",
default: false,
describe: "replay visible session history on interactive resume",
})
.option("replay-limit", {
type: "number",
describe: "cap visible interactive replay to the newest N messages",
})
.option("interactive", {
alias: ["i"],
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
die("--interactive cannot be used with --format json")
}
if (args.replay && !args.interactive) {
die("--replay requires --interactive")
}
if (args["replay-limit"] !== undefined && !args.interactive) {
die("--replay-limit requires --interactive")
}
if (
args["replay-limit"] !== undefined &&
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
) {
die("--replay-limit must be a positive integer")
}
if (args.interactive && !process.stdout.isTTY) {
die("--interactive requires a TTY stdout")
}
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
}
}
const replay = args.replay || args["replay-limit"] !== undefined
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const directory = (() => {
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
replay,
replayLimit: args["replay-limit"],
agent,
model,
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
agent: args.agent,
model,
variant: args.variant,
replay,
replayLimit: args["replay-limit"],
files,
initialInput,
thinking,
@@ -7,7 +7,7 @@
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { useKeyboard, useRenderer } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
onContentChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
const renderer = useRenderer()
let area: TextareaRenderable | undefined
let pasteTick: ReturnType<typeof setTimeout> | undefined
const refreshPasteLayout = () => {
if (pasteTick) {
clearTimeout(pasteTick)
}
pasteTick = setTimeout(() => {
pasteTick = undefined
if (!area || area.isDestroyed) {
return
}
// Paste can leave the textarea layout stale until the next edit.
area.getLayoutNode().markDirty()
renderer.requestRender()
void renderer
.idle()
.then(() => {
if (!area || area.isDestroyed) {
return
}
props.onContentChange()
})
.catch(() => {})
}, 0)
}
onMount(() => {
props.bind(area)
})
onCleanup(() => {
if (pasteTick) {
clearTimeout(pasteTick)
}
props.bind(undefined)
})
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -67,6 +69,8 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
footer,
trace: log,
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
@@ -0,0 +1,188 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import type { FooterPatch, StreamCommit } from "./types"
type ReplayInput = {
messages: SessionMessages
permissions: PermissionRequest[]
questions: QuestionRequest[]
thinking: boolean
limits: Record<string, number>
}
export type SessionReplay = {
data: SessionData
commits: StreamCommit[]
patch?: FooterPatch
}
type ReplayMessage = {
commits: StreamCommit[]
patch?: FooterPatch
}
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
return reduceSessionData({
data,
event,
sessionID,
thinking,
limits,
})
}
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
if (!left) {
return right
}
if (!right) {
return left
}
return {
...left,
...right,
}
}
function active(data: SessionData) {
return data.part.size > 0 || data.tools.size > 0
}
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
if (active(data)) {
if (!patch) {
return {
phase: "running",
} satisfies FooterPatch
}
return {
...patch,
phase: "running",
} satisfies FooterPatch
}
if (data.permissions.length > 0 || data.questions.length > 0) {
if (!patch) {
return {
phase: "idle",
} satisfies FooterPatch
}
return {
...patch,
phase: "idle",
} satisfies FooterPatch
}
if (!patch) {
return undefined
}
return {
...patch,
phase: "idle",
status: "",
} satisfies FooterPatch
}
function replayMessage(
data: SessionData,
message: SessionMessages[number],
thinking: boolean,
limits: Record<string, number>,
): ReplayMessage {
if (message.info.role === "user") {
const prompt = messagePrompt(message)
if (!prompt.text.trim()) {
return {
commits: [],
}
}
return {
commits: [
{
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: message.info.id,
},
],
}
}
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
const info = apply(
data,
{
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: message.info.sessionID,
info: message.info,
},
},
message.info.sessionID,
thinking,
limits,
)
commits.push(...info.commits)
patch = mergePatch(patch, info.footer?.patch)
for (const part of message.parts) {
const next = apply(
data,
{
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: part.sessionID,
part,
time: 0,
},
},
message.info.sessionID,
thinking,
limits,
)
patch = mergePatch(patch, next.footer?.patch)
commits.push(...next.commits)
}
return {
commits,
patch,
}
}
export function replaySession(input: ReplayInput): SessionReplay {
const data = createSessionData()
const commits: StreamCommit[] = []
let patch: FooterPatch | undefined
bootstrapSessionData({
data,
messages: input.messages,
permissions: input.permissions,
questions: input.questions,
})
for (const message of input.messages) {
const next = replayMessage(data, message, input.thinking, input.limits)
commits.push(...next.commits)
patch = mergePatch(patch, next.patch)
}
return {
data,
commits,
patch: replayPatch(data, patch),
}
}
@@ -60,7 +60,7 @@ function fileSource(
}
}
function prompt(msg: SessionMessages[number]): RunPrompt {
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
const parts: RunPrompt["parts"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
}
return {
prompt: prompt(msg),
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
@@ -27,6 +27,7 @@ import {
reduceSessionData,
type SessionData,
} from "./session-data"
import { replaySession } from "./session-replay"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
@@ -66,6 +67,8 @@ type StreamInput = {
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
footer: FooterApi
trace?: Trace
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
blockerTick: 0,
blockers: new Map(),
}
let booting = true
const buffered: Event[] = []
const replayedParts = new Set<string>()
const recovering = new Set<string>()
const tracked = (sessionID: string | undefined) =>
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
@@ -550,11 +558,11 @@ function createLayer(input: StreamInput) {
}
})
const messages = (sessionID: string, limit: number) =>
const messages = (sessionID: string, limit?: number) =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID,
limit,
...(typeof limit === "number" ? { limit } : {}),
}),
).pipe(
Effect.map((item) => item.data ?? []),
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
[
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
messages(
input.sessionID,
input.replay
? input.replayLimit === undefined
? undefined
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT)
: SUBAGENT_BOOTSTRAP_LIMIT,
),
Effect.promise(() =>
input.sdk.session.children({
sessionID: input.sessionID,
@@ -619,12 +634,52 @@ function createLayer(input: StreamInput) {
},
)
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
questions: questions.filter((item) => item.sessionID === input.sessionID),
})
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
const history = input.replay
? replaySession({
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: undefined
const replay =
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
? replaySession({
messages: messagesList.slice(-input.replayLimit),
permissions: sessionPermissions,
questions: sessionQuestions,
thinking: input.thinking,
limits: input.limits(),
})
: history
replayedParts.clear()
if (history) {
state.data = history.data
}
if (!history) {
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
})
}
if (replay) {
for (const [partID] of replay.data.text) {
if (!replay.data.part.has(partID)) {
continue
}
replayedParts.add(partID)
}
}
bootstrapSubagentData({
data: state.subagent,
messages: messagesList,
@@ -632,6 +687,7 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -642,9 +698,29 @@ function createLayer(input: StreamInput) {
seedBlocker(request.id)
}
if (replay) {
const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools])
for (const commit of replay.commits) {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
if (commit.partID && activeCommitIDs.has(commit.partID)) {
continue
}
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
}
const snapshot = currentSubagentState()
traceTabs(input.trace, [], snapshot.tabs)
syncFooter([], undefined, snapshot)
syncFooter([], replay?.patch, snapshot)
if (replay) {
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
}
booting = false
yield* drainBuffered()
const sessions = [...state.subagent.tabs.keys()]
if (sessions.length === 0) {
@@ -738,6 +814,86 @@ function createLayer(input: StreamInput) {
})
}
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
if (replayedParts.has(event.properties.partID)) {
const seen = state.data.text.get(event.properties.partID) ?? ""
if (seen.endsWith(event.properties.delta)) {
return
}
replayedParts.delete(event.properties.partID)
}
}
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
})
const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () {
let pending = buffered.splice(0)
while (pending.length > 0) {
const next: Event[] = []
let changed = false
for (const event of pending) {
if (!tracked(sid(event))) {
next.push(event)
continue
}
changed = true
yield* applyEvent(event)
}
if (!changed) {
buffered.push(...next)
return
}
pending = next
}
})
const watch = Effect.fn("RunStreamTransport.watch")(() =>
Stream.fromAsyncIterable(events.stream, (error) =>
error instanceof Error ? error : new Error(String(error)),
@@ -762,53 +918,25 @@ function createLayer(input: StreamInput) {
}
const sessionID = sid(event)
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
if (booting) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
if (!tracked(sessionID)) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
}
return
}
input.trace?.write("recv.event", event)
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
yield* applyEvent(event)
yield* drainBuffered()
}),
),
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
@@ -823,8 +951,8 @@ function createLayer(input: StreamInput) {
),
)
yield* bootstrap()
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
yield* bootstrap()
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
if (closed || next.signal?.aborted || input.footer.isClosed) {
@@ -420,9 +420,26 @@ function ensureBlockerTab(
title: string | undefined,
kind: "permission" | "question",
) {
if (data.tabs.has(sessionID)) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
return false
if (current.status !== "running") {
return false
}
const next = {
...current,
description: kind === "permission" ? "Pending permission" : "Pending question",
status: "running" as const,
title: current.title ?? title,
lastUpdatedAt: Date.now(),
}
if (sameSubagentTab(current, next)) {
return false
}
data.tabs.set(sessionID, next)
return true
}
data.tabs.set(sessionID, {
@@ -52,6 +52,8 @@ export type RunInput = {
sessionID: string
sessionTitle?: string
resume?: boolean
replay?: boolean
replayLimit?: number
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
+5 -34
View File
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as TuiAudio from "@tui/util/audio"
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
@@ -68,7 +68,6 @@ import { createTuiAttention } from "@/cli/cmd/tui/attention"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
import { DiffViewer } from "./routes/diff"
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
@@ -103,7 +102,6 @@ const appBindingCommands = [
"theme.switch",
"theme.switch_mode",
"theme.mode.lock",
"diff.open",
"help.show",
"docs.open",
"app.debug",
@@ -167,10 +165,6 @@ export function tui(input: {
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
renderer?: CliRenderer
mode?: "dark" | "light"
onReady?: (ctx: { renderer: CliRenderer }) => void | Promise<void | { simulationMcpUrl?: string }>
onStop?: (stop: () => Promise<void>) => void
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
@@ -188,11 +182,10 @@ export function tui(input: {
TuiAudio.dispose()
}
const renderer = input.renderer ?? (await createCliRenderer(rendererConfig(input.config)))
const [simulationMcpUrl, setSimulationMcpUrl] = createSignal<string | undefined>()
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = input.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
@@ -205,7 +198,7 @@ export function tui(input: {
)}
>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args} simulationMcpUrl={simulationMcpUrl}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
@@ -239,7 +232,7 @@ export function tui(input: {
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AppLifecycle onSnapshot={input.onSnapshot} onStop={input.onStop} />
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
@@ -263,17 +256,9 @@ export function tui(input: {
</ErrorBoundary>
)
}, renderer)
const ready = await input.onReady?.({ renderer })
if (ready?.simulationMcpUrl) setSimulationMcpUrl(ready.simulationMcpUrl)
})
}
function AppLifecycle(props: { onSnapshot?: () => Promise<string[]>; onStop?: (stop: () => Promise<void>) => void }) {
const exit = useExit()
props.onStop?.(() => exit())
return <App onSnapshot={props.onSnapshot} />
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig()
const route = useRoute()
@@ -354,7 +339,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [diffOpen, setDiffOpen] = createSignal(false)
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
@@ -666,16 +650,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
name: "diff.open",
title: "Open diff viewer",
slashName: "diff",
run: () => {
setDiffOpen(true)
dialog.clear()
},
category: "VCS",
},
{
name: "help.show",
title: "Help",
@@ -975,9 +949,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
<TuiPluginRuntime.Slot name="app_bottom" />
</box>
<TuiPluginRuntime.Slot name="app" />
<Show when={diffOpen()}>
<DiffViewer onClose={() => setDiffOpen(false)} />
</Show>
</Show>
<StartupLoading ready={ready} />
</box>
@@ -1,12 +1,14 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const project = useProject()
const [store] = createStore({
filter: "",
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
async () => {
const result = await sdk.client.find.files({
query: store.filter,
workspace: project.workspace.current(),
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
@@ -6,6 +6,7 @@ import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -19,7 +20,7 @@ import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { Reference } from "@/reference/reference"
import type { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
function removeLineRange(input: string) {
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const project = useProject()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
@@ -310,7 +312,7 @@ export function Autocomplete(props: {
`Referenced configured reference @${reference.name}.`,
...(reference.kind === "local" ? ["Kind: local directory"] : []),
...(reference.kind === "git" ? ["Kind: git repository"] : []),
...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
@@ -324,7 +326,7 @@ export function Autocomplete(props: {
const references = createMemo(() =>
Reference.resolveAll({
references: (sync.data.config.reference ?? {}) as NonNullable<Config.Info["reference"]>,
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
directory: sync.path.directory || process.cwd(),
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
}),
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
// Get files from SDK
const result = await sdk.client.find.files({
query: baseQuery,
workspace: project.workspace.current(),
})
const options: AutocompleteOption[] = []
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { assign, expandPastedTextPlaceholders } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
id: PartID.ascending(),
}
}
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
return parts.reduce((result, part) => {
if (part.type !== "text" || !part.source?.text) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
@@ -67,7 +67,6 @@ export const Definitions = {
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
diff_open: keybind("<leader>d", "Open diff viewer"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
@@ -189,6 +188,7 @@ export const Definitions = {
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
@@ -252,7 +252,6 @@ export const CommandMap = {
sidebar_toggle: "session.sidebar.toggle",
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
diff_open: "diff.open",
session_export: "session.export",
session_copy: "session.copy",
session_new: "session.new",
@@ -4,7 +4,6 @@ export interface Args {
model?: string
agent?: string
prompt?: string
simulationMcpUrl?: () => string | undefined
continue?: boolean
sessionID?: string
fork?: boolean
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: theme.background,
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Show
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Switch>
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -114,6 +114,7 @@ type RuntimeState = {
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, ConfigPlugin.Origin>
dispose_timeout_ms: number
}
const log = Log.create({ service: "tui.plugin" })
@@ -394,7 +395,7 @@ async function syncPluginThemes(plugin: PluginEntry) {
}
}
function createPluginScope(load: PluginLoad, id: string) {
function createPluginScope(load: PluginLoad, id: string, disposeTimeoutMs: number) {
const ctrl = new AbortController()
let list: { key: symbol; fn: TuiDispose }[] = []
let done = false
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
ctrl.abort()
const queue = [...list].reverse()
list = []
const until = Date.now() + DISPOSE_TIMEOUT_MS
const until = Date.now() + disposeTimeoutMs
for (const item of queue) {
const left = until - Date.now()
if (left <= 0) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (persist) writePluginEnabledState(state.api, plugin.id, true)
if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id)
const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) {
export async function init(input: {
api: HostPluginApi
config: TuiConfig.Resolved
dispose?: () => void
disposeTimeoutMs?: number
}) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -1052,7 +1058,7 @@ export async function dispose() {
state.dispose?.()
}
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) {
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
}
runtime = next
try {
@@ -1,251 +0,0 @@
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useTheme } from "@tui/context/theme"
import { parsePatch } from "diff"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { useBindings } from "../keymap"
type DiffFile = {
readonly file: string
readonly patch: string
readonly additions: number
readonly deletions: number
readonly status: "added" | "deleted" | "modified"
}
const stripPrefix = (file: string | undefined) => {
if (!file || file === "/dev/null") return undefined
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
}
const splitRawDiff = (text: string) => {
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
if (starts.length === 0) return text.trim() ? [text] : []
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
}
const parseRawDiff = (text: string): DiffFile[] => {
const chunks = splitRawDiff(text)
return chunks.flatMap((chunk) => {
const parsed = parsePatch(chunk)[0]
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
if (!parsed || !file) return []
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
(acc, line) => ({
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
}),
{ additions: 0, deletions: 0 },
)
return [
{
file,
patch: chunk,
additions: counts.additions,
deletions: counts.deletions,
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
} satisfies DiffFile,
]
})
}
const lineKind = (line: string) => {
if (line.startsWith("+")) return "added"
if (line.startsWith("-")) return "deleted"
if (line.startsWith("@@")) return "hunk"
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
return "context"
}
export function DiffViewer(props: { onClose: () => void }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sdk = useSDK()
const [selected, setSelected] = createSignal(0)
const [raw] = createResource(async () => {
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
return result.data ?? ""
})
const files = createMemo(() => parseRawDiff(raw() ?? ""))
const current = createMemo(() => files()[selected()])
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
const move = (delta: number) => {
const total = files().length
if (total === 0) return
setSelected((selected() + delta + total) % total)
}
createEffect(() => {
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
})
useBindings(() => ({
priority: 2000,
bindings: [
{
key: "up",
desc: "Previous file",
group: "Diff",
cmd: () => move(-1),
},
{
key: "k",
desc: "Previous file",
group: "Diff",
cmd: () => move(-1),
},
{
key: "down",
desc: "Next file",
group: "Diff",
cmd: () => move(1),
},
{
key: "j",
desc: "Next file",
group: "Diff",
cmd: () => move(1),
},
{
key: "escape",
desc: "Close diff viewer",
group: "Diff",
cmd: props.onClose,
},
{
key: "q",
desc: "Close diff viewer",
group: "Diff",
cmd: props.onClose,
},
],
}))
return (
<box
position="absolute"
zIndex={2500}
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
gap={1}
>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>Diff</text>
<text fg={theme.textMuted}>working tree</text>
</box>
<text fg={theme.textMuted}>j/k select · q/esc close</text>
</box>
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
<box
width={32}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
border={["left", "right"]}
borderColor={theme.border}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
>
<text fg={theme.textMuted}>Files</text>
<Switch>
<Match when={raw.loading}>
<text fg={theme.textMuted}>Loading diff...</text>
</Match>
<Match when={raw.error}>
<text fg={theme.error}>Failed to load diff</text>
</Match>
<Match when={files().length === 0}>
<text fg={theme.text}>No changes</text>
</Match>
<Match when={files().length > 0}>
<For each={files()}>
{(file, index) => (
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "" : " "}</text>
<text fg={theme.text} wrapMode="none">
{file.file}
</text>
<text fg={theme.diffAdded}>+{file.additions}</text>
<text fg={theme.diffRemoved}>-{file.deletions}</text>
</box>
)}
</For>
</Match>
</Switch>
</box>
<box
flexGrow={1}
minWidth={0}
backgroundColor={theme.backgroundPanel}
border={["left", "right"]}
borderColor={theme.borderActive}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
gap={1}
>
<Show
when={current()}
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
>
{(file) => (
<>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>{file().file}</text>
<text fg={theme.textMuted}>{file().status}</text>
<text fg={theme.diffAdded}>+{file().additions}</text>
<text fg={theme.diffRemoved}>-{file().deletions}</text>
</box>
<scrollbox flexGrow={1} minHeight={0}>
<For each={lines()}>
{(line) => {
const kind = lineKind(line)
return (
<text
fg={
kind === "added"
? theme.diffAdded
: kind === "deleted"
? theme.diffRemoved
: kind === "hunk"
? theme.accent
: kind === "meta"
? theme.textMuted
: theme.text
}
wrapMode="none"
>
{line || " "}
</text>
)
}}
</For>
</scrollbox>
</>
)}
</Show>
</box>
</box>
</box>
)
}
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal, onMount, Show } from "solid-js"
import { createEffect, createSignal, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync"
@@ -88,10 +88,7 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show when={args.simulationMcpUrl?.()}>
{(url) => <text fg="yellow">Simulation mode MCP: {url()}</text>}
</Show>
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
</>
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1,325 +0,0 @@
import { cmd } from "@/cli/cmd/cmd"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Filesystem } from "@/util/filesystem"
import { Rpc } from "@/util/rpc"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import {
OPENCODE_PROCESS_ROLE,
OPENCODE_RUN_ID,
ensureRunID,
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import type { SimulationMcpRuntimeState } from "./simulation-mcp"
import type { rpc } from "./worker"
import { SimulationDebugLog } from "../../../testing/simulation/debug-log"
import { SimulationNetworkLog } from "./simulation-network-log"
import { fileURLToPath } from "url"
import { writeHeapSnapshot } from "v8"
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
const simulatedDirectory = "/opencode"
const simulatedCwdEnv = "OPENCODE_SIMULATION_CWD"
function fakeCwd(directory: string) {
process.env.PWD = directory
Object.defineProperty(process, "cwd", {
value: () => directory,
configurable: true,
})
}
interface Transport {
readonly url: string
readonly fetch: typeof fetch
readonly events: EventSource
}
interface RunningInstance {
readonly client: RpcClient
readonly done: Promise<void>
readonly stopTui: () => Promise<void>
readonly stopWorker: () => Promise<void>
}
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const headers = Object.fromEntries(request.headers.entries())
SimulationDebugLog.write("simulate.fetch.start", { method: request.method, url: request.url })
const startedAt = Date.now()
const startedAtIso = new Date(startedAt).toISOString()
try {
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers,
body,
})
SimulationDebugLog.write("simulate.fetch.end", {
method: request.method,
url: request.url,
status: result.status,
})
SimulationNetworkLog.record({
time: startedAtIso,
method: request.method,
url: request.url,
status: result.status,
durationMs: Date.now() - startedAt,
requestHeaders: headers,
requestBody: body,
responseHeaders: result.headers,
responseBody: result.body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
} catch (err) {
SimulationNetworkLog.record({
time: startedAtIso,
method: request.method,
url: request.url,
status: 0,
durationMs: Date.now() - startedAt,
requestHeaders: headers,
requestBody: body,
responseHeaders: {},
responseBody: "",
error: err instanceof Error ? err.message : String(err),
})
throw err
}
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient, onSubscribe?: () => void): EventSource {
return {
subscribe: async (handler) => {
// SimulationDebugLog.write("simulate.events.subscribe")
onSubscribe?.()
return client.on<GlobalEvent>("global.event", (e) => {
// SimulationDebugLog.write("simulate.events.received", {
// directory: e.directory,
// workspace: e.workspace,
// type: e.payload?.type,
// sync: e.payload?.type === "sync",
// })
handler(e)
})
},
}
}
async function target() {
const workerPath = Reflect.get(globalThis, "OPENCODE_WORKER_PATH")
if (typeof workerPath === "string") return workerPath
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("./worker.ts", import.meta.url)
}
export const SimulateCommand = cmd({
command: "simulate",
describe: "start restartable simulated opencode tui",
handler: async () => {
SimulationDebugLog.reset()
fakeCwd(simulatedDirectory)
const file = await target()
const cwd = simulatedDirectory
const config = await TuiConfig.get()
const simulationMcpMode = Flag.OPENCODE_SIMULATION ? "stdio" : "remote"
let currentInstance: RunningInstance | undefined
let currentRuntime: SimulationMcpRuntimeState | undefined
let restartRequested = false
let restartWaiter:
| {
readonly promise: Promise<{ restarted: true }>
readonly resolve: (value: { restarted: true }) => void
readonly reject: (error: unknown) => void
}
| undefined
const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) })
}
const reload = () => {
currentInstance?.client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
})
})
}
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
process.on("SIGUSR2", reload)
const simulationMcpModule = await import("./simulation-mcp")
const simulationMcp = await simulationMcpModule.TuiSimulationMcp.createSimulationMcpServer({
mode: simulationMcpMode,
runtime: {
current: () => currentRuntime,
restart: async () => {
if (restartWaiter) return restartWaiter.promise
if (!currentInstance) throw new Error("Simulation TUI is not ready")
restartRequested = true
let resolve!: (value: { restarted: true }) => void
let reject!: (error: unknown) => void
const promise = new Promise<{ restarted: true }>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
restartWaiter = { promise, resolve, reject }
currentInstance.stopTui().catch((err) => restartWaiter?.reject(err))
return promise
},
},
})
const stopWorker = async (client: RpcClient, worker: Worker) => {
await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => {
Log.Default.warn("worker shutdown failed", {
error: errorMessage(err),
})
})
worker.terminate()
}
const start = async (): Promise<RunningInstance> => {
const worker = new Worker(file, {
env: sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
PWD: simulatedDirectory,
[simulatedCwdEnv]: simulatedDirectory,
// Simulated filesystem lives in InMemoryFs — SQLite needs to be in-memory
// too, since real fs writes to `/opencode/.local/share/opencode/...` will
// fail (the directory only exists in the sim FS).
OPENCODE_DB: ":memory:",
}),
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
}
const client = Rpc.client<typeof rpc>(worker)
let eventSubscribedResolve!: () => void
const eventSubscribed = new Promise<void>((resolve) => {
eventSubscribedResolve = resolve
})
const transport: Transport = {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client, eventSubscribedResolve),
}
const { tui } = await import("./app")
const simulationRenderer = Flag.OPENCODE_SIMULATION
? await (await import("./simulation")).TuiSimulation.createSimulationRenderer()
: undefined
let stopTui = async () => {}
let stopReadyResolve!: () => void
const stopReady = new Promise<void>((resolve) => {
stopReadyResolve = resolve
})
let readyResolve!: () => void
let readyReject!: (error: unknown) => void
const ready = new Promise<void>((resolve, reject) => {
readyResolve = resolve
readyReject = reject
})
const done = tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
renderer: simulationRenderer?.renderer,
mode: simulationRenderer ? "dark" : undefined,
onStop: (stop) => {
stopTui = stop
stopReadyResolve()
},
onReady: async (ctx) => {
try {
currentRuntime = {
harness: simulationRenderer
? simulationMcpModule.TuiSimulationMcp.harnessFromSimulationRenderer(simulationRenderer)
: simulationMcpModule.TuiSimulationMcp.harnessFromRenderer(ctx.renderer),
controlUrl: transport.url,
controlFetch: transport.fetch,
}
if (simulationRenderer) await simulationRenderer.renderOnce()
readyResolve()
return { simulationMcpUrl: simulationMcp.url }
} catch (err) {
readyReject(err)
throw err
}
},
args: {},
})
await Promise.all([ready, stopReady, eventSubscribed])
return {
client,
done,
stopTui: () => stopTui(),
stopWorker: async () => {
simulationRenderer?.destroy()
await stopWorker(client, worker)
},
}
}
try {
while (true) {
try {
currentInstance = await start()
restartWaiter?.resolve({ restarted: true })
restartWaiter = undefined
restartRequested = false
} catch (err) {
restartWaiter?.reject(err)
throw err
}
await currentInstance.done
currentRuntime = undefined
await currentInstance.stopWorker()
currentInstance = undefined
if (!restartRequested) break
}
} finally {
currentRuntime = undefined
await currentInstance?.stopTui().catch(() => {})
await currentInstance?.stopWorker()
await simulationMcp.stop()
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload)
}
},
})
export * as TuiSimulate from "./simulate"
@@ -1,891 +0,0 @@
import { SimulationActions } from "@/testing/simulation/actions"
import { SimulationNetworkLog } from "./simulation-network-log"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import type { CapturedFrame, CliRenderer } from "@opentui/core"
import { createMockKeys, createMockMouse } from "@opentui/core/testing"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import z from "zod/v4"
import type { SimulationRenderer } from "./simulation"
export type SimulationMcpMode = "stdio" | "remote"
export interface SimulationMcpHarness {
readonly renderer: CliRenderer
readonly mockInput: SimulationActions.MockInput
readonly mockMouse: SimulationActions.MockMouse
readonly renderOnce: () => Promise<void>
readonly screen: () => string
}
export interface SimulationMcpOptions {
readonly mode: SimulationMcpMode
readonly harness: SimulationMcpHarness
readonly controlUrl: string
readonly controlFetch?: typeof fetch
}
export interface SimulationMcpRuntimeState {
readonly harness: SimulationMcpHarness
readonly controlUrl: string
readonly controlFetch?: typeof fetch
}
export interface RestartableSimulationMcpOptions {
readonly mode: SimulationMcpMode
readonly runtime: {
readonly current: () => SimulationMcpRuntimeState | undefined
readonly restart: () => Promise<unknown>
}
}
type Options = SimulationMcpOptions | RestartableSimulationMcpOptions
export interface SimulationMcpServer {
readonly mode: SimulationMcpMode
readonly url?: string
readonly stop: () => Promise<void>
}
const DefaultRemotePort = 43110
const MaxPortAttempts = 100
const MasterInstanceID = "master"
interface RemoteInstance {
readonly id: string
readonly port: number
readonly url: string
}
interface JsonRpcResponse {
readonly result?: unknown
readonly error?: {
readonly code?: number
readonly message?: string
}
}
type RenderBuffer = {
readonly width: number
readonly height: number
getRealCharBytes(includeAnsi?: boolean): Uint8Array
}
const decoder = new TextDecoder()
const ActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("typeText"), text: z.string() }),
z.object({
type: z.literal("pressKey"),
key: z.string(),
modifiers: z
.object({
ctrl: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional(),
super: z.boolean().optional(),
hyper: z.boolean().optional(),
})
.optional(),
}),
z.object({ type: z.literal("pressEnter") }),
z.object({ type: z.literal("pressArrow"), direction: z.enum(["up", "down", "left", "right"]) }),
z.object({ type: z.literal("focus"), target: z.number() }),
z.object({ type: z.literal("click"), target: z.number(), x: z.number(), y: z.number() }),
]) satisfies z.ZodType<SimulationActions.Action>
const FileContentSchema = z.union([
z.string(),
z.object({ encoding: z.literal("base64"), data: z.string() }),
])
const NetworkRegistrationSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("json"),
url: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.unknown(),
}),
z.object({
kind: z.literal("text"),
url: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.string(),
}),
z.object({
kind: z.literal("status"),
url: z.string(),
method: z.string().optional(),
status: z.number(),
headers: z.record(z.string(), z.string()).optional(),
}),
])
const LlmScriptActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), content: z.string() }),
z.object({ type: z.literal("thinking"), content: z.string() }),
z.object({ type: z.literal("error"), message: z.string() }),
z.object({
type: z.literal("tool-call"),
toolCallId: z.string(),
toolName: z.string(),
input: z.any(),
}),
])
const LlmScriptSchema = z.object({
steps: z.array(z.array(LlmScriptActionSchema)),
usage: z
.object({
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
})
.optional(),
finish: z.enum(["stop", "tool-calls", "error", "length", "unknown"]).optional(),
})
const ScriptActionSchema = z.union([
ActionSchema,
z.object({ type: z.literal("writeFile"), path: z.string(), content: FileContentSchema }),
z.object({ type: z.literal("enqueueLLM"), scripts: z.array(LlmScriptSchema) }),
z.object({ type: z.literal("wait"), ms: z.number().min(0).max(30_000).optional() }),
])
const ScriptSchema = z.union([z.array(ScriptActionSchema), z.object({ actions: z.array(ScriptActionSchema) })])
const TargetSchema = z.union([z.string(), z.array(z.string()).min(1), z.literal("all")])
const BackendFetchSchema = z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.unknown().optional(),
})
const remoteInstances = new Map<string, RemoteInstance>()
function currentBuffer(renderer: CliRenderer): RenderBuffer {
return Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer
}
function remotePort() {
const port = Number(process.env.OPENCODE_SIMULATION_MCP_PORT)
if (Number.isInteger(port) && port > 0 && port <= 65535) return port
return DefaultRemotePort
}
function masterEnabled() {
return process.env.OPENCODE_SIMULATION_MCP_MASTER === "1" || process.env.OPENCODE_SIMULATION_MCP_MASTER === "true"
}
function childURL(port: number) {
return `http://127.0.0.1:${port}/mcp`
}
function jsonRpcError(response: JsonRpcResponse) {
return response.error?.message ?? `MCP request failed${response.error?.code === undefined ? "" : `: ${response.error.code}`}`
}
function isPortUnavailable(error: unknown) {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
}
function serveRemote(
fetch: (request: Request) => Response | Promise<Response>,
port = remotePort(),
attempts = MaxPortAttempts,
): ReturnType<typeof Bun.serve> {
try {
return Bun.serve({ hostname: "127.0.0.1", port, idleTimeout: 0, fetch })
} catch (error) {
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
return serveRemote(fetch, port + 1, attempts - 1)
}
}
export function harnessFromSimulationRenderer(renderer: SimulationRenderer): SimulationMcpHarness {
return renderer
}
export function harnessFromRenderer(renderer: CliRenderer): SimulationMcpHarness {
return {
renderer,
mockInput: createMockKeys(renderer),
mockMouse: createMockMouse(renderer),
renderOnce: async () => {
renderer.requestRender()
await renderer.idle()
},
screen: () => decoder.decode(currentBuffer(renderer).getRealCharBytes(true)),
}
}
function toolResult(value: unknown) {
return {
content: [
{
type: "text" as const,
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
},
],
}
}
function current(options: Options) {
if ("runtime" in options) {
const value = options.runtime.current()
if (value) return value
throw new Error("Simulation TUI is not ready")
}
return options
}
function state(options: Options) {
const running = current(options)
return {
focused: {
renderable: running.harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(running.harness.renderer.currentFocusedEditor),
},
elements: SimulationActions.elements(running.harness.renderer),
actions: SimulationActions.actions(running.harness.renderer),
}
}
function snapshot(options: Options) {
const running = current(options)
return {
screen: running.harness.screen(),
ui: state(options),
}
}
async function control(options: Options, method: string, pathname: string, body?: unknown) {
const running = current(options)
const response = await (running.controlFetch ?? fetch)(new URL(pathname, running.controlUrl), {
method,
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
const data = text ? JSON.parse(text) : undefined
if (response.ok) return data
throw new Error(typeof data?.error === "string" ? data.error : `Simulation control request failed: ${response.status}`)
}
async function backendFetch(options: Options, input: z.infer<typeof BackendFetchSchema>) {
const running = current(options)
const headers = new Headers(input.headers)
const body =
input.body === undefined ? undefined : typeof input.body === "string" ? input.body : JSON.stringify(input.body)
if (body !== undefined && !headers.has("content-type") && typeof input.body !== "string") headers.set("content-type", "application/json")
const response = await (running.controlFetch ?? fetch)(new URL(input.url, running.controlUrl), {
method: input.method ?? (body === undefined ? "GET" : "POST"),
headers,
body,
})
return {
url: response.url,
status: response.status,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
body: await response.text(),
}
}
async function mcpRequest(url: string, method: string, params: unknown, timeout = 500) {
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id: crypto.randomUUID(), method, params }),
signal: AbortSignal.timeout(timeout),
})
if (!response.ok) throw new Error(`MCP request failed: HTTP ${response.status}`)
const data = (await response.json()) as JsonRpcResponse
if (data.error) throw new Error(jsonRpcError(data))
return data.result
}
async function initializeChild(url: string, timeout?: number) {
await mcpRequest(
url,
"initialize",
{
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "opencode-simulation-master", version: InstallationVersion },
},
timeout,
)
await mcpRequest(url, "notifications/initialized", {}, timeout).catch(() => undefined)
}
async function childToolCall(instance: RemoteInstance, name: string, args: unknown) {
await initializeChild(instance.url, 2_000)
return mcpRequest(instance.url, "tools/call", { name, arguments: args }, 30_000)
}
function instances() {
return [{ id: MasterInstanceID, port: remotePort(), url: childURL(remotePort()) }, ...remoteInstances.values()]
}
function selectedTargets(target: z.infer<typeof TargetSchema>) {
const ids = target === "all" ? instances().map((item) => item.id) : Array.isArray(target) ? target : [target]
return ids.map((id) => {
if (id === MasterInstanceID) return { id, local: true as const }
const remote = remoteInstances.get(id)
if (!remote) throw new Error(`Unknown simulation instance: ${id}`)
return { id, local: false as const, remote }
})
}
async function discoverInstances(input: { startPort?: number; maxPorts?: number; consecutiveFailures?: number } = {}) {
const startPort = input.startPort ?? remotePort() + 1
const maxPorts = input.maxPorts ?? 30
const consecutiveFailures = input.consecutiveFailures ?? 3
let failures = 0
const found: RemoteInstance[] = []
for (let offset = 0; offset < maxPorts && failures < consecutiveFailures; offset++) {
const port = startPort + offset
if (port === remotePort()) continue
const instance = { id: `simulation-${port}`, port, url: childURL(port) }
try {
await initializeChild(instance.url)
await childToolCall(instance, "simulation_control_snapshot", {})
remoteInstances.set(instance.id, instance)
found.push(instance)
failures = 0
} catch {
failures++
remoteInstances.delete(instance.id)
}
}
return { instances: instances(), discovered: found, scanned: { startPort, maxPorts, consecutiveFailures } }
}
type ScriptAction = z.infer<typeof ScriptActionSchema>
type ScriptCounts = { uiActions: number; fileWrites: number; llmScriptsQueued: number; waits: number }
async function executeAction(options: Options, action: ScriptAction, counts: ScriptCounts) {
if (action.type === "writeFile") {
await control(options, "POST", "/experimental/simulation/filesystem/write", {
path: action.path,
content: action.content,
})
counts.fileWrites++
return
}
if (action.type === "enqueueLLM") {
await control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: action.scripts })
counts.llmScriptsQueued += action.scripts.length
return
}
if (action.type === "wait") {
await new Promise((resolve) => setTimeout(resolve, action.ms ?? 1_000))
await current(options).harness.renderOnce()
counts.waits++
return
}
await SimulationActions.execute(current(options).harness, action)
counts.uiActions++
}
async function runScript(options: Options, file: string) {
const parsed = ScriptSchema.parse(await Bun.file(file).json())
const actions = Array.isArray(parsed) ? parsed : parsed.actions
const counts: ScriptCounts = { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 }
for (const action of actions) await executeAction(options, action, counts)
return { file, actions: actions.length, ...counts, snapshot: snapshot(options) }
}
// ─── Step-controlled script execution ───────────────────────────────────────
//
// `simulation_script_load` parses a script and stores its actions in process
// memory keyed by a generated id. The script is NOT executed yet. Subsequent
// calls to `simulation_script_step` advance the cursor by one or more actions
// at a time, returning the snapshot and updated counts after each batch. This
// lets MCP clients drive scripts at their own pace and inspect state between
// steps. Only one script is active at a time per process; loading a new one
// while a previous one is still pending requires either consuming it to
// completion, calling `simulation_script_cancel`, or specifying replace=true.
interface LoadedScript {
readonly id: string
readonly file: string | null
readonly actions: ScriptAction[]
cursor: number
readonly counts: ScriptCounts
readonly loadedAt: string
}
let loaded: LoadedScript | undefined
let loadSeq = 0
function loadedSummary(state: LoadedScript) {
return {
id: state.id,
file: state.file,
total: state.actions.length,
cursor: state.cursor,
remaining: state.actions.length - state.cursor,
done: state.cursor >= state.actions.length,
counts: { ...state.counts },
loadedAt: state.loadedAt,
}
}
async function loadScript(input: {
file?: string
script?: unknown
replace?: boolean
}) {
if (loaded && loaded.cursor < loaded.actions.length && !input.replace) {
throw new Error(
`A script is already loaded (id=${loaded.id}, ${loaded.actions.length - loaded.cursor} actions remaining). Pass replace=true or call simulation_script_cancel first.`,
)
}
const raw = input.file
? await Bun.file(input.file).json()
: (input.script ?? (() => {
throw new Error("simulation_script_load requires either `file` or `script`.")
})())
const parsed = ScriptSchema.parse(raw)
const actions = Array.isArray(parsed) ? parsed : parsed.actions
loaded = {
id: `script-${(++loadSeq).toString(36)}`,
file: input.file ?? null,
actions: [...actions] as ScriptAction[],
cursor: 0,
counts: { uiActions: 0, fileWrites: 0, llmScriptsQueued: 0, waits: 0 },
loadedAt: new Date().toISOString(),
}
return loadedSummary(loaded)
}
async function stepScript(options: Options, input: { steps?: number; renderEach?: boolean }) {
if (!loaded) throw new Error("No script loaded. Call simulation_script_load first.")
const max = input.steps ?? 1
const executed: ScriptAction[] = []
for (let i = 0; i < max && loaded.cursor < loaded.actions.length; i++) {
const action = loaded.actions[loaded.cursor]!
executed.push(action)
await executeAction(options, action, loaded.counts)
loaded.cursor++
if (input.renderEach && i < max - 1) await current(options).harness.renderOnce()
}
return {
executed,
state: loadedSummary(loaded),
snapshot: snapshot(options),
}
}
function cancelScript() {
const was = loaded ? loadedSummary(loaded) : null
loaded = undefined
return { cancelled: was !== null, was }
}
function statusScript() {
return loaded ? loadedSummary(loaded) : null
}
async function runOnTargets<A>(
options: Options,
target: z.infer<typeof TargetSchema>,
local: () => Promise<A>,
remote: (instance: RemoteInstance) => Promise<unknown>,
) {
const output = []
for (const item of selectedTargets(target)) {
output.push({ id: item.id, result: item.local ? await local() : await remote(item.remote) })
}
return { results: output, snapshot: snapshot(options) }
}
function createServer(options: Options) {
const server = new McpServer(
{ name: "opencode-simulation", version: InstallationVersion },
{
instructions:
"Use simulation_ui_state_get before acting. Prefer generated actions and execute them with simulation_action_execute. Inspect state after each action. Use control tools to seed filesystem, network, and LLM state.",
},
)
server.registerResource("screen", "simulation://screen", { mimeType: "text/plain" }, () => ({
contents: [{ uri: "simulation://screen", mimeType: "text/plain", text: current(options).harness.screen() }],
}))
server.registerResource("ui-state", "simulation://ui-state", { mimeType: "application/json" }, () => ({
contents: [{ uri: "simulation://ui-state", mimeType: "application/json", text: JSON.stringify(state(options)) }],
}))
server.registerResource("backend-snapshot", "simulation://backend-snapshot", { mimeType: "application/json" }, async () => ({
contents: [
{
uri: "simulation://backend-snapshot",
mimeType: "application/json",
text: JSON.stringify(await control(options, "GET", "/experimental/simulation/snapshot")),
},
],
}))
server.registerPrompt("simulation-driver", { description: "Instructions for driving the simulated TUI." }, () => ({
messages: [
{
role: "user",
content: {
type: "text",
text: "Inspect simulation_ui_state_get, choose one generated action, call simulation_action_execute, then inspect again. Use control tools to seed deterministic backend state.",
},
},
],
}))
server.registerTool("simulation_screen_get", { description: "Get the current TUI screen buffer." }, () =>
toolResult({ screen: current(options).harness.screen() }),
)
server.registerTool("simulation_ui_state_get", { description: "Get elements, focus state, and generated actions." }, () =>
toolResult(state(options)),
)
server.registerTool("simulation_render_once", { description: "Force one render and return current state." }, async () => {
await current(options).harness.renderOnce()
return toolResult(snapshot(options))
})
server.registerTool(
"simulation_backend_fetch",
{
description: "Proxy one fetch request through the simulated backend and return the raw response.",
inputSchema: BackendFetchSchema,
},
async (input) => toolResult(await backendFetch(options, input)),
)
server.registerTool(
"simulation_action_execute",
{
description: "Execute one generated simulation action and render once.",
inputSchema: z.object({ action: ActionSchema }),
},
async (input) => {
await SimulationActions.execute(current(options).harness, input.action)
return toolResult(snapshot(options))
},
)
server.registerTool(
"simulation_action_sequence_execute",
{
description: "Execute a bounded sequence of simulation actions and return final state.",
inputSchema: z.object({ actions: z.array(ActionSchema).max(50) }),
},
async (input) => {
for (const action of input.actions) await SimulationActions.execute(current(options).harness, action)
return toolResult(snapshot(options))
},
)
server.registerTool(
"simulation_script_run",
{
description: "Run a JSON simulation script from a host filesystem path.",
inputSchema: z.object({ path: z.string() }),
},
async (input) => toolResult(await runScript(options, input.path)),
)
server.registerTool(
"simulation_script_load",
{
description:
"Load a simulation script into memory WITHOUT executing it. Pass `path` to load from a JSON file on disk, or `script` to load inline JSON. Returns the parsed action count and a script id. Use `simulation_script_step` to execute actions one (or N) at a time. Only one script may be loaded at once unless `replace` is true.",
inputSchema: z.object({
path: z.string().optional(),
script: z.any().optional(),
replace: z.boolean().optional(),
}),
},
async (input) => toolResult(await loadScript({ file: input.path, script: input.script, replace: input.replace })),
)
server.registerTool(
"simulation_script_step",
{
description:
"Execute the next action(s) of the loaded script and return the snapshot afterwards. Defaults to one step. Pass `steps` to advance multiple actions in a single call (1-100). When `renderEach` is true, the simulated TUI is forced to render between steps.",
inputSchema: z.object({
steps: z.number().int().min(1).max(100).optional(),
renderEach: z.boolean().optional(),
}),
},
async (input) => toolResult(await stepScript(options, { steps: input.steps, renderEach: input.renderEach })),
)
server.registerTool(
"simulation_script_status",
{
description:
"Return the currently-loaded script's progress: id, total actions, cursor, remaining, and execution counts. Returns null when nothing is loaded.",
},
async () => toolResult({ status: statusScript() }),
)
server.registerTool(
"simulation_script_cancel",
{ description: "Discard the currently-loaded script (if any). Subsequent step calls fail until a new script is loaded." },
async () => toolResult(cancelScript()),
)
if ("runtime" in options) {
server.registerTool("simulation_restart", { description: "Restart the simulated TUI and backend while keeping MCP alive." }, async () =>
toolResult(await options.runtime.restart()),
)
}
server.registerTool("simulation_control_reset", { description: "Reset backend simulation state." }, async () =>
toolResult(await control(options, "POST", "/experimental/simulation/reset")),
)
server.registerTool(
"simulation_control_filesystem_seed",
{
description: "Seed backend simulated filesystem files.",
inputSchema: z.object({ files: z.record(z.string(), FileContentSchema) }),
},
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/seed", input)),
)
server.registerTool(
"simulation_control_filesystem_write",
{
description: "Write one file into the backend simulated filesystem.",
inputSchema: z.object({ path: z.string(), content: FileContentSchema }),
},
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/filesystem/write", input)),
)
server.registerTool(
"simulation_control_network_register",
{
description: "Register one backend simulated network response.",
inputSchema: NetworkRegistrationSchema,
},
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/network/register", input)),
)
server.registerTool(
"simulation_control_llm_enqueue",
{
description: "Queue backend mock LLM scripts.",
inputSchema: z.object({ scripts: z.array(LlmScriptSchema) }),
},
async (input) => toolResult(await control(options, "POST", "/experimental/simulation/llm/enqueue", input)),
)
server.registerTool("simulation_control_snapshot", { description: "Get backend simulation state snapshot." }, async () =>
toolResult(await control(options, "GET", "/experimental/simulation/snapshot")),
)
server.registerTool(
"simulation_network_log_get",
{
description:
"Return the persistent network log: every HTTP request the simulated TUI sent to the backend, with method, URL, status code, headers, and response body (body truncated at 32KB per entry). Capped to the last 500 entries.",
inputSchema: z.object({
limit: z.number().int().min(1).max(500).optional(),
urlIncludes: z.string().optional(),
statusMin: z.number().int().optional(),
statusMax: z.number().int().optional(),
}),
},
async (input) => {
let entries = SimulationNetworkLog.snapshot()
if (input.urlIncludes) entries = entries.filter((e) => e.url.includes(input.urlIncludes!))
if (typeof input.statusMin === "number") entries = entries.filter((e) => e.status >= input.statusMin!)
if (typeof input.statusMax === "number") entries = entries.filter((e) => e.status <= input.statusMax!)
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
return toolResult({ entries, total: entries.length })
},
)
server.registerTool(
"simulation_network_log_clear",
{
description: "Clear the simulated TUI's persistent network log. Use between scripted runs to isolate observations.",
},
async () => {
SimulationNetworkLog.clear()
return toolResult({ cleared: true })
},
)
server.registerTool(
"simulation_log_get",
{
description:
"Return the in-memory log buffer captured by `@opencode-ai/core/util/log` inside the simulated backend (capped at 5000 most recent entries). Filter by `level` to return only entries at or above that level. Also supports optional `limit` and substring filters.",
inputSchema: z.object({
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).optional(),
limit: z.number().int().min(1).max(5000).optional(),
messageIncludes: z.string().optional(),
serviceIncludes: z.string().optional(),
}),
},
async (input) => {
const data = await control(options, "GET", "/experimental/simulation/log/entries")
let entries = (data?.entries ?? []) as Array<{
time: string
level: "DEBUG" | "INFO" | "WARN" | "ERROR"
tags: Record<string, unknown>
message: string
}>
if (input.level) {
const priority = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 } as const
const threshold = priority[input.level]
entries = entries.filter((e) => priority[e.level] >= threshold)
}
if (input.messageIncludes) entries = entries.filter((e) => e.message.includes(input.messageIncludes!))
if (input.serviceIncludes)
entries = entries.filter((e) => String(e.tags?.service ?? "").includes(input.serviceIncludes!))
if (typeof input.limit === "number") entries = entries.slice(-input.limit)
return toolResult({ entries, total: entries.length })
},
)
server.registerTool(
"simulation_log_clear",
{ description: "Clear the simulated backend's in-memory log buffer." },
async () => toolResult(await control(options, "POST", "/experimental/simulation/log/clear")),
)
if (masterEnabled()) {
server.registerTool(
"simulation_instances_discover",
{
description: "Discover child simulation MCP servers on sequential localhost ports.",
inputSchema: z.object({
startPort: z.number().optional(),
maxPorts: z.number().optional(),
consecutiveFailures: z.number().optional(),
}),
},
async (input) => toolResult(await discoverInstances(input)),
)
server.registerTool("simulation_instances_list", { description: "List known simulation instances." }, () =>
toolResult({ instances: instances() }),
)
server.registerTool(
"simulation_instances_action_execute",
{
description: "Execute one UI action on one or more simulation instances.",
inputSchema: z.object({ target: TargetSchema, action: ActionSchema }),
},
async (input) =>
toolResult(
await runOnTargets(
options,
input.target,
async () => {
await SimulationActions.execute(current(options).harness, input.action)
return snapshot(options)
},
(instance) => childToolCall(instance, "simulation_action_execute", { action: input.action }),
),
),
)
server.registerTool(
"simulation_instances_filesystem_write",
{
description: "Write one file on one or more simulation instances.",
inputSchema: z.object({ target: TargetSchema, path: z.string(), content: FileContentSchema }),
},
async (input) =>
toolResult(
await runOnTargets(
options,
input.target,
() => control(options, "POST", "/experimental/simulation/filesystem/write", { path: input.path, content: input.content }),
(instance) => childToolCall(instance, "simulation_control_filesystem_write", { path: input.path, content: input.content }),
),
),
)
server.registerTool(
"simulation_instances_llm_enqueue",
{
description: "Queue LLM scripts on one or more simulation instances.",
inputSchema: z.object({ target: TargetSchema, scripts: z.array(LlmScriptSchema) }),
},
async (input) =>
toolResult(
await runOnTargets(
options,
input.target,
() => control(options, "POST", "/experimental/simulation/llm/enqueue", { scripts: input.scripts }),
(instance) => childToolCall(instance, "simulation_control_llm_enqueue", { scripts: input.scripts }),
),
),
)
server.registerTool(
"simulation_instances_script_run",
{
description: "Run a JSON simulation script on one or more simulation instances.",
inputSchema: z.object({ target: TargetSchema, path: z.string() }),
},
async (input) =>
toolResult(
await runOnTargets(
options,
input.target,
() => runScript(options, input.path),
(instance) => childToolCall(instance, "simulation_script_run", { path: input.path }),
),
),
)
}
return server
}
export async function createSimulationMcpServer(options: Options): Promise<SimulationMcpServer> {
if (options.mode === "stdio") {
const server = createServer(options)
const transport = new StdioServerTransport()
await server.connect(transport)
return {
mode: options.mode,
stop: () => server.close(),
}
}
const servers = new Set<McpServer>()
const http = serveRemote(
async (request) => {
if (new URL(request.url).pathname !== "/mcp") return new Response("Not found", { status: 404 })
const server = createServer(options)
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
})
servers.add(server)
request.signal.addEventListener("abort", () => {
servers.delete(server)
void server.close()
})
await server.connect(transport)
return transport.handleRequest(request)
},
)
return {
mode: options.mode,
url: `http://${http.hostname}:${http.port}/mcp`,
stop: async () => {
http.stop(true)
await Promise.all([...servers].map((server) => server.close()))
servers.clear()
},
}
}
export * as TuiSimulationMcp from "./simulation-mcp"
@@ -1,46 +0,0 @@
// Per-process network log for the simulated TUI. Captures every HTTP request
// the TUI sends to the worker-backed backend (via `createWorkerFetch`).
// Exposed through the simulation MCP server so tests / agents can inspect what
// the running TUI is actually doing.
export interface NetworkLogEntry {
readonly id: number
readonly time: string
readonly method: string
readonly url: string
readonly status: number
readonly durationMs: number
readonly requestHeaders: Record<string, string>
readonly requestBody?: string
readonly responseHeaders: Record<string, string>
readonly responseBody: string
readonly responseTruncated: boolean
readonly error?: string
}
const MAX_ENTRIES = 500
const MAX_BODY_BYTES = 32_768
const entries: NetworkLogEntry[] = []
let nextId = 1
function truncate(text: string): { body: string; truncated: boolean } {
if (text.length <= MAX_BODY_BYTES) return { body: text, truncated: false }
return { body: text.slice(0, MAX_BODY_BYTES), truncated: true }
}
export function record(entry: Omit<NetworkLogEntry, "id" | "responseTruncated"> & { responseBody: string }) {
const { body, truncated } = truncate(entry.responseBody)
entries.push({ ...entry, id: nextId++, responseBody: body, responseTruncated: truncated })
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES)
}
export function snapshot(): NetworkLogEntry[] {
return entries.slice()
}
export function clear() {
entries.length = 0
}
export * as SimulationNetworkLog from "./simulation-network-log"
@@ -1,35 +0,0 @@
import type { CliRenderer } from "@opentui/core"
import type { CapturedFrame } from "@opentui/core"
import type { SimulationActions } from "@/testing/simulation/actions"
export interface SimulationRenderer {
readonly renderer: CliRenderer
readonly mockInput: SimulationActions.MockInput
readonly mockMouse: SimulationActions.MockMouse
readonly renderOnce: () => Promise<void>
readonly screen: () => string
readonly spans: () => CapturedFrame
readonly destroy: () => void
}
export async function createSimulationRenderer(): Promise<SimulationRenderer> {
const { createTestRenderer } = await import("@opentui/core/testing")
const setup = await createTestRenderer({
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
screenMode: "main-screen",
consoleMode: "disabled",
})
return {
renderer: setup.renderer,
mockInput: setup.mockInput,
mockMouse: setup.mockMouse,
renderOnce: setup.renderOnce,
screen: setup.captureCharFrame,
spans: setup.captureSpans,
destroy: () => setup.renderer.destroy(),
}
}
export * as TuiSimulation from "./simulation"
@@ -1,8 +1,10 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useTuiConfig } from "../context/tui-config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = {
title: string
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
function confirm() {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}
useBindings(() => ({
target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer.
priority: 1,
commands: [
{
name: "dialog.prompt.submit",
title: "Submit dialog prompt",
category: "Dialog",
run: confirm,
},
],
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
}))
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description}
<textarea
onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<Show when={submitShortcut()}>
<text fg={theme.text}>
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>
</box>
</box>
@@ -0,0 +1,19 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return {
output:
Array.from(preview)
.slice(0, Math.max(0, maxChars - 1))
.join("") + "…",
overflow: true,
}
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}
@@ -7,6 +7,7 @@ type Toast = {
type FocusableSelectionTarget = {
hasSelection: () => boolean
getClipboardText?: (text: string) => string
}
type Renderer = {
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
}
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
const selection = renderer.getSelection()
if (!selection) return false
const text = selection.getSelectedText()
if (!text) return false
Clipboard.copy(text)
const focus = renderer.currentFocusedRenderable
const clipboardText =
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
Clipboard.copy(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)

Some files were not shown because too many files have changed in this diff Show More