mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cbf607849 | |||
| 378d46c076 | |||
| e0d4a0374c | |||
| f6f8606155 | |||
| ef31823a6f | |||
| 4a42f4a724 | |||
| ce27f07f15 | |||
| 80587d936f | |||
| 09772d89d5 | |||
| 4f07025067 | |||
| f253bdaeef | |||
| 93c24cafa7 | |||
| 0963826120 | |||
| c5df2e5c28 | |||
| 13f0cdd0ea | |||
| 5887911c26 | |||
| 59bc653465 | |||
| 8a4c14aed5 | |||
| 15aef5f0f2 | |||
| 9b87120db6 | |||
| af6407a1b1 | |||
| e4ff762483 | |||
| a00822b06e | |||
| 58c33f6870 | |||
| 82d13c6715 | |||
| 6c1d066a65 | |||
| 9b8801a3c6 | |||
| 8dad59cf99 | |||
| a023ece71f | |||
| 2b5cc0090a | |||
| fc6b9e0dde | |||
| ab535c3875 | |||
| 4ca2f9dd83 | |||
| 044d04df06 | |||
| 4b9d89e943 | |||
| 5ff6bb87cf | |||
| 511b4556a2 | |||
| cb39ea1136 | |||
| ff9452bf03 | |||
| 97265f8ac5 | |||
| 56e66656b8 | |||
| 02f3f3cb3e | |||
| b0c3a16ead | |||
| 594c395576 | |||
| b7402c264d | |||
| 3f79699bce | |||
| 67fe76057e | |||
| bac474aaa0 | |||
| 98c717cb5b | |||
| 5c8d46ab4b | |||
| d5e83fefda | |||
| 46378dda50 | |||
| b38d9d812f | |||
| 8df039d261 | |||
| c92fb2d41b | |||
| 958308c913 | |||
| 16390ca47d | |||
| 643eed300d | |||
| c3a6721de2 | |||
| 47af7462ea | |||
| a76ab93673 | |||
| dfb60e37a3 | |||
| 5105913568 | |||
| 28b4cade9e | |||
| 1f61eb5ca9 | |||
| ea1ff90e42 | |||
| eb3c4c82fe |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
|
||||
@@ -82,6 +82,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# Sync Engine Plan (draft)
|
||||
|
||||
Redesign of client state management: consume the event-sourced server that
|
||||
already exists. Goal: **less code, more reliable** — optimism, reconnect, and
|
||||
retry become structural guarantees instead of defended special cases.
|
||||
|
||||
Status: outline for discussion. Sections marked `TODO` get filled in as we talk.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context ledger (receipts — do not lose these)
|
||||
|
||||
Facts established by code audit and prototyping, with locations.
|
||||
|
||||
### The server is already event-sourced
|
||||
|
||||
- Durable per-session event log in SQLite: `event(aggregate_id, seq, type, data)`
|
||||
+ `event_sequence` counter, unique index `(aggregate_id, seq)`
|
||||
(`packages/core/src/event/sql.ts`, migration `20260323234822_events`).
|
||||
- Event append, projections, and `commit` hooks all run in **one SQLite
|
||||
transaction** (`packages/core/src/bus.ts:249-370`, `behavior: "immediate"`).
|
||||
- 40 of 46 session event types are durable; the 6 ephemeral are streaming
|
||||
deltas/progress/usage ticks (`packages/schema/src/session-event.ts:626`).
|
||||
Durable events carry complete facts (`text.ended` has full text).
|
||||
- All hydration tables are written **only** by projectors inside the append
|
||||
transaction: `SessionMessageTable`, `SessionTable` → `session/projector.ts`;
|
||||
`SessionInboxTable` → `inbox.ts` `project*` fns called only from
|
||||
`projector.ts:521,555,571,577`. `SessionPendingTable` is dead (zero writers).
|
||||
- Message rows carry the aggregate seq of their creating event
|
||||
(`projector.ts:386`), and `Session.messages` already paginates by it
|
||||
(`session.ts:501`).
|
||||
- Replay+live log endpoint exists:
|
||||
`GET /api/experimental/session/:id/log?after=seq&follow=true`
|
||||
(`packages/protocol/src/groups/session.ts:631`). Only devtools consumes it.
|
||||
- Idempotent admission ~exists: `SessionInbox.admit` (`inbox.ts:137`) adopts an
|
||||
existing client-supplied `SessionMessage.ID`, re-checks after conflict
|
||||
defects. Gap: adopts without comparing payloads (no conflict on mismatch).
|
||||
- Reverts are committed server-side inside `Session.prompt`
|
||||
(`core/src/session.ts:567` at audit time) — client-side revert.commit is
|
||||
already redundant.
|
||||
|
||||
### The client ignores all of it
|
||||
|
||||
- The client data layer is `packages/client/src/solid/data.ts` (~1,530 lines;
|
||||
`packages/tui/src/context/data.tsx` is now a 22-line wrapper) — shared by
|
||||
TUI/desktop/web, so the engine lands once for all clients. Three racing
|
||||
write paths into one Solid store: a ~60-case bus-event switch, fetch
|
||||
handlers doing wholesale `reconcile()`, and (in PR #42807) an optimistic
|
||||
ledger both must consult. `sync.complete(key)` markers dedupe fetches but
|
||||
are not watermarks.
|
||||
- Opening a session today = four separate fetches: `session.get`,
|
||||
`session.list({parentID})` (children), `session.inbox.list`, messages
|
||||
(`data.ts:1084-1140`) — four sync keys, no shared consistent point.
|
||||
- Reconnect = `sync.reset()` + refetch everything; no gap proof.
|
||||
|
||||
### Prior art
|
||||
|
||||
- PRs #42807 (optimistic message send) and #42808 (optimistic session create):
|
||||
the stopgap. They landed client-generated IDs (keep) and ~15 hand-placed
|
||||
guard sites defending the optimistic ledger (delete when this plan ships).
|
||||
- Prototype: `~/code/open-source/sync-proto` — Effect v4 + Solid. Engine core
|
||||
~200 lines. Six laws as passing tests; seeded 2-client chaos sim (lost
|
||||
requests, lost responses after durable write, rejections, connection cuts,
|
||||
latency shifts) with convergence / ordering / uniqueness / no-flicker
|
||||
invariants; sim caught a real protocol bug (validation-before-adoption) on
|
||||
first run. Two-pane demo (await vs optimistic) with narrated scenarios.
|
||||
- Proposal doc: `~/code/open-source/opencode-sync-proposal/PROPOSAL.md`
|
||||
("Intents, Not Mutations").
|
||||
|
||||
### Vocabulary
|
||||
|
||||
- **fold** — pure reduction of durable events into state; same meaning on both
|
||||
sides. **intent** — a user action not yet acknowledged, client ID, in the
|
||||
**outbox**. **watermark** — the aggregate seq a snapshot reflects.
|
||||
**echo determinism** — an intent renders byte-identical to the row its
|
||||
admission event folds to, so acks are invisible.
|
||||
- The six laws: idempotency, echo determinism, sync opacity, ordering,
|
||||
convergence, failure atomicity.
|
||||
|
||||
### Ordering constraint (the one subtle thing)
|
||||
|
||||
Watermark reads must share a transaction with row reads:
|
||||
`seq`-then-rows double-applies accumulators; rows-then-`seq` loses events.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Optimism, reconnect, and retry are each defended point-by-point instead of
|
||||
guaranteed structurally; every new sync path re-fights the same races.
|
||||
- The client maintains a hand-written mutable mirror of state the server can
|
||||
already replay deterministically.
|
||||
|
||||
## 2. The claim: less code, more reliable
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Fold cases ≈ today's event switch (that code stays, becomes pure).
|
||||
- Deleted: optimistic ledger + guards, echo replace-in-place, sync-survival
|
||||
merges, `submitTails` throttling, `sync.complete` session keys,
|
||||
refetch-everything reconnect.
|
||||
- Added: engine core (~200 lines proven in prototype) + watermark plumbing.
|
||||
- Reliability from structure: the six laws hold by construction, not vigilance.
|
||||
|
||||
## 3. Design
|
||||
|
||||
```
|
||||
state = fold(snapshot, durable events) server truth only
|
||||
outbox = ordered pending intents client IDs, idempotent, serial resend
|
||||
overlay = live ephemeral fragments deltas/progress, superseded by durable facts
|
||||
view = render(state ⊕ outbox ⊕ overlay) derived
|
||||
```
|
||||
|
||||
The entire client surface for one session — two calls, one stream:
|
||||
|
||||
```
|
||||
GET /session/:id/snapshot fetch → { session, children, inbox,
|
||||
messages: last(N), seq }
|
||||
GET /session/:id/log?after=seq&follow=true one ordered stream:
|
||||
replay: durable events seq+1..head (deltas are live-only, absent here)
|
||||
marker: log.synced "caught up" — first-class engine signal
|
||||
live: durable + ephemeral session events interleaved, publish order
|
||||
```
|
||||
|
||||
- **Decided:** the log endpoint's follow phase carries ephemeral session
|
||||
events too (widen its union from `Durable | Synced`). One server-merged
|
||||
stream per session; the client never merges two event feeds for one
|
||||
aggregate, so a delta can never precede its own `Started`.
|
||||
- **Overlay semantics:** `Map<(messageID, ordinal), accumulated>` — never
|
||||
enters the fold. The durable full-value boundary (`text.ended`, carries
|
||||
complete text) supersedes and clears the overlay entry in the same atomic
|
||||
update that folds it — same no-seam trick as an intent leaving the outbox
|
||||
on ack. Dropped deltas are self-healing by construction. Same mechanism for
|
||||
all 6 ephemeral types (`tool.progress`/`usage.updated` superseded by
|
||||
`tool.success|failed`/`step.ended`).
|
||||
- **Decided (mid-stream reconnect):** accept today's behavior — after
|
||||
re-hydrate, overlay accumulates from now, so in-flight text may render with
|
||||
a missing prefix until its `Ended` supersedes. Simplest code, self-healing,
|
||||
no protocol change. (Optional later polish: if `Started` wasn't observed
|
||||
live, show a streaming indicator instead of partial text.)
|
||||
- Overlay entries render only once their base part exists in folded state
|
||||
(covers the one degenerate case: a live delta arriving during the replay
|
||||
window).
|
||||
- **Outbox ack contract: the echo is the only ack.** Intents carry a
|
||||
client-minted message ID. The HTTP 2xx is a latency hint, never trusted;
|
||||
the intent leaves the outbox only when the durable event carrying its ID
|
||||
folds in (same atomic store update — no flicker), or on a typed rejection
|
||||
(drop + surface). Timeout/disconnect = unknown fate = intent just stays.
|
||||
On reconnect: tail first, wait for `log.synced` (replay acks anything that
|
||||
landed), then resend unacked serially — adopt-on-same-ID (`inbox.ts:137`)
|
||||
makes resend duplicate-free. Lost request and lost response collapse into
|
||||
the same case ("no echo yet"), which is why no guard machinery survives.
|
||||
- **Contract: the snapshot is the only entry point.** The engine never replays
|
||||
a session from genesis; fold's domain is "snapshot + events after its seq."
|
||||
Forced by `session.forked` — the event carries only `{ parentID, boundary }`
|
||||
and the projector copies parent rows, which no client fold could reproduce.
|
||||
Bonus: licenses future log truncation (serve back to the oldest live
|
||||
snapshot, not seq 0).
|
||||
- Compaction/revert/fork all fold cleanly under that contract: compaction only
|
||||
adds (summary message + epoch marker); revert folds exactly what the
|
||||
projector does (drop messages/inbox at-or-after boundary, clear marker —
|
||||
`projector.ts:660-690`); fork needs no fold case at all (a forked session's
|
||||
snapshot already contains the copied messages).
|
||||
- Reconnect: tail from last seq first; on typed seq-unavailable → snapshot →
|
||||
resend unacknowledged → tail. Gapless by construction.
|
||||
- Scope boundary: session aggregate only. Ambient state (catalog, agents,
|
||||
projects, vcs, worktrees) stays on the existing global bus as-is.
|
||||
- Render target: existing Solid store shape, so components don't change.
|
||||
|
||||
## 4. Server changes (small)
|
||||
|
||||
- S1: watermarked hydration — **decided (tentative, pending prior-art review):**
|
||||
one new endpoint, `GET /api/session/:id/snapshot`, one read transaction:
|
||||
|
||||
```
|
||||
GET /api/session/:id/snapshot?recent=N
|
||||
→ { session, children, inbox, messages: last(N), seq }
|
||||
```
|
||||
|
||||
`recent` defaults to 200 — today's first page exactly (`data.ts:1136`,
|
||||
`limit: 200`). Tuning it smaller later is a parameter change, not a design
|
||||
change.
|
||||
|
||||
- Named `snapshot`, not `state`: point-in-time consistency is the contract,
|
||||
and `state` collides with the engine's `state = fold(...)`.
|
||||
- One transaction → one watermark. Rejected alternative: adding `seq` to
|
||||
each existing read — three watermarks force per-slice replay guards in
|
||||
the engine, and the session row's accumulators (usage sums) double-count
|
||||
on overlap replay. Server savings reappear as engine complexity, worse.
|
||||
- `children` are info rows only; child sessions are separate aggregates and
|
||||
hydrate their own messages/inbox when opened.
|
||||
- Older history stays on the existing seq-cursored `messages` endpoint,
|
||||
folded in as inert backfill (IDs dedupe; revert/compaction arrive on the
|
||||
log).
|
||||
- Prior art for snapshot+watermark+tail: Discord (`seq`/RESUME), Telegram
|
||||
(`pts`/`getDifference`), Linear (`lastSyncId`), Postgres logical
|
||||
replication (exported snapshot + LSN), Kafka/EventStore catch-up
|
||||
subscriptions, Replicache (cookie — whose launch checklist requires the
|
||||
watermark "read in the same transaction as the client view data").
|
||||
Slack's deprecated `rtm.start` is the cautionary tale for unbounded
|
||||
snapshots (their edge cache cut boot payloads 7–44×; 42-message pages).
|
||||
Details: `notes/hydration-prior-art.md`.
|
||||
- Adopted from prior art:
|
||||
- `log?after=seq` returns a typed **seq-unavailable error** when the seq
|
||||
is not servable → client full-rehydrates. Never silently clamp to the
|
||||
oldest retained event (Discord op 9 / Telegram `differenceTooLong` /
|
||||
Postgres explicitly warns against clamping).
|
||||
- **Cursor honesty (S1 follow-up, landed):** `events.persist` defaults to
|
||||
off (only workerd enables it), so `event` payload rows usually don't
|
||||
exist even though `event_sequence` always advances. Old behavior:
|
||||
`after < head` "replayed" nothing from the empty table and marked
|
||||
synced — silent desync. Now `openLog` proves retained rows cover
|
||||
`(after, head]` (`Bus.retainedCount`) and returns seq-unavailable
|
||||
otherwise; the engine re-snapshots. A cursor is a resumption claim and
|
||||
gets the strict check; a cursorless read remains "what's retained".
|
||||
Client side, the engine also treats a `log.synced` marker past its
|
||||
folded seq as a gap → snapshot recovery, closing the residual
|
||||
in-process race between the head check and the live subscription.
|
||||
Persistence therefore stays optional: snapshot + live tail + honest
|
||||
refusal is a complete contract. A short retention window (persist on +
|
||||
pruning sweep) can drop in later behind the same check to make busy
|
||||
reconnects replay instead of refetch.
|
||||
- Resume-first discipline: reconnect tries the tail from the last seq
|
||||
before re-snapshotting (Discord meters full rehydrates, not resumes).
|
||||
- Keep the existing `log.synced` caught-up marker as a first-class engine
|
||||
signal (EventStoreDB added `CaughtUp` after pain; suppress notifications
|
||||
during replay).
|
||||
- ~~S2: adopt strictness~~ — dropped. The engine only needs adopt-on-same-ID,
|
||||
which exists (`inbox.ts:137` + AGENTS.md reconciliation rules). Conflict on
|
||||
same-ID/different-payload defends against a client bug we never create.
|
||||
- S3: widen `session.log` follow phase to interleave ephemeral session events,
|
||||
**opt-in via `?ephemeral=true`** — default stays durable-only, so existing
|
||||
log consumers (devtools) are untouched. The response union widens either way
|
||||
(extra variants simply never appear unless requested); replay is durable-only
|
||||
in both modes since ephemeral events aren't retained. Also add the typed
|
||||
seq-unavailable error. Later: promote out of experimental.
|
||||
- S4: filter param on `event.subscribe` (`/api/event` today sends everything
|
||||
to everyone and fails slow consumers by contract). The ambient stream slims
|
||||
to: non-session events + session-lifecycle events needed by the session
|
||||
list for unopened sessions (created/deleted/renamed/moved, usage, execution
|
||||
status). Exact param shape TBD.
|
||||
|
||||
## 5. Delivery: parallel build in this worktree, compare locally
|
||||
|
||||
Not a staged production migration — this branch carries a complete alternate
|
||||
data layer next to the existing one, so both versions can be run and compared
|
||||
before any adoption decision.
|
||||
|
||||
1. Server additions S1–S4 (additive; existing clients unaffected).
|
||||
2. Engine as a new module in `packages/client` (`solid/engine.ts` or similar)
|
||||
beside `solid/data.ts` — same store shape and consumer-facing API, fed by
|
||||
snapshot + session stream + outbox instead of fetch + bus + ledger.
|
||||
`data.ts` is not modified.
|
||||
3. TUI in this worktree wired to the engine layer.
|
||||
4. Compare: run this worktree's full stack (`bun run dev`) against stock v2
|
||||
side by side — submit latency, kill/reconnect behavior, streaming, revert,
|
||||
forks. Port the six-laws + chaos tests as the regression net.
|
||||
5. If it wins: adoption (swap the wiring, delete `data.ts` + PR #42807/#42808
|
||||
guards) is its own decision with two working implementations to diff.
|
||||
|
||||
Deletion inventory at adoption: `data.ts` (~1,530 lines — the 64-case bus
|
||||
switch becomes the pure fold; 4-fetch session open → one snapshot; all
|
||||
refetch-on-reconnect machinery → nothing), PRs #42807/#42808 never merge
|
||||
(stopgap ledger avoided entirely), TUI `util/single-flight.ts` + call
|
||||
sites (submit is idempotent by minted ID), per-callsite await-then-toast
|
||||
action plumbing → one generic outbox-rejection path. Rendering components
|
||||
unchanged by design (same store shape).
|
||||
|
||||
### Commit-by-commit
|
||||
|
||||
Status: Lane A done (`0af27771` snapshot core, `d611b39f` endpoint,
|
||||
`b21a3c7d` typed seq-unavailable, `74929dd7` ephemeral opt-in, `e6eac3a0`
|
||||
simplify — S4 pending shape decision). Lane B done through validation +
|
||||
simplify (`54a2ec54` fold, `0f8831b3` engine, `2c29a743` laws, `354ab6f9`
|
||||
chaos, `c9ec1385` simplify). Wiring done: `5d70561f` engine-backed data
|
||||
layer, `48e237d9` TUI wiring, `90760fc6`/`5f63f252` regression fixes (5 of 6
|
||||
initial TUI failures were real engine-layer gaps — reconnect refresh, queued
|
||||
lifecycle, pending refresh, synced-gated sync, family-index pollution — all
|
||||
fixed; 1 obsolete assertion updated with justification). Suites: TUI 729/0,
|
||||
client green except 3 pre-existing generated-client failures (repro on stock
|
||||
v2). Remaining: live comparison pass (B7), S4 shape decision.
|
||||
|
||||
Two lanes, disjoint packages, parallelizable. Worker sessions execute; the
|
||||
coordinating session reviews each commit and keeps this doc current. Every
|
||||
commit typechecks and passes its package tests before landing.
|
||||
|
||||
**Lane A — server** (`packages/core`, `packages/protocol`, generated client):
|
||||
|
||||
- **Snapshot** (S1)
|
||||
- `feat(core): Session.snapshot` — service method, one read transaction:
|
||||
`{ session, children, inbox, messages: last(recent), seq }`. Core tests:
|
||||
seq consistency under concurrent publish (a write landing between the
|
||||
transaction's reads must be impossible), empty session, recent windowing.
|
||||
- `feat(protocol): session.snapshot endpoint` — route + handler + `bun run
|
||||
generate` from packages/client.
|
||||
- **Session stream** (S3)
|
||||
- `feat(core): typed seq-unavailable on session.log` — `after > head` (or
|
||||
below retention, future) fails typed instead of silently serving. Test
|
||||
the `after == head` boundary explicitly (Zero's fence-post bug).
|
||||
- `feat(core): session.log follow includes ephemeral events` — opt-in
|
||||
`?ephemeral=true` (default durable-only, existing consumers untouched);
|
||||
widen union (`Durable | Synced` → session events + `Synced`), interleave
|
||||
live ephemeral session events in publish order + generate. Test: delta
|
||||
never precedes its Started on one stream; replay phase stays durable-only
|
||||
in both modes; default mode carries no ephemeral events.
|
||||
- **Ambient filter** (S4, can trail; nothing in Lane B blocks on it)
|
||||
- `feat(protocol): event.subscribe filter` — ambient scope.
|
||||
|
||||
**Lane B — engine** (`packages/client/src/solid`, `packages/client/test`):
|
||||
|
||||
- **Fold**
|
||||
- `feat(client): session fold` — pure module: durable session events →
|
||||
session state (messages, inbox, markers). No transport, no store. The
|
||||
~60-case switch in `data.ts` is the reference for event semantics.
|
||||
- **Engine core**
|
||||
- `feat(client): engine core` — outbox (intents, serial resend, ack-on-
|
||||
fold-in-same-update), overlay (ephemeral fragments, superseded-and-
|
||||
cleared by durable facts), view derivation, reconnect loop against a
|
||||
transport interface.
|
||||
- **Validation**
|
||||
- `test(client): six laws` — idempotency, echo determinism, sync opacity,
|
||||
ordering, convergence, failure atomicity — fake in-process transport,
|
||||
TestClock, no server.
|
||||
- `test(client): seeded chaos sim` — two clients, lost requests/responses,
|
||||
rejections, cuts, latency shifts; convergence + no-flicker invariants.
|
||||
- **Wiring** (waits on Snapshot's generated client)
|
||||
- `feat(client): engine-backed data layer` — `data.ts`-compatible surface
|
||||
over the engine, real transport.
|
||||
- `feat(tui): wire TUI to engine layer` — this worktree only.
|
||||
- Verification pass: termctrl live comparison vs stock v2; record demo.
|
||||
|
||||
Order: Lane A (Snapshot → Session stream → Ambient filter) and Lane B
|
||||
(Fold → Engine core → Validation) run in parallel; Wiring waits on Snapshot,
|
||||
then the verification pass waits on Session stream + Wiring.
|
||||
|
||||
## 6. Validation
|
||||
|
||||
- Port sync-proto's six laws + seeded chaos sim to run against the real engine
|
||||
with a faked transport. TODO: where these tests live, CI story.
|
||||
- Explicit boundary test at `seq == snapshot.seq` (empty tail vs purged log) —
|
||||
Zero shipped a fence-post bug at exactly this boundary (rocicorp/mono#5589).
|
||||
|
||||
## 7. Open questions (to resolve together)
|
||||
- Web app / desktop adoption order after TUI.
|
||||
- S4 filter shape: type list vs a named "ambient" scope.
|
||||
|
||||
## 8. Non-goals
|
||||
|
||||
- No server storage migration; no new event types; no runner changes.
|
||||
- No component/rendering changes; no changes to ambient (non-session) state.
|
||||
@@ -62,7 +62,6 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -120,6 +119,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -590,9 +590,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"@opentui/core": ">=0.5.4",
|
||||
"@opentui/keymap": ">=0.5.4",
|
||||
"@opentui/solid": ">=0.5.4",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -685,9 +685,8 @@
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "file:../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
@@ -1089,9 +1088,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2113,27 +2112,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -6369,8 +6368,6 @@
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
||||
|
||||
+3
-3
@@ -47,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -437,10 +437,19 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
// Mid-conversation system messages are a native Claude API feature only for
|
||||
// Opus 4.8. Other Anthropic models intentionally use the same visible wrapped-
|
||||
// user fallback as non-Anthropic routes rather than sending a role they reject.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => String(request.model.id) === "claude-opus-4-8"
|
||||
// Mid-conversation system messages became available with Opus 4.8 and version
|
||||
// 5 of the other supported Claude families. Treat later family versions as
|
||||
// compatible without assuming that every Anthropic Messages model is Claude.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => {
|
||||
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
|
||||
String(request.model.id).toLowerCase(),
|
||||
)
|
||||
if (!match) return false
|
||||
const major = Number(match[2])
|
||||
if (match[1] !== "opus") return major >= 5
|
||||
if (major !== 4) return major >= 5
|
||||
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
|
||||
}
|
||||
|
||||
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
const last = message.content.at(-1)
|
||||
@@ -896,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
|
||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
|
||||
@@ -136,6 +136,33 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = [
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-5-1",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-5-1",
|
||||
"claude-fable-6",
|
||||
"anthropic/claude-mythos-7.2",
|
||||
]
|
||||
|
||||
const prepared = yield* Effect.forEach(ids, (id) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id }),
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared.map((item) => item.body.messages[1]?.role)).toEqual(ids.map(() => "system"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -163,6 +190,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-9",
|
||||
"claude-haiku-4-9",
|
||||
"custom-model-7",
|
||||
]
|
||||
|
||||
const prepared = yield* Effect.forEach(ids, (id) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id }),
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared.map((item) => item.body.messages.some((message) => message.role === "system"))).toEqual(
|
||||
ids.map(() => false),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-text chronological system update content before send", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
@@ -955,6 +1010,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores tool input deltas without a matching tool start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles pending tool calls at message_stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const APP_READY_TIMEOUT = 30_000
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { Project } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
||||
import { formatKeybind } from "@/context/command"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function nativePickerPath(path: string) {
|
||||
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
||||
return value
|
||||
}
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { ServerSDK } from "@/context/server-sdk"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
|
||||
@@ -173,7 +173,7 @@ beforeAll(async () => {
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/core/util/encode", () => ({
|
||||
mock.module("@opencode-ai/util/encode", () => ({
|
||||
base64Decode: (value: string) => value,
|
||||
base64Encode: (value: string) => value,
|
||||
checksum: (value: string) => value,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
@@ -12,7 +12,7 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { sameDirectory } from "@/utils/workspace"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/types"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
ProjectListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { cmp, normalizeProjectInfo } from "./utils"
|
||||
|
||||
@@ -4,8 +4,6 @@ import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
@@ -37,6 +35,12 @@ export function parseHomeSessionIndex(sessions: SessionInfo[]) {
|
||||
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
|
||||
}
|
||||
|
||||
export function mergeHomeSessionIndex(fetched: SessionInfo[], known: SessionInfo[]) {
|
||||
return parseHomeSessionIndex([
|
||||
...new Map([...fetched, ...known].map((session) => [session.id, session] as const)).values(),
|
||||
])
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||
return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => {
|
||||
const sorted = items.toSorted((a, b) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Agent, Config, LspStatus, Path, VcsInfo } from "@/types"
|
||||
import type { Agent, Config, LspStatus, Path, ProviderListResponse, VcsInfo } from "@/types"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
@@ -25,7 +24,7 @@ export type State = {
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: NormalizedProviderListResponse
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
mcp_ready: boolean
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -33,7 +32,7 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
): ProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return providers
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import {
|
||||
autoRespondsPermission,
|
||||
isDirectoryAutoAccepting,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Persist } from "@/utils/persist"
|
||||
import type { Platform } from "./platform"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||
import { useWorkspaceLocation, type LocationContext } from "./location"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
import { selectProviderCatalog } from "./provider-catalog"
|
||||
|
||||
const catalog = (id: string): NormalizedProviderListResponse => ({
|
||||
const catalog = (id: string): ProviderListResponse => ({
|
||||
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
|
||||
connected: [id],
|
||||
default: { [id]: `${id}-model` },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ProviderListResponse } from "@/types"
|
||||
|
||||
export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
export const emptyProviderCatalog: ProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||
|
||||
type DirectoryCatalog = {
|
||||
ready: boolean
|
||||
providers: NormalizedProviderListResponse
|
||||
providers: ProviderListResponse
|
||||
}
|
||||
|
||||
type ProviderCatalogInput =
|
||||
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
|
||||
explicit: false
|
||||
directory?: string
|
||||
catalog?: DirectoryCatalog
|
||||
global: NormalizedProviderListResponse
|
||||
global: ProviderListResponse
|
||||
}
|
||||
|
||||
export function selectProviderCatalog(input: ProviderCatalogInput) {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
homeSessionIndexKey,
|
||||
loadHomeSessionIndex,
|
||||
parseHomeSessionIndex,
|
||||
mergeHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
@@ -53,19 +52,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
return {
|
||||
queryKey: conn
|
||||
? homeSessionIndexKey(ServerConnection.key(conn))
|
||||
: (["home", "session-index", "unselected"] as const),
|
||||
queryKey: ["home-sessions", conn] as const,
|
||||
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
|
||||
queryFn: ctx
|
||||
? async ({ signal }) => {
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.api.session.list(input, options),
|
||||
signal,
|
||||
)
|
||||
index.forEach(ctx.data.session.remember)
|
||||
return Date.now()
|
||||
}
|
||||
? ({ signal }) => loadHomeSessionIndex((input, options) => ctx.sdk.api.session.list(input, options), signal)
|
||||
: skipToken,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
@@ -76,7 +66,11 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const indexedSessions = createMemo(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return []
|
||||
return retainHomeSessions(parseHomeSessionIndex(ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now())
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data ?? [], ctx.data.session.list()),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
)
|
||||
})
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import type { FilePart } from "@/types"
|
||||
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLayout } from "@/context/layout"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const useSessionKey = () => {
|
||||
const params = useParams()
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
partDefaultOpen,
|
||||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolPart } from "@/types"
|
||||
import {
|
||||
presentAssistantMessage,
|
||||
presentAssistantContent,
|
||||
@@ -56,9 +56,7 @@ export function CurrentAssistantContent(props: {
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
|
||||
const part = createMemo(() =>
|
||||
presentAssistantContent(props.sessionID, props.message, props.contentID, props.content),
|
||||
)
|
||||
const part = createMemo(() => presentAssistantContent(props.sessionID, props.message, props.contentID, props.content))
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
@@ -113,9 +111,5 @@ export function currentPartDefaultOpen(
|
||||
shellExpanded: boolean,
|
||||
editExpanded: boolean,
|
||||
) {
|
||||
return partDefaultOpen(
|
||||
presentAssistantContent(sessionID, message, contentID, content),
|
||||
shellExpanded,
|
||||
editExpanded,
|
||||
)
|
||||
return partDefaultOpen(presentAssistantContent(sessionID, message, contentID, content), shellExpanded, editExpanded)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@openc
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { Project } from "@/types"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
|
||||
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
worktree: string
|
||||
@@ -257,9 +256,83 @@ export type Agent = {
|
||||
steps?: number
|
||||
}
|
||||
|
||||
export type Provider = NormalizedProviderListResponse["all"] extends Map<string, infer Item> ? Item : never
|
||||
export type Model = Provider["models"][string]
|
||||
export type ProviderListResponse = NormalizedProviderListResponse
|
||||
export type Model = {
|
||||
id: string
|
||||
providerID: string
|
||||
api: {
|
||||
id: string
|
||||
url: string
|
||||
npm: string
|
||||
}
|
||||
name: string
|
||||
family?: string
|
||||
capabilities: {
|
||||
temperature: boolean
|
||||
reasoning: boolean
|
||||
attachment: boolean
|
||||
toolcall: boolean
|
||||
input: {
|
||||
text: boolean
|
||||
audio: boolean
|
||||
image: boolean
|
||||
video: boolean
|
||||
pdf: boolean
|
||||
}
|
||||
output: {
|
||||
text: boolean
|
||||
audio: boolean
|
||||
image: boolean
|
||||
video: boolean
|
||||
pdf: boolean
|
||||
}
|
||||
interleaved: boolean | { field: "reasoning" | "reasoning_content" | "reasoning_details" }
|
||||
}
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
tiers?: {
|
||||
input: number
|
||||
output: number
|
||||
cache: { read: number; write: number }
|
||||
tier: { type: "context"; size: number }
|
||||
}[]
|
||||
experimentalOver200K?: {
|
||||
input: number
|
||||
output: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
limit: {
|
||||
context: number
|
||||
input?: number
|
||||
output: number
|
||||
}
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
options: Record<string, unknown>
|
||||
headers: Record<string, string>
|
||||
release_date: string
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
env: string[]
|
||||
key?: string
|
||||
options: Record<string, unknown>
|
||||
models: Record<string, Model>
|
||||
}
|
||||
|
||||
export type ProviderListResponse = {
|
||||
all: Map<string, Provider>
|
||||
default: Record<string, string>
|
||||
connected: string[]
|
||||
}
|
||||
|
||||
export type ProviderAuthResponse = Record<string, unknown>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Decode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function decode64(value: string | undefined) {
|
||||
if (value === undefined) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform, usePlatform } from "@/context/platform"
|
||||
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@/types"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
|
||||
@@ -22,6 +22,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -41,8 +42,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -109,7 +109,7 @@ for (const item of targets) {
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -63,28 +64,22 @@ try {
|
||||
})
|
||||
if (unauthorizedOpenApi.status !== 401)
|
||||
throw new Error("Compiled service exposed application routes without authentication")
|
||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
||||
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
||||
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
|
||||
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const loser = processes.find((process) => process.pid !== info.pid)
|
||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||
|
||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
||||
await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then((response) => response.json()),
|
||||
await Effect.runPromise(
|
||||
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
|
||||
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Manage authentication",
|
||||
description: "manage AI providers and credentials",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to a well-known authentication provider",
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("login", {
|
||||
description: "log in to a provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out from a configured provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -145,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to export to stdout"),
|
||||
Flag.optional,
|
||||
),
|
||||
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
@@ -254,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("stop", { description: "Stop the background server" }),
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.string("value").pipe(
|
||||
Argument.withDescription("Setting value or environment variable name"),
|
||||
),
|
||||
nestedValue: Argument.string("env-value").pipe(
|
||||
Argument.withDescription("Environment variable value"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: { key: Argument.string("key") },
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
|
||||
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
|
||||
const skip = Symbol("skip")
|
||||
const custom = Symbol("custom")
|
||||
|
||||
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
|
||||
if (!fields) return undefined
|
||||
yield* requireInteractive("Authentication form input requires an interactive terminal")
|
||||
const answer: FormAnswer = {}
|
||||
for (const field of fields) {
|
||||
if (!active(field, answer)) continue
|
||||
const value = yield* answerField(field)
|
||||
if (value !== undefined) answer[field.key] = value
|
||||
}
|
||||
return answer
|
||||
})
|
||||
|
||||
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
|
||||
yield* requireInteractive("API key input requires an interactive terminal")
|
||||
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
|
||||
})
|
||||
|
||||
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
|
||||
const message = field.title ?? field.key
|
||||
if (field.description) log.info(field.description)
|
||||
if (field.type === "external") {
|
||||
log.info(field.url)
|
||||
yield* openUrl(field.url)
|
||||
const acknowledged = yield* prompt<boolean>(() =>
|
||||
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
|
||||
)
|
||||
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
|
||||
return true
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
|
||||
const options: Array<Option<boolean | typeof skip>> = [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
{ value: skip, label: "Skip" },
|
||||
]
|
||||
const value = yield* prompt<boolean | typeof skip>(() =>
|
||||
select<boolean | typeof skip>({
|
||||
message,
|
||||
options,
|
||||
initialValue: field.default ?? skip,
|
||||
}),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
return value
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type another value" })
|
||||
const values = yield* prompt<Array<string | typeof custom>>(() =>
|
||||
multiselect<string | typeof custom>({
|
||||
message,
|
||||
options,
|
||||
initialValues: field.default,
|
||||
required: field.required || (field.minItems ?? 0) > 0,
|
||||
}),
|
||||
)
|
||||
const selected = values.filter((value): value is string => value !== custom)
|
||||
if (values.includes(custom)) {
|
||||
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
|
||||
}
|
||||
if (field.minItems !== undefined && selected.length < field.minItems) {
|
||||
return yield* Effect.fail(new Error(`Select at least ${field.minItems}`))
|
||||
}
|
||||
if (field.maxItems !== undefined && selected.length > field.maxItems) {
|
||||
return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`))
|
||||
}
|
||||
return selected
|
||||
}
|
||||
if (field.type === "string" && field.options) {
|
||||
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
|
||||
if (!field.required) options.push({ value: skip, label: "Skip" })
|
||||
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
|
||||
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
if (value !== custom) return value
|
||||
}
|
||||
const value = yield* prompt<string>(() =>
|
||||
text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
)
|
||||
if (!value && !field.required) return undefined
|
||||
if (field.type === "string") return value
|
||||
return Number(value)
|
||||
})
|
||||
|
||||
function active(field: FormField, answer: FormAnswer) {
|
||||
if (field.type === "external" || !field.when) return true
|
||||
return field.when.every((condition) => {
|
||||
const value = answer[condition.key]
|
||||
if (value === undefined) return false
|
||||
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
|
||||
return condition.op === "eq" ? matches : !matches
|
||||
})
|
||||
}
|
||||
|
||||
function required(value: string | undefined) {
|
||||
return value ? undefined : "Required"
|
||||
}
|
||||
|
||||
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
|
||||
if (!value) return field.required ? "Required" : undefined
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return undefined
|
||||
}
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
|
||||
} catch {
|
||||
return "Invalid format"
|
||||
}
|
||||
}
|
||||
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "date") {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations } from "./shared"
|
||||
import { errorMessage } from "../../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
|
||||
list(input).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("cli.auth.list")(function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
integrations.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
connections: integration.connections,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rows = integrations.flatMap((integration) =>
|
||||
integration.connections.map((connection) => ({
|
||||
integration: integration.name,
|
||||
source: connection.type === "credential" ? connection.label : connection.name,
|
||||
type: connection.type === "credential" ? "stored" : "environment",
|
||||
})),
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No authenticated integrations" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
|
||||
process.stdout.write(
|
||||
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
|
||||
)
|
||||
})
|
||||
@@ -1,52 +1,267 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm, secret } from "./form"
|
||||
import {
|
||||
createClient,
|
||||
connectMethods,
|
||||
loadIntegrations,
|
||||
location,
|
||||
request,
|
||||
resolveIntegration,
|
||||
resolveMethod,
|
||||
type ConnectMethod,
|
||||
} from "./shared"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.login,
|
||||
Effect.fn("cli.auth.login")(function* (input) {
|
||||
process.stdout.write("Logging in..." + EOL + EOL)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||
const integrationID = input.url.replace(/\/+$/, "")
|
||||
const started = yield* request(() =>
|
||||
client.integration.command.connect({ integrationID, methodID: "login", location }),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||
process.stdout.write("Logged in" + EOL)
|
||||
}),
|
||||
Effect.fn("cli.auth.login")((input) =>
|
||||
login({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
method: Option.getOrUndefined(input.method),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const wait = (
|
||||
const login = Effect.fn("cli.auth.login.run")(function* (input: {
|
||||
target?: string
|
||||
method?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Connect an integration")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integration = yield* findIntegration(client, input.target)
|
||||
const methods = connectMethods(integration)
|
||||
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
|
||||
const method = yield* chooseMethod(methods, input.method)
|
||||
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
|
||||
yield* authenticate(client, integration, method, answer)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
|
||||
if (target && URL.canParse(target)) {
|
||||
const protocol = new URL(target).protocol
|
||||
if (protocol === "http:" || protocol === "https:") {
|
||||
const progress = spinner()
|
||||
progress.start("Discovering authentication provider...")
|
||||
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
|
||||
)
|
||||
}
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
.filter((integration) => connectMethods(integration).length > 0)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
|
||||
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: available.map((integration) => {
|
||||
const option = { value: integration.id, label: integration.name, hint: integration.id }
|
||||
if (integration.connections.length > 0) return { ...option, hint: "connected" }
|
||||
if (integration.id === "opencode") return { ...option, hint: "recommended" }
|
||||
return option
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(available, id)
|
||||
})
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
if (methods.length === 1) return methods[0]
|
||||
yield* requireInteractive("Pass --method when running without an interactive terminal")
|
||||
const id = yield* prompt<string>(() =>
|
||||
select({
|
||||
message: "Select login method",
|
||||
options: methods.map((method) => {
|
||||
if (method.type === "key") return { value: "key", label: method.label ?? "API key" }
|
||||
return { value: method.id, label: method.label }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveMethod(methods, id)
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: ConnectMethod,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
|
||||
if (method.type === "command") return yield* commandLogin(client, integration, method)
|
||||
return yield* oauthLogin(client, integration, method, answer)
|
||||
})
|
||||
|
||||
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
|
||||
const progress = spinner()
|
||||
progress.start("Saving credential...")
|
||||
yield* request((signal) =>
|
||||
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "oauth" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authorization...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.oauth.cancel(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
progress.stop("Authorization started")
|
||||
log.info(attempt.instructions)
|
||||
log.info(attempt.url)
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
|
||||
|
||||
if (attempt.mode === "code") {
|
||||
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
|
||||
const code = yield* prompt<string>(() =>
|
||||
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
|
||||
)
|
||||
const completing = spinner()
|
||||
completing.start("Completing authorization...")
|
||||
yield* request((signal) =>
|
||||
client.integration.oauth.complete(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const waiting = spinner()
|
||||
waiting.start("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
|
||||
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
|
||||
)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
waiting.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authorization expired"))
|
||||
})
|
||||
|
||||
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "command" }>,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authentication command...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel(
|
||||
{
|
||||
integrationID: integration.id,
|
||||
attemptID: started.data.attemptID,
|
||||
location,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
|
||||
progress.message(message.trim() || "Waiting for authentication command..."),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
progress.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authentication expired"))
|
||||
})
|
||||
|
||||
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
shown = false,
|
||||
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
const output = response.data.message?.trim()
|
||||
if (!shown && output) process.stdout.write(output + EOL + EOL)
|
||||
yield* Effect.sleep(500)
|
||||
return yield* wait(client, integrationID, attemptID, shown || !!output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: () => Promise<A>) {
|
||||
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||
}
|
||||
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
update: (message: string) => void,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
if (response.data.message) update(response.data.message)
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Remove credential")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length) {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
const progress = spinner()
|
||||
progress.start("Removing credential...")
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) =>
|
||||
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export const location = { directory: process.cwd() }
|
||||
|
||||
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
|
||||
const server = yield* ServerConnection.resolve(input)
|
||||
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
|
||||
})
|
||||
|
||||
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
)
|
||||
})
|
||||
|
||||
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target: string,
|
||||
) {
|
||||
const normalized = target.replace(/\/+$/, "")
|
||||
const byID = integrations.find((integration) => integration.id === normalized)
|
||||
if (byID) return byID
|
||||
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
|
||||
})
|
||||
|
||||
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
const normalized = target.toLowerCase()
|
||||
const matches = methods.filter((method) => {
|
||||
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
|
||||
return method.id === target || method.label.toLowerCase() === normalized
|
||||
})
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
|
||||
const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ")
|
||||
return yield* Effect.fail(
|
||||
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
|
||||
)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const serviceStarts = yield* Queue.unbounded<{
|
||||
readonly reason: "missing" | "version-mismatch"
|
||||
readonly previousVersion?: string
|
||||
}>()
|
||||
yield* Queue.take(serviceStarts).pipe(
|
||||
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: requestedServer,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
|
||||
@@ -1,140 +1,88 @@
|
||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
||||
import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { emitKeypressEvents, type Key } from "node:readline"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.export,
|
||||
Effect.fn("cli.export")(function* (input) {
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
const selected = requested
|
||||
? undefined
|
||||
: yield* Effect.promise(async () => {
|
||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||
const page = await client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
Effect.fn("cli.export")((input) =>
|
||||
Effect.gen(function* () {
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
if (!requested && !process.stdin.isTTY) {
|
||||
yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal"))
|
||||
}
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const sessionID = requested
|
||||
? requested
|
||||
: yield* Effect.gen(function* () {
|
||||
intro("Export session", { output: process.stderr })
|
||||
const location = yield* Effect.tryPromise({
|
||||
try: () => client.location.get({ location: { directory: process.cwd() } }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
const page = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
log.error("No sessions found", { output: process.stderr })
|
||||
outro("Done", { output: process.stderr })
|
||||
return undefined
|
||||
}
|
||||
const selected = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: page.data.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (isCancel(selected)) {
|
||||
cancel("Cancelled", { output: process.stderr })
|
||||
process.exitCode = 130
|
||||
return undefined
|
||||
}
|
||||
outro("Exporting session...", { output: process.stderr })
|
||||
return selected
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
process.stderr.write(`No sessions found${EOL}`)
|
||||
return undefined
|
||||
}
|
||||
return selectSession(page.data, input.sanitize)
|
||||
})
|
||||
const sessionID = requested ?? selected?.session.id
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.promise(() =>
|
||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
||||
)
|
||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
||||
}),
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
||||
|
||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
||||
const input = process.stdin
|
||||
const output = process.stderr
|
||||
const wasRaw = input.isRaw
|
||||
const wasPaused = input.isPaused()
|
||||
const date = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const columns = output.columns ?? 100
|
||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
||||
let selected = 0
|
||||
let offset = 0
|
||||
let sanitize = initialSanitize
|
||||
let height = 0
|
||||
|
||||
const render = () => {
|
||||
const visible = sessions.slice(offset, offset + 10)
|
||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
||||
lines.push(
|
||||
...visible.map((session) => {
|
||||
const index = sessions.indexOf(session)
|
||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
||||
}),
|
||||
"",
|
||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
||||
"",
|
||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
||||
)
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write(lines.join(EOL) + EOL)
|
||||
height = lines.length
|
||||
}
|
||||
const clear = () => {
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write("\x1b[?25h")
|
||||
input.removeListener("keypress", onKeypress)
|
||||
input.setRawMode(wasRaw ?? false)
|
||||
if (wasPaused) input.pause()
|
||||
}
|
||||
const onKeypress = (value: string | undefined, key: Key) => {
|
||||
if (key.name === "up") {
|
||||
selected = (selected - 1 + sessions.length) % sessions.length
|
||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
||||
if (selected < offset) offset = selected
|
||||
}
|
||||
if (key.name === "down") {
|
||||
selected = (selected + 1) % sessions.length
|
||||
if (selected === 0) offset = 0
|
||||
if (selected >= offset + 10) offset = selected - 9
|
||||
}
|
||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
||||
if (key.name === "return") return finish(sessions[selected])
|
||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
||||
render()
|
||||
}
|
||||
const finish = (session: SessionInfo) => {
|
||||
clear()
|
||||
resolveSelection?.({ session, sanitize })
|
||||
}
|
||||
const cancel = () => {
|
||||
clear()
|
||||
resolveSelection?.()
|
||||
}
|
||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
||||
|
||||
emitKeypressEvents(input)
|
||||
input.setRawMode(true)
|
||||
input.resume()
|
||||
input.on("keypress", onKeypress)
|
||||
output.write("\x1b[?25l")
|
||||
render()
|
||||
return new Promise<Selection | undefined>((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
||||
const json = JSON.stringify(data, null, 2) + EOL
|
||||
if (stdout) return json
|
||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
||||
await Bun.write(file, json)
|
||||
return file + EOL
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
@@ -14,11 +14,18 @@ export default Runtime.handler(
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.get,
|
||||
Effect.fn("cli.service.get")(function* (input) {
|
||||
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
|
||||
process.stdout.write(
|
||||
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user