mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-14 01:44:46 +00:00
Compare commits
56 Commits
kit/effect
...
oc-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5124c730 | ||
|
|
7db7ff3889 | ||
|
|
9cf7510223 | ||
|
|
506319ac03 | ||
|
|
988a80ccb2 | ||
|
|
b040f36320 | ||
|
|
1819a03be8 | ||
|
|
aca2991d91 | ||
|
|
1f3bea91fd | ||
|
|
489661f1f4 | ||
|
|
cb1a50055c | ||
|
|
65e3348232 | ||
|
|
a6b9f0dac1 | ||
|
|
34f5bdbc99 | ||
|
|
0b4fe14b0a | ||
|
|
7230cd2683 | ||
|
|
a915fe74be | ||
|
|
26d35583c5 | ||
|
|
ae17b416b8 | ||
|
|
8ffadde85c | ||
|
|
3c0ad70653 | ||
|
|
264418c0cd | ||
|
|
fa2c69f09c | ||
|
|
113304a058 | ||
|
|
8c4d49c2bc | ||
|
|
2aa6110c6e | ||
|
|
8b9b9ad31e | ||
|
|
3729fd5706 | ||
|
|
74b14a2d4e | ||
|
|
cdb951ec2f | ||
|
|
fc01cad2b8 | ||
|
|
c1ddc0ea2d | ||
|
|
319b7655b7 | ||
|
|
824c12c01a | ||
|
|
17b2900884 | ||
|
|
003010bdb6 | ||
|
|
82a4292934 | ||
|
|
eea4253d67 | ||
|
|
1eacc3c339 | ||
|
|
1a509d62a0 | ||
|
|
4c4eef46f1 | ||
|
|
d62ec7776e | ||
|
|
cb1e5d9e41 | ||
|
|
ca5f086759 | ||
|
|
57c40eb7c2 | ||
|
|
63035f977f | ||
|
|
514d2a36bc | ||
|
|
0b6fd5f612 | ||
|
|
029e7135b7 | ||
|
|
c43591f8a2 | ||
|
|
a2c22714cb | ||
|
|
312f10f797 | ||
|
|
d1f05b0f3a | ||
|
|
ccb0b320e1 | ||
|
|
5ee7edaf9e | ||
|
|
27190635ea |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -26,6 +26,7 @@ kommander
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-robinmordasiewicz
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-toastythebot
|
||||
|
||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -213,7 +213,6 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -390,7 +389,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -591,13 +590,12 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
|
||||
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
|
||||
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
|
||||
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
|
||||
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
|
||||
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
|
||||
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
|
||||
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.46",
|
||||
"ai": "6.0.149",
|
||||
"ai": "6.0.158",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
||||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
props.loadMore()
|
||||
|
||||
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
|
||||
@@ -316,7 +316,8 @@
|
||||
|
||||
/* Download Hero Section */
|
||||
[data-component="download-hero"] {
|
||||
display: grid;
|
||||
/* display: grid; */
|
||||
display: none;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 4rem;
|
||||
padding-bottom: 2rem;
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: true,
|
||||
show: false,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
|
||||
wireZoom(win)
|
||||
injectGlobals(win, globals)
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,11 @@
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
"lint": "echo 'Running lint checks...' && bun test --coverage",
|
||||
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
|
||||
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
|
||||
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
|
||||
"db": "bun drizzle-kit"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"randomField": "this-is-a-random-value-12345",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -39,6 +32,11 @@
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#hono": {
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -78,7 +76,8 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||
"@ai-sdk/anthropic": "3.0.67",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
@@ -90,7 +89,7 @@
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
@@ -100,7 +99,6 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
@@ -116,9 +114,9 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@22d015f",
|
||||
"@opentui/solid": "https://pkg.pr.new/@opentui/solid@22d015f",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -217,6 +217,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
- [x] `SessionSummary` — `session/summary.ts`
|
||||
- [x] `SessionRevert` — `session/revert.ts`
|
||||
- [x] `Instruction` — `session/instruction.ts`
|
||||
- [x] `SystemPrompt` — `session/system.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
@@ -229,24 +230,24 @@ Still open:
|
||||
|
||||
## Tool interface → Effect
|
||||
|
||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
|
||||
|
||||
1. Migrate each tool to return Effects
|
||||
2. Update `Tool.define()` factory to work with Effects
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
||||
1. Migrate each tool body to return Effects
|
||||
2. Keep `Tool.define()` inputs Effect-native
|
||||
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
||||
|
||||
### Tool migration details
|
||||
|
||||
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
|
||||
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
|
||||
|
||||
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
||||
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
|
||||
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
|
||||
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
|
||||
|
||||
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
||||
|
||||
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
|
||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
|
||||
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
||||
|
||||
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
||||
@@ -340,3 +341,47 @@ For each service, the migration is roughly:
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
|
||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||
|
||||
## Route handler effectification
|
||||
|
||||
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
||||
|
||||
```ts
|
||||
// Before — one facade call per service
|
||||
;async (c) => {
|
||||
await SessionRunState.assertNotBusy(id)
|
||||
await Session.removeMessage({ sessionID: id, messageID })
|
||||
return c.json(true)
|
||||
}
|
||||
|
||||
// After — one Effect.gen, yield services from context
|
||||
;async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(id)
|
||||
yield* session.removeMessage({ sessionID: id, messageID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
}
|
||||
```
|
||||
|
||||
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
|
||||
|
||||
Route files to convert (each handler that calls facades should be wrapped):
|
||||
|
||||
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
|
||||
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
|
||||
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
|
||||
- [ ] `server/routes/question.ts` — uses Question
|
||||
- [ ] `server/routes/pty.ts` — uses Pty
|
||||
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
@@ -454,18 +453,4 @@ export namespace Account {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
||||
return runPromise((service) => service.orgsByAccount())
|
||||
}
|
||||
|
||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,13 +398,11 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
),
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||
await AppRuntime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => switchEffect())
|
||||
await AppRuntime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => orgsEffect())
|
||||
await AppRuntime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => openEffect())
|
||||
await AppRuntime.runPromise(openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -148,6 +148,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
if (method.authorize) {
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
@@ -157,7 +163,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
key: result.key ?? key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
// CLI entry point for `opencode run`.
|
||||
//
|
||||
// Handles three modes:
|
||||
// 1. Non-interactive (default): sends a single prompt, streams events to
|
||||
// stdout, and exits when the session goes idle.
|
||||
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
|
||||
// with an in-process server (no external HTTP).
|
||||
// 3. Interactive attach (`--interactive --attach`): connects to a running
|
||||
// opencode server and runs interactive mode against it.
|
||||
//
|
||||
// Also supports `--command` for slash-command execution, `--format json` for
|
||||
// raw event streaming, `--continue` / `--session` for session resumption,
|
||||
// and `--fork` for forking before continuing.
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -8,39 +21,26 @@ import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Permission } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
import { ListTool } from "../../tool/ls"
|
||||
import { ReadTool } from "../../tool/read"
|
||||
import { WebFetchTool } from "../../tool/webfetch"
|
||||
import { EditTool } from "../../tool/edit"
|
||||
import { WriteTool } from "../../tool/write"
|
||||
import { CodeSearchTool } from "../../tool/codesearch"
|
||||
import { WebSearchTool } from "../../tool/websearch"
|
||||
import { TaskTool } from "../../tool/task"
|
||||
import { SkillTool } from "../../tool/skill"
|
||||
import { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "../../util/locale"
|
||||
import type { RunDemo } from "./run/types"
|
||||
|
||||
type ToolProps<T> = {
|
||||
input: Tool.InferParameters<T>
|
||||
metadata: Tool.InferMetadata<T>
|
||||
part: ToolPart
|
||||
const runtimeTask = import("./run/runtime")
|
||||
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
function pick(value: string | undefined): ModelInput | undefined {
|
||||
if (!value) return undefined
|
||||
const [providerID, ...rest] = value.split("/")
|
||||
return {
|
||||
providerID,
|
||||
modelID: rest.join("/"),
|
||||
} as ModelInput
|
||||
}
|
||||
|
||||
function props<T>(part: ToolPart): ToolProps<T> {
|
||||
const state = part.state
|
||||
return {
|
||||
input: state.input as Tool.InferParameters<T>,
|
||||
metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
|
||||
part,
|
||||
}
|
||||
type FilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Inline = {
|
||||
@@ -49,6 +49,12 @@ type Inline = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SessionInfo = {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function inline(info: Inline) {
|
||||
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
|
||||
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
|
||||
@@ -62,160 +68,22 @@ function block(info: Inline, output?: string) {
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
function fallback(part: ToolPart) {
|
||||
const state = part.state
|
||||
const input = "input" in state ? state.input : undefined
|
||||
const title =
|
||||
("title" in state && state.title ? state.title : undefined) ||
|
||||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
|
||||
inline({
|
||||
icon: "⚙",
|
||||
title: `${part.tool} ${title}`,
|
||||
})
|
||||
}
|
||||
async function tool(part: ToolPart) {
|
||||
try {
|
||||
const { toolInlineInfo } = await import("./run/tool")
|
||||
const next = toolInlineInfo(part)
|
||||
if (next.mode === "block") {
|
||||
block(next, next.body)
|
||||
return
|
||||
}
|
||||
|
||||
function glob(info: ToolProps<typeof GlobTool>) {
|
||||
const root = info.input.path ?? ""
|
||||
const title = `Glob "${info.input.pattern}"`
|
||||
const suffix = root ? `in ${normalizePath(root)}` : ""
|
||||
const num = info.metadata.count
|
||||
const description =
|
||||
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
|
||||
inline({
|
||||
icon: "✱",
|
||||
title,
|
||||
...(description && { description }),
|
||||
})
|
||||
}
|
||||
|
||||
function grep(info: ToolProps<typeof GrepTool>) {
|
||||
const root = info.input.path ?? ""
|
||||
const title = `Grep "${info.input.pattern}"`
|
||||
const suffix = root ? `in ${normalizePath(root)}` : ""
|
||||
const num = info.metadata.matches
|
||||
const description =
|
||||
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
|
||||
inline({
|
||||
icon: "✱",
|
||||
title,
|
||||
...(description && { description }),
|
||||
})
|
||||
}
|
||||
|
||||
function list(info: ToolProps<typeof ListTool>) {
|
||||
const dir = info.input.path ? normalizePath(info.input.path) : ""
|
||||
inline({
|
||||
icon: "→",
|
||||
title: dir ? `List ${dir}` : "List",
|
||||
})
|
||||
}
|
||||
|
||||
function read(info: ToolProps<typeof ReadTool>) {
|
||||
const file = normalizePath(info.input.filePath)
|
||||
const pairs = Object.entries(info.input).filter(([key, value]) => {
|
||||
if (key === "filePath") return false
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
})
|
||||
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
|
||||
inline({
|
||||
icon: "→",
|
||||
title: `Read ${file}`,
|
||||
...(description && { description }),
|
||||
})
|
||||
}
|
||||
|
||||
function write(info: ToolProps<typeof WriteTool>) {
|
||||
block(
|
||||
{
|
||||
icon: "←",
|
||||
title: `Write ${normalizePath(info.input.filePath)}`,
|
||||
},
|
||||
info.part.state.status === "completed" ? info.part.state.output : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function webfetch(info: ToolProps<typeof WebFetchTool>) {
|
||||
inline({
|
||||
icon: "%",
|
||||
title: `WebFetch ${info.input.url}`,
|
||||
})
|
||||
}
|
||||
|
||||
function edit(info: ToolProps<typeof EditTool>) {
|
||||
const title = normalizePath(info.input.filePath)
|
||||
const diff = info.metadata.diff
|
||||
block(
|
||||
{
|
||||
icon: "←",
|
||||
title: `Edit ${title}`,
|
||||
},
|
||||
diff,
|
||||
)
|
||||
}
|
||||
|
||||
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
|
||||
inline({
|
||||
icon: "◇",
|
||||
title: `Exa Code Search "${info.input.query}"`,
|
||||
})
|
||||
}
|
||||
|
||||
function websearch(info: ToolProps<typeof WebSearchTool>) {
|
||||
inline({
|
||||
icon: "◈",
|
||||
title: `Exa Web Search "${info.input.query}"`,
|
||||
})
|
||||
}
|
||||
|
||||
function task(info: ToolProps<typeof TaskTool>) {
|
||||
const input = info.part.state.input
|
||||
const status = info.part.state.status
|
||||
const subagent =
|
||||
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
|
||||
const agent = Locale.titlecase(subagent)
|
||||
const desc =
|
||||
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
|
||||
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
|
||||
const name = desc ?? `${agent} Task`
|
||||
inline({
|
||||
icon,
|
||||
title: name,
|
||||
description: desc ? `${agent} Agent` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function skill(info: ToolProps<typeof SkillTool>) {
|
||||
inline({
|
||||
icon: "→",
|
||||
title: `Skill "${info.input.name}"`,
|
||||
})
|
||||
}
|
||||
|
||||
function bash(info: ToolProps<typeof BashTool>) {
|
||||
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
|
||||
block(
|
||||
{
|
||||
icon: "$",
|
||||
title: `${info.input.command}`,
|
||||
},
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
function todo(info: ToolProps<typeof TodoWriteTool>) {
|
||||
block(
|
||||
{
|
||||
icon: "#",
|
||||
title: "Todos",
|
||||
},
|
||||
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
|
||||
return input
|
||||
inline(next)
|
||||
} catch {
|
||||
inline({
|
||||
icon: "⚙",
|
||||
title: part.tool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunCommand = cmd({
|
||||
@@ -300,6 +168,11 @@ export const RunCommand = cmd({
|
||||
.option("thinking", {
|
||||
type: "boolean",
|
||||
describe: "show thinking blocks",
|
||||
})
|
||||
.option("interactive", {
|
||||
alias: ["i"],
|
||||
type: "boolean",
|
||||
describe: "run in direct interactive split-footer mode",
|
||||
default: false,
|
||||
})
|
||||
.option("dangerously-skip-permissions", {
|
||||
@@ -307,30 +180,89 @@ export const RunCommand = cmd({
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
})
|
||||
.option("demo", {
|
||||
type: "string",
|
||||
choices: ["on", "permission", "question", "mix", "text"],
|
||||
describe: "enable direct interactive demo slash commands",
|
||||
})
|
||||
.option("demo-text", {
|
||||
type: "string",
|
||||
describe: "text used with --demo text",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
if (args.interactive && args.command) {
|
||||
UI.error("--interactive cannot be used with --command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.demo && !args.interactive) {
|
||||
UI.error("--demo requires --interactive")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.demoText && args.demo !== "text") {
|
||||
UI.error("--demo-text requires --demo text")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && args.format === "json") {
|
||||
UI.error("--interactive cannot be used with --format json")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdin.isTTY) {
|
||||
UI.error("--interactive requires a TTY")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.interactive && !process.stdout.isTTY) {
|
||||
UI.error("--interactive requires a TTY stdout")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (!args.dir) return args.attach ? undefined : root
|
||||
if (args.attach) return args.dir
|
||||
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
const attachHeaders = (() => {
|
||||
if (!args.attach) return undefined
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const attachSDK = (dir?: string) => {
|
||||
return createOpencodeClient({
|
||||
baseUrl: args.attach!,
|
||||
directory: dir,
|
||||
headers: attachHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
const files: FilePart[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
@@ -349,7 +281,7 @@ export const RunCommand = cmd({
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
if (message.trim().length === 0 && !args.command && !args.interactive) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -359,23 +291,25 @@ export const RunCommand = cmd({
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
const rules: Permission.Ruleset = args.interactive
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
@@ -383,19 +317,83 @@ export const RunCommand = cmd({
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
if (!current?.data) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: args.session,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? current.data.title,
|
||||
directory: forked.data?.directory ?? current.data.directory,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: current.data.id,
|
||||
title: current.data.title,
|
||||
directory: current.data.directory,
|
||||
}
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
||||
|
||||
if (base && args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: base.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? base.title,
|
||||
directory: forked.data?.directory ?? base.directory,
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
directory: base.directory,
|
||||
}
|
||||
}
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
const result = await sdk.session.create({
|
||||
title: name,
|
||||
permission: rules,
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title ?? name,
|
||||
directory: result.data?.directory,
|
||||
}
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
@@ -413,45 +411,122 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "list") return list(props<typeof ListTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
|
||||
return fallback(part)
|
||||
} catch {
|
||||
return fallback(part)
|
||||
}
|
||||
async function current(sdk: OpencodeClient): Promise<string> {
|
||||
if (!args.attach) {
|
||||
return directory ?? root
|
||||
}
|
||||
|
||||
const next = await sdk.path
|
||||
.get()
|
||||
.then((x) => x.data?.directory)
|
||||
.catch(() => undefined)
|
||||
if (next) {
|
||||
return next
|
||||
}
|
||||
|
||||
UI.error("Failed to resolve remote directory")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function localAgent() {
|
||||
if (!args.agent) return undefined
|
||||
|
||||
const entry = await (await import("../../agent/agent")).Agent.get(args.agent)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return args.agent
|
||||
}
|
||||
|
||||
async function attachAgent(sdk: OpencodeClient) {
|
||||
if (!args.agent) return undefined
|
||||
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === args.agent)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return args.agent
|
||||
}
|
||||
|
||||
async function pickAgent(sdk: OpencodeClient) {
|
||||
if (!args.agent) return undefined
|
||||
if (args.attach) {
|
||||
return attachAgent(sdk)
|
||||
}
|
||||
|
||||
return localAgent()
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
sessionID,
|
||||
...data,
|
||||
}) + EOL,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
// Consume one subscribed event stream for the active session and mirror it
|
||||
// to stdout/UI. `client` is passed explicitly because attach mode may
|
||||
// rebind the SDK to the session's directory after the subscription is
|
||||
// created, and replies issued from inside the loop must use that client.
|
||||
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
|
||||
const toggles = new Map<string, boolean>()
|
||||
let error: string | undefined
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
@@ -469,7 +544,7 @@ export const RunCommand = cmd({
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
await tool(part)
|
||||
continue
|
||||
}
|
||||
inline({
|
||||
@@ -486,7 +561,7 @@ export const RunCommand = cmd({
|
||||
args.format !== "json"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
await tool(part)
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
@@ -551,7 +626,7 @@ export const RunCommand = cmd({
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
@@ -561,7 +636,7 @@ export const RunCommand = cmd({
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
@@ -570,119 +645,112 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === args.agent)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return args.agent
|
||||
}
|
||||
|
||||
const entry = await Agent.get(args.agent)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return args.agent
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
const sess = await session(sdk)
|
||||
if (!sess?.id) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await share(sdk, sessionID)
|
||||
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
|
||||
const client = args.attach ? attachSDK(cwd) : sdk
|
||||
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
// Validate agent if specified
|
||||
const agent = await pickAgent(client)
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
const sessionID = sess.id
|
||||
await share(client, sessionID)
|
||||
|
||||
if (!args.interactive) {
|
||||
const events = await client.event.subscribe()
|
||||
loop(client, events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
|
||||
if (args.command) {
|
||||
await client.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = pick(args.model)
|
||||
await client.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = pick(args.model)
|
||||
const { runInteractiveMode } = await runtimeTask
|
||||
await runInteractiveMode({
|
||||
sdk: client,
|
||||
directory: cwd,
|
||||
sessionID,
|
||||
sessionTitle: sess.title,
|
||||
resume: Boolean(args.session) && !args.fork,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking,
|
||||
demo: args.demo as RunDemo | undefined,
|
||||
demoText: args.demoText,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
if (args.interactive && !args.attach && !args.session && !args.continue) {
|
||||
const model = pick(args.model)
|
||||
const { runInteractiveLocalMode } = await runtimeTask
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { Server } = await import("../../server/server")
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
|
||||
return await runInteractiveLocalMode({
|
||||
directory: directory ?? root,
|
||||
fetch: fetchFn,
|
||||
resolveAgent: localAgent,
|
||||
session,
|
||||
share,
|
||||
agent: args.agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
files,
|
||||
initialInput: rawMessage.trim().length > 0 ? rawMessage : undefined,
|
||||
thinking,
|
||||
demo: args.demo as RunDemo | undefined,
|
||||
demoText: args.demoText,
|
||||
})
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const sdk = attachSDK(directory)
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(directory ?? root, async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { Server } = await import("../../server/server")
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: fetchFn,
|
||||
directory,
|
||||
})
|
||||
await execute(sdk)
|
||||
})
|
||||
},
|
||||
|
||||
1105
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
1105
packages/opencode/src/cli/cmd/run/demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
487
packages/opencode/src/cli/cmd/run/footer.permission.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
// Permission UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "permission". Uses a three-stage state machine (permission.shared.ts):
|
||||
//
|
||||
// permission → shows the request with Allow once / Always / Reject buttons
|
||||
// always → confirmation step before granting permanent access
|
||||
// reject → text field for the rejection message
|
||||
//
|
||||
// Keyboard: left/right to select, enter to confirm, esc to reject.
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
permissionCancel,
|
||||
permissionEscape,
|
||||
permissionHover,
|
||||
permissionInfo,
|
||||
permissionLabel,
|
||||
permissionOptions,
|
||||
permissionReject,
|
||||
permissionRun,
|
||||
permissionShift,
|
||||
type PermissionOption,
|
||||
} from "./permission.shared"
|
||||
import { toolDiffView, toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
||||
|
||||
type RejectArea = {
|
||||
isDestroyed: boolean
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
function buttons(
|
||||
list: PermissionOption[],
|
||||
selected: PermissionOption,
|
||||
theme: RunFooterTheme,
|
||||
disabled: boolean,
|
||||
onHover: (option: PermissionOption) => void,
|
||||
onSelect: (option: PermissionOption) => void,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0} paddingBottom={1}>
|
||||
<For each={list}>
|
||||
{(option) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === selected ? theme.highlight : transparent}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) onHover(option)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled) onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function RejectField(props: {
|
||||
theme: RunFooterTheme
|
||||
text: string
|
||||
disabled: boolean
|
||||
onChange: (text: string) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let area: RejectArea | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== props.text) {
|
||||
area.setText(props.text)
|
||||
area.cursorOffset = props.text.length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || props.disabled) {
|
||||
return
|
||||
}
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id="run-direct-footer-permission-reject"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={3}
|
||||
paddingBottom={1}
|
||||
wrapMode="word"
|
||||
placeholder="Tell OpenCode what to do differently"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!props.disabled}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
props.onChange(area.plainText)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
|
||||
event.preventDefault()
|
||||
props.onConfirm()
|
||||
}
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item as RejectArea
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: PermissionRequest
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
onReply: (input: PermissionReply) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
||||
const info = createMemo(() => permissionInfo(props.request))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const view = createMemo(() => toolDiffView(dims().width, props.diffStyle))
|
||||
const narrow = createMemo(() => dims().width < 80)
|
||||
const opts = createMemo(() => permissionOptions(state().stage))
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
return "Always allow"
|
||||
}
|
||||
|
||||
if (state().stage === "reject") {
|
||||
return "Reject permission"
|
||||
}
|
||||
|
||||
return "Permission required"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = props.request.id
|
||||
if (state().requestID === id) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(createPermissionBodyState(id))
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setState((prev) => permissionShift(prev, dir))
|
||||
}
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: true,
|
||||
}))
|
||||
|
||||
try {
|
||||
await props.onReply(next)
|
||||
} catch {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
submitting: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const run = (option: PermissionOption) => {
|
||||
const cur = state()
|
||||
const next = permissionRun(cur, props.request.id, option)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next.reply)
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
const next = permissionReject(state(), props.request.id)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
void submit(next)
|
||||
}
|
||||
|
||||
const cancelReject = () => {
|
||||
setState((prev) => permissionCancel(prev))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.stage === "reject") {
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.submitting) {
|
||||
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab") {
|
||||
shift(event.shift ? -1 : 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "left" || event.name === "h") {
|
||||
shift(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "right" || event.name === "l") {
|
||||
shift(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
run(state().selected)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name !== "escape") {
|
||||
return
|
||||
}
|
||||
|
||||
setState((prev) => permissionEscape(prev))
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
id="run-direct-footer-permission-head"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>△</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2}>
|
||||
<text fg={props.theme.muted} flexShrink={0}>
|
||||
{info().icon}
|
||||
</text>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info().title}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state().stage === "reject"}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={state().stage !== "reject"}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
|
||||
<box
|
||||
id="run-direct-footer-permission-reject-bar"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.line}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
>
|
||||
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
|
||||
<RejectField
|
||||
theme={props.theme}
|
||||
text={state().message}
|
||||
disabled={busy()}
|
||||
onChange={(text) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
message: text,
|
||||
}))
|
||||
}}
|
||||
onConfirm={reject}
|
||||
onCancel={cancelReject}
|
||||
/>
|
||||
</box>
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
diff={info().diff!}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.text}
|
||||
addedBg={props.block.diffAddedBg}
|
||||
removedBg={props.block.diffRemovedBg}
|
||||
contextBg={props.block.diffContextBg}
|
||||
addedSignColor={props.block.diffHighlightAdded}
|
||||
removedSignColor={props.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.block.diffLineNumber}
|
||||
lineNumberBg={props.block.diffContextBg}
|
||||
addedLineNumberBg={props.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!info().diff && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-permission-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.pane}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
{buttons(
|
||||
opts(),
|
||||
state().selected,
|
||||
props.theme,
|
||||
busy(),
|
||||
(option) => {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
)}
|
||||
<Show
|
||||
when={!busy()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
|
||||
Waiting for permission event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0} paddingBottom={1}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
977
packages/opencode/src/cli/cmd/run/footer.prompt.tsx
Normal file
@@ -0,0 +1,977 @@
|
||||
// Prompt textarea component and its state machine for direct interactive mode.
|
||||
//
|
||||
// createPromptState() wires keybinds, history navigation, leader-key sequences,
|
||||
// and direct-mode `@` autocomplete for files, subagents, and MCP resources.
|
||||
// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
|
||||
// while RunPromptAutocomplete renders a fixed-height suggestion list below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import {
|
||||
Index,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import {
|
||||
createPromptHistory,
|
||||
isExitCommand,
|
||||
movePromptHistory,
|
||||
promptCycle,
|
||||
promptHit,
|
||||
promptInfo,
|
||||
promptKeys,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
|
||||
const LEADER_TIMEOUT_MS = 2000
|
||||
const AUTOCOMPLETE_ROWS = 6
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
export const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1
|
||||
|
||||
export const HINT_BREAKPOINTS = {
|
||||
send: 50,
|
||||
newline: 66,
|
||||
history: 80,
|
||||
variant: 95,
|
||||
}
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
type Auto = {
|
||||
display: string
|
||||
value: string
|
||||
part: Mention
|
||||
description?: string
|
||||
directory?: boolean
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
resources: Accessor<RunResource[]>
|
||||
keybinds: FooterKeybinds
|
||||
state: Accessor<FooterState>
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
width: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
history?: RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
|
||||
export type PromptState = {
|
||||
placeholder: Accessor<StyledText | string>
|
||||
bindings: Accessor<KeyBinding[]>
|
||||
visible: Accessor<boolean>
|
||||
options: Accessor<Auto[]>
|
||||
selected: Accessor<number>
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
function clamp(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
}
|
||||
}
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
return hash === -1 ? input : input.slice(0, hash)
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) {
|
||||
return { base: input }
|
||||
}
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const line = input.slice(hash + 1)
|
||||
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) {
|
||||
return { base }
|
||||
}
|
||||
|
||||
const start = Number(match[1])
|
||||
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, line: { start, end } }
|
||||
}
|
||||
|
||||
export function hintFlags(width: number) {
|
||||
return {
|
||||
send: width >= HINT_BREAKPOINTS.send,
|
||||
newline: width >= HINT_BREAKPOINTS.newline,
|
||||
history: width >= HINT_BREAKPOINTS.history,
|
||||
variant: width >= HINT_BREAKPOINTS.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function RunPromptBody(props: {
|
||||
theme: () => RunFooterTheme
|
||||
placeholder: () => StyledText | string
|
||||
bindings: () => KeyBinding[]
|
||||
onSubmit: () => void
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}) {
|
||||
let area: TextareaRenderable | undefined
|
||||
|
||||
onMount(() => {
|
||||
props.bind(area)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
props.bind(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-prompt" width="100%">
|
||||
<box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
|
||||
<textarea
|
||||
id="run-direct-footer-composer"
|
||||
width="100%"
|
||||
minHeight={TEXTAREA_MIN_ROWS}
|
||||
maxHeight={TEXTAREA_MAX_ROWS}
|
||||
wrapMode="word"
|
||||
placeholder={props.placeholder()}
|
||||
placeholderColor={props.theme().muted}
|
||||
textColor={props.theme().text}
|
||||
focusedTextColor={props.theme().text}
|
||||
backgroundColor={props.theme().surface}
|
||||
focusedBackgroundColor={props.theme().surface}
|
||||
cursorColor={props.theme().text}
|
||||
keyBindings={props.bindings()}
|
||||
onSubmit={props.onSubmit}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onContentChange={props.onContentChange}
|
||||
ref={(next) => {
|
||||
area = next
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunPromptAutocomplete(props: {
|
||||
theme: () => RunFooterTheme
|
||||
options: () => Auto[]
|
||||
selected: () => number
|
||||
}) {
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-complete"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
border={["left"]}
|
||||
borderColor={props.theme().border}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-complete-fill"
|
||||
width="100%"
|
||||
height={AUTOCOMPLETE_ROWS}
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme().pane}
|
||||
>
|
||||
<Index
|
||||
each={props.options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={props.theme().muted}>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
|
||||
>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().display}
|
||||
</text>
|
||||
<Show when={item().description}>
|
||||
<text
|
||||
fg={index === props.selected() ? props.theme().surface : props.theme().muted}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{item().description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Index>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function createPromptState(input: PromptInput): PromptState {
|
||||
const keys = createMemo(() => promptKeys(input.keybinds))
|
||||
const bindings = createMemo(() => keys().bindings)
|
||||
const placeholder = createMemo(() => {
|
||||
if (!input.state().first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return new StyledText([
|
||||
bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
|
||||
])
|
||||
})
|
||||
|
||||
let history = createPromptHistory(input.history)
|
||||
let draft: RunPrompt = { text: "", parts: [] }
|
||||
let stash: RunPrompt = { text: "", parts: [] }
|
||||
let area: TextareaRenderable | undefined
|
||||
let leader = false
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
let tick = false
|
||||
let prev = input.view()
|
||||
let type = 0
|
||||
let parts: Mention[] = []
|
||||
let marks = new Map<number, number>()
|
||||
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
const [at, setAt] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const width = createMemo(() => Math.max(20, input.width() - 8))
|
||||
const agents = createMemo<Auto[]>(() => {
|
||||
return input
|
||||
.agents()
|
||||
.filter((item) => !item.hidden && item.mode !== "primary")
|
||||
.map((item) => ({
|
||||
display: "@" + item.name,
|
||||
value: item.name,
|
||||
part: {
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
const resources = createMemo<Auto[]>(() => {
|
||||
return input.resources().map((item) => ({
|
||||
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
|
||||
value: item.name,
|
||||
description: item.description,
|
||||
part: {
|
||||
type: "file",
|
||||
mime: item.mimeType ?? "text/plain",
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
source: {
|
||||
type: "resource",
|
||||
clientName: item.client,
|
||||
uri: item.uri,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
const [files] = createResource(
|
||||
query,
|
||||
async (value) => {
|
||||
if (!visible()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const next = extractLineRange(value)
|
||||
const list = await input.findFiles(next.base)
|
||||
return list
|
||||
.sort((a, b) => {
|
||||
const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
|
||||
if (dir !== 0) {
|
||||
return dir
|
||||
}
|
||||
|
||||
const depth = a.split("/").length - b.split("/").length
|
||||
if (depth !== 0) {
|
||||
return depth
|
||||
}
|
||||
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
.map((item): Auto => {
|
||||
const url = pathToFileURL(path.resolve(input.directory, item))
|
||||
let filename = item
|
||||
if (next.line && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||
url.searchParams.set("start", String(next.line.start))
|
||||
if (next.line.end !== undefined) {
|
||||
url.searchParams.set("end", String(next.line.end))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
display: Locale.truncateMiddle("@" + filename, width()),
|
||||
value: filename,
|
||||
directory: item.endsWith("/"),
|
||||
part: {
|
||||
type: "file",
|
||||
mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
|
||||
filename,
|
||||
url: url.href,
|
||||
source: {
|
||||
type: "file",
|
||||
path: item,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
{ initialValue: [] as Auto[] },
|
||||
)
|
||||
const options = createMemo(() => {
|
||||
const mixed = [...agents(), ...files(), ...resources()]
|
||||
if (!query()) {
|
||||
return mixed.slice(0, AUTOCOMPLETE_ROWS)
|
||||
}
|
||||
|
||||
return fuzzysort
|
||||
.go(removeLineRange(query()), mixed, {
|
||||
keys: [(item) => (item.value || item.display).trimEnd(), "description"],
|
||||
limit: AUTOCOMPLETE_ROWS,
|
||||
})
|
||||
.map((item) => item.obj)
|
||||
})
|
||||
const popup = createMemo(() => {
|
||||
return visible() ? AUTOCOMPLETE_ROWS - 1 : 0
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
leader = false
|
||||
if (!timeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
clear()
|
||||
leader = true
|
||||
timeout = setTimeout(() => {
|
||||
clear()
|
||||
}, LEADER_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
setVisible(false)
|
||||
setQuery("")
|
||||
setSelected(0)
|
||||
}
|
||||
|
||||
const syncRows = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
input.onRows(clamp(area.virtualLineCount || 1) + popup())
|
||||
}
|
||||
|
||||
const scheduleRows = () => {
|
||||
if (tick) {
|
||||
return
|
||||
}
|
||||
|
||||
tick = true
|
||||
queueMicrotask(() => {
|
||||
tick = false
|
||||
syncRows()
|
||||
})
|
||||
}
|
||||
|
||||
const syncParts = () => {
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const next: Mention[] = []
|
||||
const map = new Map<number, number>()
|
||||
for (const item of area.extmarks.getAllForTypeId(type)) {
|
||||
const idx = marks.get(item.id)
|
||||
if (idx === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const part = parts[idx]
|
||||
if (!part) {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = area.plainText.slice(item.start, item.end)
|
||||
const prev =
|
||||
part.type === "agent"
|
||||
? (part.source?.value ?? "@" + part.name)
|
||||
: (part.source?.text.value ?? "@" + (part.filename ?? ""))
|
||||
if (text !== prev) {
|
||||
continue
|
||||
}
|
||||
|
||||
const copy = structuredClone(part)
|
||||
if (copy.type === "agent") {
|
||||
copy.source = {
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
value: text,
|
||||
}
|
||||
}
|
||||
if (copy.type === "file" && copy.source?.text) {
|
||||
copy.source.text.start = item.start
|
||||
copy.source.text.end = item.end
|
||||
copy.source.text.value = text
|
||||
}
|
||||
|
||||
map.set(item.id, next.length)
|
||||
next.push(copy)
|
||||
}
|
||||
|
||||
const stale = map.size !== marks.size
|
||||
parts = next
|
||||
marks = map
|
||||
if (stale) {
|
||||
restoreParts(next)
|
||||
}
|
||||
}
|
||||
|
||||
const clearParts = () => {
|
||||
if (area && !area.isDestroyed) {
|
||||
area.extmarks.clear()
|
||||
}
|
||||
parts = []
|
||||
marks = new Map()
|
||||
}
|
||||
|
||||
const restoreParts = (value: RunPromptPart[]) => {
|
||||
clearParts()
|
||||
parts = value
|
||||
.filter((item): item is Mention => item.type === "file" || item.type === "agent")
|
||||
.map((item) => structuredClone(item))
|
||||
if (!area || area.isDestroyed || type === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const box = area
|
||||
parts.forEach((item, idx) => {
|
||||
const start = item.type === "agent" ? item.source?.start : item.source?.text.start
|
||||
const end = item.type === "agent" ? item.source?.end : item.source?.text.end
|
||||
if (start === undefined || end === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = box.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
virtual: true,
|
||||
typeId: type,
|
||||
})
|
||||
marks.set(id, idx)
|
||||
})
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = value.text.length) => {
|
||||
draft = clonePrompt(value)
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
hide()
|
||||
area.setText(value.text)
|
||||
restoreParts(value.parts)
|
||||
area.cursorOffset = Math.min(cursor, area.plainText.length)
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const text = area.plainText
|
||||
if (visible()) {
|
||||
if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(text.slice(at() + 1, cursor))
|
||||
return
|
||||
}
|
||||
|
||||
if (cursor === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const head = text.slice(0, cursor)
|
||||
const idx = head.lastIndexOf("@")
|
||||
if (idx === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const before = idx === 0 ? undefined : head[idx - 1]
|
||||
const tail = head.slice(idx)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
|
||||
setAt(idx)
|
||||
setSelected(0)
|
||||
setVisible(true)
|
||||
setQuery(head.slice(idx + 1))
|
||||
}
|
||||
}
|
||||
|
||||
const bind = (next?: TextareaRenderable) => {
|
||||
if (area === next) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
|
||||
area = next
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 0) {
|
||||
type = area.extmarks.registerType("run-direct-prompt-part")
|
||||
}
|
||||
area.on("line-info-change", scheduleRows)
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !input.prompt()) {
|
||||
return
|
||||
}
|
||||
|
||||
restore(draft)
|
||||
refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const syncDraft = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
syncParts()
|
||||
draft = {
|
||||
text: area.plainText,
|
||||
parts: structuredClone(parts),
|
||||
}
|
||||
}
|
||||
|
||||
const push = (value: RunPrompt) => {
|
||||
history = pushPromptHistory(history, value)
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1, event: KeyEvent) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
stash = clonePrompt(draft)
|
||||
}
|
||||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
if (!next.apply || next.text === undefined || next.cursor === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
history = next.state
|
||||
const value =
|
||||
next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
|
||||
restore(value, next.cursor)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const cycle = (event: KeyEvent): boolean => {
|
||||
const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
|
||||
if (!next.consume) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (next.clear) {
|
||||
clear()
|
||||
}
|
||||
|
||||
if (next.arm) {
|
||||
arm()
|
||||
}
|
||||
|
||||
if (next.cycle) {
|
||||
input.onCycle()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
const select = (item?: Auto) => {
|
||||
const next = item ?? options()[selected()]
|
||||
if (!next || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const tail = area.plainText.at(cursor)
|
||||
const append = "@" + next.value + (tail === " " ? "" : " ")
|
||||
area.cursorOffset = at()
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
const end = area.logicalCursor
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText(append)
|
||||
|
||||
const text = "@" + next.value
|
||||
const startOffset = at()
|
||||
const endOffset = startOffset + Bun.stringWidth(text)
|
||||
const part = structuredClone(next.part)
|
||||
if (part.type === "agent") {
|
||||
part.source = {
|
||||
start: startOffset,
|
||||
end: endOffset,
|
||||
value: text,
|
||||
}
|
||||
}
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = startOffset
|
||||
part.source.text.end = endOffset
|
||||
part.source.text.value = text
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
|
||||
if (prev !== -1) {
|
||||
const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
|
||||
if (mark !== undefined) {
|
||||
area.extmarks.delete(mark)
|
||||
}
|
||||
parts = parts.filter((_, idx) => idx !== prev)
|
||||
marks = new Map(
|
||||
[...marks.entries()]
|
||||
.filter((item) => item[0] !== mark)
|
||||
.map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const id = area.extmarks.create({
|
||||
start: startOffset,
|
||||
end: endOffset,
|
||||
virtual: true,
|
||||
typeId: type,
|
||||
})
|
||||
marks.set(id, parts.length)
|
||||
parts.push(part)
|
||||
hide()
|
||||
syncDraft()
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
const next = options()[selected()]
|
||||
if (!next?.directory || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
area.cursorOffset = at()
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
const end = area.logicalCursor
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText("@" + next.value)
|
||||
syncDraft()
|
||||
refresh()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyEvent) => {
|
||||
if (visible()) {
|
||||
const name = event.name.toLowerCase()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
setSelected((selected() - 1 + options().length) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
if (options().length > 0) {
|
||||
setSelected((selected() + 1) % options().length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "escape") {
|
||||
event.preventDefault()
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
event.preventDefault()
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "tab") {
|
||||
event.preventDefault()
|
||||
if (options()[selected()]?.directory) {
|
||||
expand()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = promptInfo(event)
|
||||
if (promptHit(keys().interrupts, key)) {
|
||||
if (input.onInterrupt()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cycle(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const up = promptHit(keys().previous, key)
|
||||
const down = promptHit(keys().next, key)
|
||||
if (!up && !down) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const dir = up ? -1 : 1
|
||||
if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) {
|
||||
move(dir, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === -1 && area.visualCursor.visualRow === 0) {
|
||||
area.cursorOffset = 0
|
||||
}
|
||||
|
||||
const end =
|
||||
typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
|
||||
? area.height - 1
|
||||
: Math.max(0, area.virtualLineCount - 1)
|
||||
if (dir === 1 && area.visualCursor.visualRow === end) {
|
||||
area.cursorOffset = area.plainText.length
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (input.prompt()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrl && event.name === "c") {
|
||||
const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
select()
|
||||
return
|
||||
}
|
||||
|
||||
syncDraft()
|
||||
const next = clonePrompt(draft)
|
||||
if (!next.text.trim()) {
|
||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||
return
|
||||
}
|
||||
|
||||
if (isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
}
|
||||
|
||||
area.setText("")
|
||||
clearParts()
|
||||
hide()
|
||||
draft = { text: "", parts: [] }
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
queueMicrotask(async () => {
|
||||
if (await input.onSubmit(next)) {
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clear()
|
||||
if (area && !area.isDestroyed) {
|
||||
area.off("line-info-change", scheduleRows)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.width()
|
||||
popup()
|
||||
if (input.prompt()) {
|
||||
scheduleRows()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
setSelected(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.state().phase
|
||||
if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const kind = input.view()
|
||||
if (kind === prev) {
|
||||
return
|
||||
}
|
||||
|
||||
if (prev === "prompt") {
|
||||
syncDraft()
|
||||
}
|
||||
|
||||
clear()
|
||||
hide()
|
||||
prev = kind
|
||||
if (kind !== "prompt") {
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
restore(draft)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
placeholder,
|
||||
bindings,
|
||||
visible,
|
||||
options,
|
||||
selected,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
onContentChange: () => {
|
||||
syncDraft()
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
bind,
|
||||
}
|
||||
}
|
||||
596
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
596
packages/opencode/src/cli/cmd/run/footer.question.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
// Question UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "question". Supports single-question and multi-question flows:
|
||||
//
|
||||
// Single question: options list with up/down selection, digit shortcuts,
|
||||
// and optional custom text input.
|
||||
//
|
||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
||||
// or left/right to navigate between questions.
|
||||
//
|
||||
// All state logic lives in question.shared.ts as a pure state machine.
|
||||
// This component just renders it and dispatches keyboard events.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
questionCustom,
|
||||
questionInfo,
|
||||
questionInput,
|
||||
questionMove,
|
||||
questionOther,
|
||||
questionPicked,
|
||||
questionReject,
|
||||
questionSave,
|
||||
questionSelect,
|
||||
questionSetEditing,
|
||||
questionSetSelected,
|
||||
questionSetSubmitting,
|
||||
questionSetTab,
|
||||
questionSingle,
|
||||
questionStoreCustom,
|
||||
questionSubmit,
|
||||
questionSync,
|
||||
questionTabs,
|
||||
questionTotal,
|
||||
} from "./question.shared"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
type Area = {
|
||||
isDestroyed: boolean
|
||||
plainText: string
|
||||
cursorOffset: number
|
||||
setText(text: string): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionRequest
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
||||
const single = createMemo(() => questionSingle(props.request))
|
||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
||||
const info = createMemo(() => questionInfo(props.request, state()))
|
||||
const input = createMemo(() => questionInput(state()))
|
||||
const other = createMemo(() => questionOther(props.request, state()))
|
||||
const picked = createMemo(() => questionPicked(state()))
|
||||
const disabled = createMemo(() => state().submitting)
|
||||
const narrow = createMemo(() => dims().width < 80)
|
||||
const verb = createMemo(() => {
|
||||
if (confirm()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
if (info()?.multiple) {
|
||||
return "toggle"
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
return "confirm"
|
||||
})
|
||||
let area: Area | undefined
|
||||
|
||||
createEffect(() => {
|
||||
setState((prev) => questionSync(prev, props.request.id))
|
||||
})
|
||||
|
||||
const setTab = (tab: number) => {
|
||||
setState((prev) => questionSetTab(prev, tab))
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
setState((prev) => questionMove(prev, props.request, dir))
|
||||
}
|
||||
|
||||
const beginReply = async (input: QuestionReply) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReply(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const beginReject = async (input: QuestionReject) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReject(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustom = () => {
|
||||
const cur = state()
|
||||
const next = questionSave(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const choose = (selected: number) => {
|
||||
const base = state()
|
||||
const cur = questionSetSelected(base, selected)
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== base) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const mark = (selected: number) => {
|
||||
setState((prev) => questionSetSelected(prev, selected))
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
const cur = state()
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
void beginReply(questionSubmit(props.request, state()))
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
void beginReject(questionReject(props.request))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.submitting) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.editing) {
|
||||
if (event.name === "escape") {
|
||||
setState((prev) => questionSetEditing(prev, false))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
|
||||
saveCustom()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && event.name === "tab") {
|
||||
const dir = event.shift ? -1 : 1
|
||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (questionConfirm(props.request, cur)) {
|
||||
if (event.name === "return") {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const total = questionTotal(props.request, cur)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(event.name)
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
choose(digit - 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
move(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
move(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
select()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state().editing || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== input()) {
|
||||
area.setText(input())
|
||||
area.cursorOffset = input().length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !state().editing) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
area.cursorOffset = area.plainText.length
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
id="run-direct-footer-question-panel"
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={3}
|
||||
paddingTop={1}
|
||||
marginBottom={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<box id="run-direct-footer-question-tabs"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const active = () => state().tab === index()
|
||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-tab-${index()}`}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(index())
|
||||
}}
|
||||
>
|
||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
||||
{item.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
id="run-direct-footer-question-tab-confirm"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(props.request.questions.length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!confirm()}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text wrapMode="word">
|
||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info()?.question}
|
||||
{info()?.multiple ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column">
|
||||
<For each={info()?.options ?? []}>
|
||||
{(item, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
||||
return (
|
||||
<box
|
||||
id={`run-direct-footer-question-option-${index()}`}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(index())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{hit() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={questionCustom(props.request, state())}>
|
||||
<box
|
||||
id="run-direct-footer-question-option-custom"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple
|
||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
||||
: "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{picked() ? "✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={state().editing}
|
||||
fallback={
|
||||
<Show when={input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{input()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
id="run-direct-footer-question-custom"
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={4}
|
||||
wrapMode="word"
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!disabled()}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed || disabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = area.plainText
|
||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item as Area
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-question-actions"
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<Show
|
||||
when={!disabled()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
Waiting for question event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
gap={narrow() ? 1 : 2}
|
||||
flexShrink={0}
|
||||
paddingBottom={1}
|
||||
width={narrow() ? "100%" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={!state().editing}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
636
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
636
packages/opencode/src/cli/cmd/run/footer.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
// RunFooter -- the mutable control surface for direct interactive mode.
|
||||
//
|
||||
// In the split-footer architecture, scrollback is immutable (append-only)
|
||||
// and the footer is the only region that can repaint. RunFooter owns both
|
||||
// sides of that boundary:
|
||||
//
|
||||
// Scrollback: append() queues StreamCommit entries and flush() writes them
|
||||
// to the renderer via writeToScrollback(). Commits coalesce in a microtask
|
||||
// queue -- consecutive progress chunks for the same part merge into one
|
||||
// write to avoid excessive scrollback snapshots.
|
||||
//
|
||||
// Footer: event() updates the SolidJS signal-backed FooterState, which
|
||||
// drives the reactive footer view (prompt, status, permission, question).
|
||||
// present() swaps the active footer view and resizes the footer region.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - close() flushes pending commits and notifies listeners (the prompt
|
||||
// queue uses this to know when to stop).
|
||||
// - destroy() does the same plus tears down event listeners and clears
|
||||
// internal state.
|
||||
// - The renderer's DESTROY event triggers destroy() so the footer
|
||||
// doesn't outlive the renderer.
|
||||
//
|
||||
// Interrupt and exit use a two-press pattern: first press shows a hint,
|
||||
// second press within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { normalizeEntry } from "./scrollback.format"
|
||||
import { entryWriter } from "./scrollback"
|
||||
import { spacerWriter } from "./scrollback.writer"
|
||||
import { toolView } from "./tool"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type {
|
||||
RunAgent,
|
||||
FooterApi,
|
||||
FooterEvent,
|
||||
FooterKeybinds,
|
||||
FooterPatch,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type RunFooterOptions = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
keybinds: FooterKeybinds
|
||||
diffStyle: RunDiffStyle
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
const QUESTION_ROWS = 14
|
||||
|
||||
export class RunFooter implements FooterApi {
|
||||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private closes = new Set<() => void>()
|
||||
// Most recent visible scrollback commit.
|
||||
private tail: StreamCommit | undefined
|
||||
// The entry splash is already in scrollback before footer output starts.
|
||||
private wrote = true
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
private pending = false
|
||||
// Fixed portion of footer height above the textarea.
|
||||
private base: number
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
private state: Accessor<FooterState>
|
||||
private setState: Setter<FooterState>
|
||||
private view: Accessor<FooterView>
|
||||
private setView: Setter<FooterView>
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private options: RunFooterOptions,
|
||||
) {
|
||||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: options.modelLabel,
|
||||
duration: "",
|
||||
usage: "",
|
||||
first: options.first,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
})
|
||||
this.state = state
|
||||
this.setState = setState
|
||||
const [view, setView] = createSignal<FooterView>({ type: "prompt" })
|
||||
this.view = view
|
||||
this.setView = setView
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
void render(
|
||||
() =>
|
||||
createComponent(RunFooterView, {
|
||||
directory: options.directory,
|
||||
state: this.state,
|
||||
view: this.view,
|
||||
findFiles: options.findFiles,
|
||||
agents: () => options.agents,
|
||||
resources: () => options.resources,
|
||||
theme: options.theme.footer,
|
||||
block: options.theme.block,
|
||||
diffStyle: options.diffStyle,
|
||||
keybinds: options.keybinds,
|
||||
history: options.history,
|
||||
agent: options.agentLabel,
|
||||
onSubmit: this.handlePrompt,
|
||||
onPermissionReply: this.handlePermissionReply,
|
||||
onQuestionReply: this.handleQuestionReply,
|
||||
onQuestionReject: this.handleQuestionReject,
|
||||
onCycle: this.handleCycle,
|
||||
onInterrupt: this.handleInterrupt,
|
||||
onExitRequest: this.handleExit,
|
||||
onExit: () => this.close(),
|
||||
onRows: this.syncRows,
|
||||
onStatus: this.setStatus,
|
||||
}),
|
||||
this.renderer as unknown as Parameters<typeof render>[1],
|
||||
).catch(() => {
|
||||
if (!this.destroyed && !this.renderer.isDestroyed) {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get isClosed(): boolean {
|
||||
return this.closed || this.destroyed || this.renderer.isDestroyed
|
||||
}
|
||||
|
||||
public onPrompt(fn: (input: RunPrompt) => void): () => void {
|
||||
this.prompts.add(fn)
|
||||
return () => {
|
||||
this.prompts.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
this.closes.add(fn)
|
||||
return () => {
|
||||
this.closes.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public event(next: FooterEvent): void {
|
||||
if (next.type === "queue") {
|
||||
this.patch({ queue: next.queue })
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "first") {
|
||||
this.patch({ first: next.first })
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "model") {
|
||||
this.patch({ model: next.model })
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "turn.send") {
|
||||
this.patch({
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: next.queue,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "turn.wait") {
|
||||
this.patch({
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "turn.idle") {
|
||||
this.patch({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: next.queue,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
this.patch({ duration: next.duration })
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "stream.patch") {
|
||||
if (typeof next.patch.status === "string" && next.patch.phase === undefined) {
|
||||
this.patch({ phase: "running", ...next.patch })
|
||||
return
|
||||
}
|
||||
|
||||
this.patch(next.patch)
|
||||
return
|
||||
}
|
||||
|
||||
this.present(next.view)
|
||||
}
|
||||
|
||||
private patch(next: FooterPatch): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = this.state()
|
||||
const state = {
|
||||
phase: next.phase ?? prev.phase,
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
duration: typeof next.duration === "string" ? next.duration : prev.duration,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
interrupt:
|
||||
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
|
||||
? Math.max(0, Math.floor(next.interrupt))
|
||||
: prev.interrupt,
|
||||
exit:
|
||||
typeof next.exit === "number" && Number.isFinite(next.exit) ? Math.max(0, Math.floor(next.exit)) : prev.exit,
|
||||
}
|
||||
|
||||
if (state.phase === "idle") {
|
||||
state.interrupt = 0
|
||||
}
|
||||
|
||||
this.setState(state)
|
||||
|
||||
if (prev.phase === "running" && state.phase === "idle") {
|
||||
this.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private present(view: FooterView): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setView(view)
|
||||
this.applyHeight()
|
||||
}
|
||||
|
||||
// Queues a scrollback commit. Consecutive progress chunks for the same
|
||||
// part coalesce by appending text, reducing the number of renderer writes.
|
||||
// Actual flush happens on the next microtask, so a burst of events from
|
||||
// one reducer pass becomes a single scrollback write.
|
||||
public append(commit: StreamCommit): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeEntry(commit)) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = this.queue.at(-1)
|
||||
if (
|
||||
last &&
|
||||
last.phase === "progress" &&
|
||||
commit.phase === "progress" &&
|
||||
last.kind === commit.kind &&
|
||||
last.source === commit.source &&
|
||||
last.partID === commit.partID &&
|
||||
last.tool === commit.tool
|
||||
) {
|
||||
last.text += commit.text
|
||||
} else {
|
||||
this.queue.push(commit)
|
||||
}
|
||||
|
||||
if (this.pending) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pending = true
|
||||
queueMicrotask(() => {
|
||||
this.pending = false
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
|
||||
public idle(): Promise<void> {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.renderer.idle().catch(() => {})
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flush()
|
||||
this.notifyClose()
|
||||
}
|
||||
|
||||
public requestExit(): boolean {
|
||||
return this.handleExit()
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flush()
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
this.tail = undefined
|
||||
this.wrote = false
|
||||
}
|
||||
|
||||
private notifyClose(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.closed = true
|
||||
for (const fn of [...this.closes]) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus = (status: string): void => {
|
||||
this.patch({ status })
|
||||
}
|
||||
|
||||
// Resizes the footer to fit the current view. Permission and question views
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const height =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "question"
|
||||
? this.base + QUESTION_ROWS
|
||||
: Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows))
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
private syncRows = (value: number): void => {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
|
||||
if (rows === this.rows) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rows = rows
|
||||
if (this.view().type === "prompt") {
|
||||
this.applyHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private handlePrompt = (input: RunPrompt): boolean => {
|
||||
if (this.isClosed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state().first) {
|
||||
this.patch({ first: false })
|
||||
}
|
||||
|
||||
if (this.prompts.size === 0) {
|
||||
this.patch({ status: "input queue unavailable" })
|
||||
return false
|
||||
}
|
||||
|
||||
for (const fn of [...this.prompts]) {
|
||||
fn(input)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handlePermissionReply = async (input: PermissionReply): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onPermissionReply(input)
|
||||
}
|
||||
|
||||
private handleQuestionReply = async (input: QuestionReply): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onQuestionReply(input)
|
||||
}
|
||||
|
||||
private handleQuestionReject = async (input: QuestionReject): Promise<void> => {
|
||||
if (this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.options.onQuestionReject(input)
|
||||
}
|
||||
|
||||
private handleCycle = (): void => {
|
||||
const result = this.options.onCycleVariant?.()
|
||||
if (!result) {
|
||||
this.patch({ status: "no variants available" })
|
||||
return
|
||||
}
|
||||
|
||||
const patch: FooterPatch = {
|
||||
status: result.status ?? "variant updated",
|
||||
}
|
||||
|
||||
if (result.modelLabel) {
|
||||
patch.model = result.modelLabel
|
||||
}
|
||||
|
||||
this.patch(patch)
|
||||
}
|
||||
|
||||
private clearInterruptTimer(): void {
|
||||
if (!this.interruptTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.interruptTimeout)
|
||||
this.interruptTimeout = undefined
|
||||
}
|
||||
|
||||
private armInterruptTimer(): void {
|
||||
this.clearInterruptTimer()
|
||||
this.interruptTimeout = setTimeout(() => {
|
||||
this.interruptTimeout = undefined
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.state().phase !== "running") {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ interrupt: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private clearExitTimer(): void {
|
||||
if (!this.exitTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.exitTimeout)
|
||||
this.exitTimeout = undefined
|
||||
}
|
||||
|
||||
private armExitTimer(): void {
|
||||
this.clearExitTimer()
|
||||
this.exitTimeout = setTimeout(() => {
|
||||
this.exitTimeout = undefined
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.patch({ exit: 0 })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Two-press interrupt: first press shows a hint ("esc again to interrupt"),
|
||||
// second press within 5 seconds fires onInterrupt. The timer resets the
|
||||
// counter if the user doesn't follow through.
|
||||
private handleInterrupt = (): boolean => {
|
||||
if (this.isClosed || this.state().phase !== "running") {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = this.state().interrupt + 1
|
||||
this.patch({ interrupt: next })
|
||||
|
||||
if (next < 2) {
|
||||
this.armInterruptTimer()
|
||||
this.patch({ status: `${this.interruptHint} again to interrupt` })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
this.patch({ interrupt: 0, status: "interrupting" })
|
||||
this.options.onInterrupt?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private handleExit = (): boolean => {
|
||||
if (this.isClosed) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearInterruptTimer()
|
||||
const next = this.state().exit + 1
|
||||
this.patch({ exit: next, interrupt: 0 })
|
||||
|
||||
if (next < 2) {
|
||||
this.armExitTimer()
|
||||
this.patch({ status: "Press Ctrl-c again to exit" })
|
||||
return true
|
||||
}
|
||||
|
||||
this.clearExitTimer()
|
||||
this.patch({ exit: 0, status: "exiting" })
|
||||
this.close()
|
||||
this.options.onExit?.()
|
||||
return true
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flush()
|
||||
this.destroyed = true
|
||||
this.notifyClose()
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
this.tail = undefined
|
||||
this.wrote = false
|
||||
}
|
||||
|
||||
// Drains the commit queue to scrollback. Visible commits start a new block
|
||||
// whenever their block key changes, and new blocks get a single spacer.
|
||||
private flush(): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.queue.length === 0) {
|
||||
this.queue.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of this.queue.splice(0)) {
|
||||
const same = sameGroup(this.tail, item)
|
||||
if (this.wrote && !same) {
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(entryWriter(item, this.options.theme, { diffStyle: this.options.diffStyle }))
|
||||
this.wrote = true
|
||||
this.tail = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snap(commit: StreamCommit): boolean {
|
||||
const tool = commit.tool ?? commit.part?.tool
|
||||
return (
|
||||
commit.kind === "tool" &&
|
||||
commit.phase === "final" &&
|
||||
(commit.toolState ?? commit.part?.state.status) === "completed" &&
|
||||
typeof tool === "string" &&
|
||||
Boolean(toolView(tool).snap)
|
||||
)
|
||||
}
|
||||
|
||||
function groupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (snap(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.partID}`
|
||||
}
|
||||
|
||||
function sameGroup(a: StreamCommit | undefined, b: StreamCommit): boolean {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
const left = groupKey(a)
|
||||
const right = groupKey(b)
|
||||
if (left && right && left === right) {
|
||||
return true
|
||||
}
|
||||
|
||||
return a.kind === "tool" && a.phase === "start" && b.kind === "tool" && b.phase === "start"
|
||||
}
|
||||
335
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
335
packages/opencode/src/cli/cmd/run/footer.view.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
// Top-level footer layout for direct interactive mode.
|
||||
//
|
||||
// Renders the footer region as a vertical stack:
|
||||
// 1. Spacer row (visual separation from scrollback)
|
||||
// 2. Composer frame with left-border accent -- swaps between prompt,
|
||||
// permission, and question bodies via Switch/Match
|
||||
// 3. Meta row showing agent name and model label
|
||||
// 4. Bottom border + status row (spinner, interrupt hint, duration, usage)
|
||||
//
|
||||
// All state comes from the parent RunFooter through SolidJS signals.
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import type {
|
||||
FooterKeybinds,
|
||||
RunAgent,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
} from "./types"
|
||||
import { RUN_THEME_FALLBACK, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
type RunFooterViewProps = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: () => RunAgent[]
|
||||
resources: () => RunResource[]
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
theme?: RunFooterTheme
|
||||
block?: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
keybinds: FooterKeybinds
|
||||
history?: RunPrompt[]
|
||||
agent: string
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
|
||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const prompt = createMemo(() => active().type === "prompt")
|
||||
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
|
||||
const block = createMemo(() => props.block ?? RUN_THEME_FALLBACK.block)
|
||||
const spin = createMemo(() => {
|
||||
return {
|
||||
frames: createFrames({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
color: createColors({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
}
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "permission" ? view : undefined
|
||||
})
|
||||
const question = createMemo<Extract<FooterView, { type: "question" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "question" ? view : undefined
|
||||
})
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
agents: props.agents,
|
||||
resources: props.resources,
|
||||
keybinds: props.keybinds,
|
||||
state: props.state,
|
||||
view: () => active().type,
|
||||
prompt,
|
||||
width: () => term().width,
|
||||
theme,
|
||||
history: props.history,
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
onInterrupt: props.onInterrupt,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const menu = createMemo(() => active().type === "prompt" && composer.visible())
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-shell"
|
||||
width="100%"
|
||||
height="100%"
|
||||
border={false}
|
||||
backgroundColor="transparent"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={0}
|
||||
paddingRight={0}
|
||||
paddingTop={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={active().type === "prompt"}>
|
||||
<RunPromptBody
|
||||
theme={theme}
|
||||
placeholder={composer.placeholder}
|
||||
bindings={composer.bindings}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
diffStyle={props.diffStyle}
|
||||
onReply={props.onPermissionReply}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "question"}>
|
||||
<RunQuestionBody
|
||||
request={question()!.request}
|
||||
theme={theme()}
|
||||
onReply={props.onQuestionReply}
|
||||
onReject={props.onQuestionReject}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-meta-row"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.agent}
|
||||
</text>
|
||||
<text id="run-direct-footer-model" fg={theme().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{props.state().model}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-line-6"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "╹",
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-line-6-fill"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme().surface}
|
||||
backgroundColor={menu() ? theme().shade : "transparent"}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={menu()}
|
||||
fallback={
|
||||
<box
|
||||
id="run-direct-footer-row"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting()}>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={exiting()}>
|
||||
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
|
||||
Press Ctrl-c again to exit
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-hint-interrupt"
|
||||
fg={armed() ? theme().highlight : theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{interruptKey()}{" "}
|
||||
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
|
||||
{armed() ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||
▣
|
||||
</text>
|
||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||
·
|
||||
</text>
|
||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||
{duration()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
|
||||
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
|
||||
<Show when={queue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
256
packages/opencode/src/cli/cmd/run/permission.shared.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Pure state machine for the permission UI.
|
||||
//
|
||||
// Lives outside the JSX component so it can be tested independently. The
|
||||
// machine has three stages:
|
||||
//
|
||||
// permission → initial view with Allow once / Always / Reject options
|
||||
// always → confirmation step (Confirm / Cancel)
|
||||
// reject → text input for rejection message
|
||||
//
|
||||
// permissionRun() is the main transition: given the current state and the
|
||||
// selected option, it returns a new state and optionally a PermissionReply
|
||||
// to send to the SDK. The component calls this on enter/click.
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
|
||||
export type PermissionBodyState = {
|
||||
requestID: string
|
||||
stage: PermissionStage
|
||||
selected: PermissionOption
|
||||
message: string
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type PermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionStep = {
|
||||
state: PermissionBodyState
|
||||
reply?: PermissionReply
|
||||
}
|
||||
|
||||
function dict(v: unknown): Dict {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return v as Dict
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: PermissionRequest): Dict {
|
||||
const meta = dict(request.metadata)
|
||||
return {
|
||||
...meta,
|
||||
...dict(meta.input),
|
||||
}
|
||||
}
|
||||
|
||||
function patterns(request: PermissionRequest): string[] {
|
||||
return request.patterns.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
stage: "permission",
|
||||
selected: "once",
|
||||
message: "",
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
if (stage === "permission") {
|
||||
return ["once", "always", "reject"]
|
||||
}
|
||||
|
||||
if (stage === "always") {
|
||||
return ["confirm", "cancel"]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const input = data(request)
|
||||
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.permission === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${toolPath(dir, { home: true })}`,
|
||||
lines: pats.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (request.permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.permission}`,
|
||||
lines: [`Tool: ${request.permission}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
}
|
||||
|
||||
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
|
||||
return {
|
||||
requestID,
|
||||
reply,
|
||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
|
||||
const list = permissionOptions(state.stage)
|
||||
if (list.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
const idx = Math.max(0, list.indexOf(state.selected))
|
||||
const selected = list[(idx + dir + list.length) % list.length]
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected: option,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
|
||||
if (state.submitting) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (state.stage === "permission") {
|
||||
if (option === "always") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "always",
|
||||
selected: "confirm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (option === "reject") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "once"),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage !== "always") {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (option === "cancel") {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
reply: permissionReply(requestID, "always"),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
|
||||
if (state.submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
return permissionReply(requestID, "reject", state.message)
|
||||
}
|
||||
|
||||
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
|
||||
if (state.stage === "always") {
|
||||
return {
|
||||
...state,
|
||||
stage: "permission",
|
||||
selected: "always",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
stage: "reject",
|
||||
selected: "reject",
|
||||
}
|
||||
}
|
||||
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
271
packages/opencode/src/cli/cmd/run/prompt.shared.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// Pure state machine for the prompt input.
|
||||
//
|
||||
// Handles keybind parsing, history ring navigation, and the leader-key
|
||||
// sequence for variant cycling. All functions are pure -- they take state
|
||||
// in and return new state out, with no side effects.
|
||||
//
|
||||
// The history ring (PromptHistoryState) stores past prompts and tracks
|
||||
// the current browse position. When the user arrows up at cursor offset 0,
|
||||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
//
|
||||
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
|
||||
// arms the leader, second press within the timeout fires the action.
|
||||
import type { KeyBinding } from "@opentui/core"
|
||||
import { Keybind } from "../../../util/keybind"
|
||||
import type { FooterKeybinds, RunPrompt } from "./types"
|
||||
|
||||
const HISTORY_LIMIT = 200
|
||||
|
||||
export type PromptHistoryState = {
|
||||
items: RunPrompt[]
|
||||
index: number | null
|
||||
draft: string
|
||||
}
|
||||
|
||||
export type PromptKeys = {
|
||||
leaders: Keybind.Info[]
|
||||
cycles: Keybind.Info[]
|
||||
interrupts: Keybind.Info[]
|
||||
previous: Keybind.Info[]
|
||||
next: Keybind.Info[]
|
||||
bindings: KeyBinding[]
|
||||
}
|
||||
|
||||
export type PromptCycle = {
|
||||
arm: boolean
|
||||
clear: boolean
|
||||
cycle: boolean
|
||||
consume: boolean
|
||||
}
|
||||
|
||||
export type PromptMove = {
|
||||
state: PromptHistoryState
|
||||
text?: string
|
||||
cursor?: number
|
||||
apply: boolean
|
||||
}
|
||||
|
||||
function copy(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: RunPrompt, b: RunPrompt): boolean {
|
||||
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
||||
}
|
||||
|
||||
function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
|
||||
return Keybind.parse(binding).map((item) => ({
|
||||
name: item.name,
|
||||
ctrl: item.ctrl || undefined,
|
||||
meta: item.meta || undefined,
|
||||
shift: item.shift || undefined,
|
||||
super: item.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...mapInputBindings(keybinds.inputSubmit, "submit"),
|
||||
...mapInputBindings(keybinds.inputNewline, "newline"),
|
||||
]
|
||||
}
|
||||
|
||||
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
|
||||
return {
|
||||
leaders: Keybind.parse(keybinds.leader),
|
||||
cycles: Keybind.parse(keybinds.variantCycle),
|
||||
interrupts: Keybind.parse(keybinds.interrupt),
|
||||
previous: Keybind.parse(keybinds.historyPrevious),
|
||||
next: Keybind.parse(keybinds.historyNext),
|
||||
bindings: textareaBindings(keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
export function printableBinding(binding: string, leader: string): string {
|
||||
const first = Keybind.parse(binding).at(0)
|
||||
if (!first) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let text = Keybind.toString(first)
|
||||
const lead = Keybind.parse(leader).at(0)
|
||||
if (lead) {
|
||||
text = text.replace("<leader>", Keybind.toString(lead))
|
||||
}
|
||||
|
||||
return text.replace(/escape/g, "esc")
|
||||
}
|
||||
|
||||
export function isExitCommand(input: string): boolean {
|
||||
const text = input.trim().toLowerCase()
|
||||
return text === "/exit" || text === "/quit"
|
||||
}
|
||||
|
||||
export function promptInfo(event: {
|
||||
name: string
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
super?: boolean
|
||||
}): Keybind.Info {
|
||||
return {
|
||||
name: event.name === " " ? "space" : event.name,
|
||||
ctrl: !!event.ctrl,
|
||||
meta: !!event.meta,
|
||||
shift: !!event.shift,
|
||||
super: !!event.super,
|
||||
leader: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function promptHit(bindings: Keybind.Info[], event: Keybind.Info): boolean {
|
||||
return bindings.some((item) => Keybind.match(item, event))
|
||||
}
|
||||
|
||||
export function promptCycle(
|
||||
armed: boolean,
|
||||
event: Keybind.Info,
|
||||
leaders: Keybind.Info[],
|
||||
cycles: Keybind.Info[],
|
||||
): PromptCycle {
|
||||
if (!armed && promptHit(leaders, event)) {
|
||||
return {
|
||||
arm: true,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (armed) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: true,
|
||||
cycle: promptHit(cycles, { ...event, leader: true }),
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptHit(cycles, event)) {
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: false,
|
||||
consume: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
arm: false,
|
||||
clear: false,
|
||||
cycle: true,
|
||||
consume: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(copy)
|
||||
const next: RunPrompt[] = []
|
||||
for (const item of list) {
|
||||
if (next.length > 0 && same(next[next.length - 1], item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
next.push(item)
|
||||
}
|
||||
|
||||
return {
|
||||
items: next.slice(-HISTORY_LIMIT),
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
|
||||
if (!prompt.text.trim()) {
|
||||
return state
|
||||
}
|
||||
|
||||
const next = copy(prompt)
|
||||
if (state.items[state.items.length - 1] && same(state.items[state.items.length - 1], next)) {
|
||||
return {
|
||||
...state,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...state.items, next].slice(-HISTORY_LIMIT)
|
||||
return {
|
||||
...state,
|
||||
items,
|
||||
index: null,
|
||||
draft: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptMove {
|
||||
if (state.items.length === 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === -1 && cursor !== 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === 1 && cursor !== text.length) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (state.index === null) {
|
||||
if (dir === 1) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
const idx = state.items.length - 1
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
draft: text,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: 0,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = state.index + dir
|
||||
if (idx < 0) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (idx >= state.items.length) {
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: null,
|
||||
},
|
||||
text: state.draft,
|
||||
cursor: state.draft.length,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
index: idx,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: dir === -1 ? 0 : state.items[idx].text.length,
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
340
packages/opencode/src/cli/cmd/run/question.shared.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// Pure state machine for the question UI.
|
||||
//
|
||||
// Supports both single-question and multi-question flows. Single questions
|
||||
// submit immediately on selection. Multi-question flows use tabs and a
|
||||
// final confirmation step.
|
||||
//
|
||||
// State transitions:
|
||||
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
||||
// questionSave → saves custom text input
|
||||
// questionMove → arrow key navigation through options
|
||||
// questionSetTab → tab navigation between questions
|
||||
// questionSubmit → builds the final QuestionReply with all answers
|
||||
//
|
||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
||||
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export type QuestionBodyState = {
|
||||
requestID: string
|
||||
tab: number
|
||||
answers: string[][]
|
||||
custom: string[]
|
||||
selected: number
|
||||
editing: boolean
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export type QuestionStep = {
|
||||
state: QuestionBodyState
|
||||
reply?: QuestionReply
|
||||
}
|
||||
|
||||
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
||||
return {
|
||||
requestID,
|
||||
tab: 0,
|
||||
answers: [],
|
||||
custom: [],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
||||
if (state.requestID === requestID) {
|
||||
return state
|
||||
}
|
||||
|
||||
return createQuestionBodyState(requestID)
|
||||
}
|
||||
|
||||
export function questionSingle(request: QuestionRequest): boolean {
|
||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
||||
}
|
||||
|
||||
export function questionTabs(request: QuestionRequest): number {
|
||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
||||
}
|
||||
|
||||
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return !questionSingle(request) && state.tab === request.questions.length
|
||||
}
|
||||
|
||||
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
||||
return request.questions[state.tab]
|
||||
}
|
||||
|
||||
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
return questionInfo(request, state)?.custom !== false
|
||||
}
|
||||
|
||||
export function questionInput(state: QuestionBodyState): string {
|
||||
return state.custom[state.tab] ?? ""
|
||||
}
|
||||
|
||||
export function questionPicked(state: QuestionBodyState): boolean {
|
||||
const value = questionInput(state)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.answers[state.tab]?.includes(value) ?? false
|
||||
}
|
||||
|
||||
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info || info.custom === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.selected === info.options.length
|
||||
}
|
||||
|
||||
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
||||
}
|
||||
|
||||
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
||||
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
||||
}
|
||||
|
||||
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
tab,
|
||||
selected: 0,
|
||||
editing: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
editing,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
||||
return {
|
||||
...state,
|
||||
submitting,
|
||||
}
|
||||
}
|
||||
|
||||
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
||||
const answers = [...state.answers]
|
||||
answers[tab] = list
|
||||
return {
|
||||
...state,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
||||
const custom = [...state.custom]
|
||||
custom[tab] = text
|
||||
return {
|
||||
...state,
|
||||
custom,
|
||||
}
|
||||
}
|
||||
|
||||
function questionPick(
|
||||
state: QuestionBodyState,
|
||||
request: QuestionRequest,
|
||||
answer: string,
|
||||
custom = false,
|
||||
): QuestionStep {
|
||||
const answers = [...state.answers]
|
||||
answers[state.tab] = [answer]
|
||||
let next: QuestionBodyState = {
|
||||
...state,
|
||||
answers,
|
||||
editing: false,
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
const list = [...state.custom]
|
||||
list[state.tab] = answer
|
||||
next = {
|
||||
...next,
|
||||
custom: list,
|
||||
}
|
||||
}
|
||||
|
||||
if (questionSingle(request)) {
|
||||
return {
|
||||
state: next,
|
||||
reply: {
|
||||
requestID: request.id,
|
||||
answers: [[answer]],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetTab(next, state.tab + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
||||
const list = [...(state.answers[state.tab] ?? [])]
|
||||
const idx = list.indexOf(answer)
|
||||
if (idx === -1) {
|
||||
list.push(answer)
|
||||
} else {
|
||||
list.splice(idx, 1)
|
||||
}
|
||||
|
||||
return storeAnswers(state, state.tab, list)
|
||||
}
|
||||
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
||||
const total = questionTotal(request, state)
|
||||
if (total === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selected: (state.selected + dir + total) % total,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (questionOther(request, state)) {
|
||||
if (!info.multiple) {
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const value = questionInput(state)
|
||||
if (value && questionPicked(state)) {
|
||||
return {
|
||||
state: questionToggle(state, value),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: questionSetEditing(state, true),
|
||||
}
|
||||
}
|
||||
|
||||
const option = info.options[state.selected]
|
||||
if (!option) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
return {
|
||||
state: questionToggle(state, option.label),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, option.label)
|
||||
}
|
||||
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
}
|
||||
|
||||
const value = questionInput(state).trim()
|
||||
const prev = state.custom[state.tab]
|
||||
if (!value) {
|
||||
if (!prev) {
|
||||
return {
|
||||
state: questionSetEditing(state, false),
|
||||
}
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, "")
|
||||
return {
|
||||
state: questionSetEditing(
|
||||
storeAnswers(
|
||||
next,
|
||||
state.tab,
|
||||
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
||||
),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (info.multiple) {
|
||||
const answers = [...(state.answers[state.tab] ?? [])]
|
||||
if (prev) {
|
||||
const idx = answers.indexOf(prev)
|
||||
if (idx !== -1) {
|
||||
answers.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!answers.includes(value)) {
|
||||
answers.push(value)
|
||||
}
|
||||
|
||||
const next = questionStoreCustom(state, state.tab, value)
|
||||
return {
|
||||
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
||||
}
|
||||
}
|
||||
|
||||
return questionPick(state, request, value, true)
|
||||
}
|
||||
|
||||
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
||||
return {
|
||||
requestID: request.id,
|
||||
answers: questionAnswers(state, request.questions.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function questionReject(request: QuestionRequest): QuestionReject {
|
||||
return {
|
||||
requestID: request.id,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
||||
if (state.submitting) {
|
||||
return "Waiting for question event..."
|
||||
}
|
||||
|
||||
if (questionConfirm(request, state)) {
|
||||
return "enter submit esc dismiss"
|
||||
}
|
||||
|
||||
if (state.editing) {
|
||||
return "enter save esc cancel"
|
||||
}
|
||||
|
||||
const info = questionInfo(request, state)
|
||||
if (questionSingle(request)) {
|
||||
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
||||
}
|
||||
|
||||
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
||||
}
|
||||
140
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
140
packages/opencode/src/cli/cmd/run/runtime.boot.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Boot-time resolution for direct interactive mode.
|
||||
//
|
||||
// These functions run concurrently at startup to gather everything the runtime
|
||||
// needs before the first frame: keybinds from TUI config, diff display style,
|
||||
// model variant list with context limits, and session history for the prompt
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { TuiConfig } from "../../../config/tui"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
variantCycle: "ctrl+t,<leader>t",
|
||||
interrupt: "escape",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "return",
|
||||
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
variants: string[]
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
// Fetches available variants and context limits for every provider/model pair.
|
||||
export async function resolveModelInfo(sdk: RunInput["sdk"], model: RunInput["model"]): Promise<ModelInfo> {
|
||||
try {
|
||||
const response = await sdk.provider.list()
|
||||
const providers = response.data?.all ?? []
|
||||
const limits: Record<string, number> = {}
|
||||
|
||||
for (const provider of providers) {
|
||||
for (const [modelID, info] of Object.entries(provider.models ?? {})) {
|
||||
const limit = info?.limit?.context
|
||||
if (typeof limit === "number" && limit > 0) {
|
||||
limits[modelKey(provider.id, modelID)] = limit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
variants: [],
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
const provider = providers.find((item) => item.id === model.providerID)
|
||||
const modelInfo = provider?.models?.[model.modelID]
|
||||
return {
|
||||
variants: Object.keys(modelInfo?.variants ?? {}),
|
||||
limits,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
variants: [],
|
||||
limits: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
||||
export async function resolveSessionInfo(
|
||||
sdk: RunInput["sdk"],
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
): Promise<SessionInfo> {
|
||||
try {
|
||||
const session = await resolveSession(sdk, sessionID)
|
||||
return {
|
||||
first: session.first,
|
||||
history: sessionHistory(session),
|
||||
variant: pickVariant(model, session),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
first: true,
|
||||
history: [],
|
||||
variant: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads keybind overrides from TUI config and merges them with defaults.
|
||||
// Always ensures <leader>t is present in the variant cycle binding.
|
||||
export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
|
||||
try {
|
||||
const config = await TuiConfig.get()
|
||||
const configuredLeader = config.keybinds?.leader?.trim() || DEFAULT_KEYBINDS.leader
|
||||
const configuredVariantCycle = config.keybinds?.variant_cycle?.trim() || "ctrl+t"
|
||||
const configuredInterrupt = config.keybinds?.session_interrupt?.trim() || DEFAULT_KEYBINDS.interrupt
|
||||
const configuredHistoryPrevious = config.keybinds?.history_previous?.trim() || DEFAULT_KEYBINDS.historyPrevious
|
||||
const configuredHistoryNext = config.keybinds?.history_next?.trim() || DEFAULT_KEYBINDS.historyNext
|
||||
const configuredSubmit = config.keybinds?.input_submit?.trim() || DEFAULT_KEYBINDS.inputSubmit
|
||||
const configuredNewline = config.keybinds?.input_newline?.trim() || DEFAULT_KEYBINDS.inputNewline
|
||||
|
||||
const variantBindings = configuredVariantCycle
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
|
||||
if (!variantBindings.some((binding) => binding.toLowerCase() === "<leader>t")) {
|
||||
variantBindings.push("<leader>t")
|
||||
}
|
||||
|
||||
return {
|
||||
leader: configuredLeader,
|
||||
variantCycle: variantBindings.join(","),
|
||||
interrupt: configuredInterrupt,
|
||||
historyPrevious: configuredHistoryPrevious,
|
||||
historyNext: configuredHistoryNext,
|
||||
inputSubmit: configuredSubmit,
|
||||
inputNewline: configuredNewline,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_KEYBINDS
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||
try {
|
||||
const config = await TuiConfig.get()
|
||||
return config.diff_style ?? "auto"
|
||||
} catch {
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
259
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
259
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// Lifecycle management for the split-footer renderer.
|
||||
//
|
||||
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
|
||||
// from the terminal palette, writes the entry splash to scrollback, and
|
||||
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
|
||||
// the exit splash and tears everything down in the right order:
|
||||
// footer.close → footer.destroy → renderer shutdown.
|
||||
//
|
||||
// Also wires SIGINT so Ctrl-c during a turn triggers the two-press exit
|
||||
// sequence through RunFooter.requestExit().
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterKeybinds,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunAgent,
|
||||
RunDiffStyle,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
} from "./types"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
|
||||
const FOOTER_HEIGHT = 7
|
||||
const DEFAULT_TITLE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
type SplashState = {
|
||||
entry: boolean
|
||||
exit: boolean
|
||||
}
|
||||
|
||||
type CycleResult = {
|
||||
modelLabel?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type FooterLabels = {
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
}
|
||||
|
||||
export type LifecycleInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
keybinds: FooterKeybinds
|
||||
diffStyle: RunDiffStyle
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
close(input: { showExit: boolean; sessionTitle?: string }): Promise<void>
|
||||
}
|
||||
|
||||
// Gracefully tears down the renderer. Order matters: switch external output
|
||||
// back to passthrough before leaving split-footer mode, so pending stdout
|
||||
// doesn't get captured into the now-dead scrollback pipeline.
|
||||
function shutdown(renderer: CliRenderer): void {
|
||||
if (renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (renderer.externalOutputMode === "capture-stdout") {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
}
|
||||
|
||||
if (renderer.screenMode === "split-footer") {
|
||||
renderer.screenMode = "main-screen"
|
||||
}
|
||||
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function splashTitle(title: string | undefined, history: RunPrompt[]): string | undefined {
|
||||
if (title && !DEFAULT_TITLE.test(title)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const next = history.find((item) => item.text.trim().length > 0)
|
||||
return next?.text ?? title
|
||||
}
|
||||
|
||||
function splashSession(title: string | undefined, history: RunPrompt[]): boolean {
|
||||
if (title && !DEFAULT_TITLE.test(title)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !!history.find((item) => item.text.trim().length > 0)
|
||||
}
|
||||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function queueSplash(
|
||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
||||
state: SplashState,
|
||||
phase: keyof SplashState,
|
||||
write: ScrollbackWriter | undefined,
|
||||
): boolean {
|
||||
if (state[phase]) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!write) {
|
||||
return false
|
||||
}
|
||||
|
||||
state[phase] = true
|
||||
renderer.writeToScrollback(write)
|
||||
renderer.requestRender()
|
||||
return true
|
||||
}
|
||||
|
||||
// Boots the split-footer renderer and constructs the RunFooter.
|
||||
//
|
||||
// The renderer starts in split-footer mode with captured stdout so that
|
||||
// scrollback commits and footer repaints happen in the same frame. After
|
||||
// the entry splash, RunFooter takes over the footer region.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
screenMode: "split-footer",
|
||||
footerHeight: FOOTER_HEIGHT,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
let theme = await resolveRunTheme(renderer)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
}
|
||||
const showSession = splashSession(input.sessionTitle, input.history)
|
||||
const meta = splashMeta({
|
||||
title: splashTitle(input.sessionTitle, input.history),
|
||||
session_id: input.sessionID,
|
||||
})
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"entry",
|
||||
entrySplash({
|
||||
...meta,
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
showSession,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
|
||||
const { RunFooter } = await import("./footer")
|
||||
|
||||
const labels = footerLabels({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
})
|
||||
const footer = new RunFooter(renderer, {
|
||||
directory: input.directory,
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
resources: input.resources,
|
||||
...labels,
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
keybinds: input.keybinds,
|
||||
diffStyle: input.diffStyle,
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
onCycleVariant: input.onCycleVariant,
|
||||
onInterrupt: input.onInterrupt,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
footer.requestExit()
|
||||
}
|
||||
process.on("SIGINT", sigint)
|
||||
|
||||
let closed = false
|
||||
const close = async (next: { showExit: boolean; sessionTitle?: string }) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
process.off("SIGINT", sigint)
|
||||
|
||||
try {
|
||||
const show = renderer.isDestroyed ? false : next.showExit
|
||||
if (!renderer.isDestroyed && show) {
|
||||
queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
"exit",
|
||||
exitSplash({
|
||||
...splashMeta({
|
||||
title: splashTitle(next.sessionTitle ?? input.sessionTitle, input.history),
|
||||
session_id: input.sessionID,
|
||||
}),
|
||||
theme: theme.entry,
|
||||
background: theme.background,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
footer.close()
|
||||
footer.destroy()
|
||||
shutdown(renderer)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
footer,
|
||||
close,
|
||||
}
|
||||
}
|
||||
213
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
213
packages/opencode/src/cli/cmd/run/runtime.queue.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time: it appends the user row to
|
||||
// scrollback, calls input.run() to execute the turn through the stream
|
||||
// transport, and waits for completion before starting the next prompt.
|
||||
//
|
||||
// The queue also handles /exit and /quit commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
//
|
||||
// Resolves when the footer closes and all in-flight work finishes.
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { isExitCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onPrompt?: () => void
|
||||
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events, queues them, and drains one at a
|
||||
// time through input.run(). If the user submits multiple prompts while
|
||||
// a turn is running, they queue up and execute in order. The footer shows
|
||||
// the queue depth so the user knows how many are pending.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const q: RunPrompt[] = []
|
||||
let busy = false
|
||||
let closed = input.footer.isClosed
|
||||
let ctrl: AbortController | undefined
|
||||
let stop: (() => void) | undefined
|
||||
let err: unknown
|
||||
let hasErr = false
|
||||
let done: (() => void) | undefined
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
const until = new Promise<void>((resolve) => {
|
||||
stop = resolve
|
||||
})
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
err = error
|
||||
hasErr = true
|
||||
done?.()
|
||||
done = undefined
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!closed || busy) {
|
||||
return
|
||||
}
|
||||
|
||||
done?.()
|
||||
done = undefined
|
||||
}
|
||||
|
||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||
input.trace?.write("ui.patch", row)
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const pump = async () => {
|
||||
if (busy || closed) {
|
||||
return
|
||||
}
|
||||
|
||||
busy = true
|
||||
|
||||
try {
|
||||
while (!closed && q.length > 0) {
|
||||
const prompt = q.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
queue: q.length,
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: q.length,
|
||||
},
|
||||
)
|
||||
const start = Date.now()
|
||||
const next = new AbortController()
|
||||
ctrl = next
|
||||
try {
|
||||
const task = input.run(prompt, next.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
await input.footer.idle()
|
||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
|
||||
if (out.type === "closed") {
|
||||
next.abort()
|
||||
break
|
||||
}
|
||||
|
||||
if (out.type === "error") {
|
||||
throw out.error
|
||||
}
|
||||
} finally {
|
||||
if (ctrl === next) {
|
||||
ctrl = undefined
|
||||
}
|
||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||
emit(
|
||||
{
|
||||
type: "turn.duration",
|
||||
duration,
|
||||
},
|
||||
{
|
||||
duration,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
emit(
|
||||
{
|
||||
type: "turn.idle",
|
||||
queue: q.length,
|
||||
},
|
||||
{
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: q.length,
|
||||
},
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const push = (prompt: RunPrompt) => {
|
||||
if (!prompt.text.trim() || closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isExitCommand(prompt.text)) {
|
||||
input.footer.close()
|
||||
return
|
||||
}
|
||||
|
||||
input.onPrompt?.()
|
||||
q.push(prompt)
|
||||
emit(
|
||||
{
|
||||
type: "queue",
|
||||
queue: q.length,
|
||||
},
|
||||
{
|
||||
queue: q.length,
|
||||
},
|
||||
)
|
||||
emit(
|
||||
{
|
||||
type: "first",
|
||||
first: false,
|
||||
},
|
||||
{
|
||||
first: false,
|
||||
},
|
||||
)
|
||||
void pump().catch(fail)
|
||||
}
|
||||
|
||||
const offPrompt = input.footer.onPrompt((prompt) => {
|
||||
push(prompt)
|
||||
})
|
||||
const offClose = input.footer.onClose(() => {
|
||||
closed = true
|
||||
q.length = 0
|
||||
ctrl?.abort()
|
||||
stop?.()
|
||||
finish()
|
||||
})
|
||||
|
||||
try {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
push({ text: input.initialInput ?? "", parts: [] })
|
||||
await pump()
|
||||
|
||||
if (!closed) {
|
||||
await wait
|
||||
}
|
||||
|
||||
if (hasErr) {
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
}
|
||||
}
|
||||
324
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
324
packages/opencode/src/cli/cmd/run/runtime.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// Top-level orchestrator for `run --interactive`.
|
||||
//
|
||||
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
|
||||
// and prompt queue together into a single session loop. Two entry points:
|
||||
//
|
||||
// runInteractiveMode -- used when an SDK client already exists (attach mode)
|
||||
// runInteractiveLocalMode -- used for local in-process mode (no server)
|
||||
//
|
||||
// Both delegate to runInteractiveRuntime, which:
|
||||
// 1. resolves keybinds, diff style, model info, and session history,
|
||||
// 2. creates the split-footer lifecycle (renderer + RunFooter),
|
||||
// 3. starts the stream transport (SDK event subscription),
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { trace } from "./trace"
|
||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||
import type { RunInput } from "./types"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { runPromptQueue } from "./runtime.queue"
|
||||
|
||||
type BootContext = Pick<RunInput, "sdk" | "directory" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
|
||||
|
||||
type RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
type RunLocalInput = {
|
||||
directory: string
|
||||
fetch: typeof globalThis.fetch
|
||||
resolveAgent: () => Promise<string | undefined>
|
||||
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
|
||||
share: (sdk: RunInput["sdk"], sessionID: string) => Promise<void>
|
||||
agent: RunInput["agent"]
|
||||
model: RunInput["model"]
|
||||
variant: RunInput["variant"]
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunInput["demo"]
|
||||
demoText?: RunInput["demoText"]
|
||||
}
|
||||
|
||||
// Core runtime loop. Boot resolves the SDK context, then we set up the
|
||||
// lifecycle (renderer + footer), wire the stream transport for SDK events,
|
||||
// and feed prompts through the queue until the user exits.
|
||||
//
|
||||
// Files only attach on the first prompt turn -- after that, includeFiles
|
||||
// flips to false so subsequent turns don't re-send attachments.
|
||||
async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
const log = trace()
|
||||
const keybindTask = resolveFooterKeybinds()
|
||||
const diffTask = resolveDiffStyle()
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
|
||||
const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||
const savedTask = resolveSavedVariant(ctx.model)
|
||||
const agentsTask = ctx.sdk.app
|
||||
.agents({ directory: ctx.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
const resourcesTask = ctx.sdk.experimental.resource
|
||||
.list({ directory: ctx.directory })
|
||||
.then((x) => Object.values(x.data ?? {}))
|
||||
.catch(() => [])
|
||||
let variants: string[] = []
|
||||
let limits: Record<string, number> = {}
|
||||
let aborting = false
|
||||
let shown = false
|
||||
let demo: ReturnType<typeof createRunDemo> | undefined
|
||||
const [keybinds, diffStyle, session, savedVariant, agents, resources] = await Promise.all([
|
||||
keybindTask,
|
||||
diffTask,
|
||||
sessionTask,
|
||||
savedTask,
|
||||
agentsTask,
|
||||
resourcesTask,
|
||||
])
|
||||
shown = !session.first
|
||||
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
|
||||
|
||||
const shell = await createRuntimeLifecycle({
|
||||
directory: ctx.directory,
|
||||
findFiles: (query) =>
|
||||
ctx.sdk.find
|
||||
.files({ query, directory: ctx.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
agents,
|
||||
resources,
|
||||
sessionID: ctx.sessionID,
|
||||
sessionTitle: ctx.sessionTitle,
|
||||
first: session.first,
|
||||
history: session.history,
|
||||
agent: ctx.agent,
|
||||
model: ctx.model,
|
||||
variant: activeVariant,
|
||||
keybinds,
|
||||
diffStyle,
|
||||
onPermissionReply: async (next) => {
|
||||
if (demo?.permission(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
log?.write("send.permission.reply", next)
|
||||
await ctx.sdk.permission.reply(next)
|
||||
},
|
||||
onQuestionReply: async (next) => {
|
||||
if (demo?.questionReply(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reply(next)
|
||||
},
|
||||
onQuestionReject: async (next) => {
|
||||
if (demo?.questionReject(next)) {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reject(next)
|
||||
},
|
||||
onCycleVariant: () => {
|
||||
if (!ctx.model || variants.length === 0) {
|
||||
return {
|
||||
status: "no variants available",
|
||||
}
|
||||
}
|
||||
|
||||
activeVariant = cycleVariant(activeVariant, variants)
|
||||
saveVariant(ctx.model, activeVariant)
|
||||
return {
|
||||
status: activeVariant ? `variant ${activeVariant}` : "variant default",
|
||||
modelLabel: formatModelLabel(ctx.model, activeVariant),
|
||||
}
|
||||
},
|
||||
onInterrupt: () => {
|
||||
if (aborting) {
|
||||
return
|
||||
}
|
||||
|
||||
aborting = true
|
||||
void ctx.sdk.session
|
||||
.abort({
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
aborting = false
|
||||
})
|
||||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
|
||||
if (input.demo) {
|
||||
demo = createRunDemo({
|
||||
mode: input.demo,
|
||||
text: input.demoText,
|
||||
footer,
|
||||
sessionID: ctx.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => limits,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.afterPaint) {
|
||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
||||
}
|
||||
|
||||
void modelTask.then((info) => {
|
||||
variants = info.variants
|
||||
limits = info.limits
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
|
||||
if (next === activeVariant) {
|
||||
return
|
||||
}
|
||||
|
||||
activeVariant = next
|
||||
if (!ctx.model || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(ctx.model, activeVariant),
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const mod = await import("./stream.transport")
|
||||
let includeFiles = true
|
||||
const stream = await mod.createSessionTransport({
|
||||
sdk: ctx.sdk,
|
||||
sessionID: ctx.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => limits,
|
||||
footer,
|
||||
trace: log,
|
||||
})
|
||||
|
||||
try {
|
||||
if (demo) {
|
||||
await demo.start()
|
||||
}
|
||||
|
||||
const queue = await import("./runtime.queue")
|
||||
await queue.runPromptQueue({
|
||||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onPrompt: () => {
|
||||
shown = true
|
||||
},
|
||||
run: async (prompt, signal) => {
|
||||
if (demo && (await demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stream.runPromptTurn({
|
||||
agent: ctx.agent,
|
||||
model: ctx.model,
|
||||
variant: activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
})
|
||||
includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
footer.append({ kind: "error", text: mod.formatUnknownError(error), phase: "start", source: "system" })
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await stream.close()
|
||||
}
|
||||
} finally {
|
||||
const title = shown
|
||||
? await ctx.sdk.session
|
||||
.get({
|
||||
sessionID: ctx.sessionID,
|
||||
})
|
||||
.then((x) => x.data?.title)
|
||||
.catch(() => undefined)
|
||||
: undefined
|
||||
|
||||
await shell.close({
|
||||
showExit: shown,
|
||||
sessionTitle: title,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Local in-process mode. Creates an SDK client backed by a direct fetch to
|
||||
// the in-process server, so no external HTTP server is needed.
|
||||
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
fetch: input.fetch,
|
||||
directory: input.directory,
|
||||
})
|
||||
|
||||
return runInteractiveRuntime({
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
demo: input.demo,
|
||||
demoText: input.demoText,
|
||||
afterPaint: (ctx) => input.share(ctx.sdk, ctx.sessionID),
|
||||
boot: async () => {
|
||||
const agent = await input.resolveAgent()
|
||||
const session = await input.session(sdk)
|
||||
if (!session?.id) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
return {
|
||||
sdk,
|
||||
directory: input.directory,
|
||||
sessionID: session.id,
|
||||
sessionTitle: session.title,
|
||||
agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Attach mode. Uses the caller-provided SDK client directly.
|
||||
export async function runInteractiveMode(input: RunInput): Promise<void> {
|
||||
return runInteractiveRuntime({
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
demo: input.demo,
|
||||
demoText: input.demoText,
|
||||
boot: async () => ({
|
||||
sdk: input.sdk,
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
sessionTitle: input.sessionTitle,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}),
|
||||
})
|
||||
}
|
||||
92
packages/opencode/src/cli/cmd/run/scrollback.format.ts
Normal file
92
packages/opencode/src/cli/cmd/run/scrollback.format.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Text normalization for scrollback entries.
|
||||
//
|
||||
// Transforms a StreamCommit into the final text that will be appended to
|
||||
// terminal scrollback. Each entry kind has its own formatting:
|
||||
//
|
||||
// user → prefixed with "› "
|
||||
// assistant → raw text (progress), empty (start/final unless interrupted)
|
||||
// reasoning → raw text with [REDACTED] stripped
|
||||
// tool → delegated to tool.ts for per-tool scrollback formatting
|
||||
// error/system → raw trimmed text
|
||||
//
|
||||
// Returns an empty string when the commit should produce no visible output
|
||||
// (e.g., assistant start events, empty final events).
|
||||
import { toolFrame, toolScroll, toolView } from "./tool"
|
||||
import type { StreamCommit } from "./types"
|
||||
|
||||
export function clean(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function toolText(commit: StreamCommit, raw: string): string {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const view = toolView(ctx.name)
|
||||
|
||||
if (commit.phase === "progress" && !view.output) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
if (ctx.status === "error") {
|
||||
return toolScroll("final", ctx)
|
||||
}
|
||||
|
||||
if (!view.final) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (ctx.status && ctx.status !== "completed") {
|
||||
return ctx.raw.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return toolScroll(commit.phase, ctx)
|
||||
}
|
||||
|
||||
export function normalizeEntry(commit: StreamCommit): string {
|
||||
const raw = clean(commit.text)
|
||||
|
||||
if (commit.kind === "user") {
|
||||
if (!raw.trim()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return `${lead}› ${body}`
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolText(commit, raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
if (commit.phase === "start") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? "assistant interrupted" : ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
if (commit.phase === "start") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? "reasoning interrupted" : ""
|
||||
}
|
||||
|
||||
return raw.replace(/\[REDACTED\]/g, "")
|
||||
}
|
||||
|
||||
if (commit.phase === "start" || commit.phase === "final") {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
26
packages/opencode/src/cli/cmd/run/scrollback.tsx
Normal file
26
packages/opencode/src/cli/cmd/run/scrollback.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// Entry writer routing for scrollback commits.
|
||||
//
|
||||
// Decides whether a commit should render as plain text or as a rich snapshot
|
||||
// (code block, diff view, task card, etc.). Completed tool parts whose tool
|
||||
// rule has a "snap" mode get routed to snapEntryWriter, which produces a
|
||||
// structured JSX snapshot. Everything else goes through textEntryWriter.
|
||||
import type { ScrollbackWriter } from "@opentui/core"
|
||||
import { toolView } from "./tool"
|
||||
import { snapEntryWriter, textEntryWriter } from "./scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
export function entryWriter(
|
||||
commit: StreamCommit,
|
||||
theme: RunTheme = RUN_THEME_FALLBACK,
|
||||
opts: ScrollbackOptions = {},
|
||||
): ScrollbackWriter {
|
||||
const state = commit.toolState ?? commit.part?.state.status
|
||||
if (commit.kind === "tool" && commit.phase === "final" && state === "completed") {
|
||||
if (toolView(commit.tool).snap) {
|
||||
return snapEntryWriter(commit, theme, opts)
|
||||
}
|
||||
}
|
||||
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
641
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
641
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
// JSX-based scrollback snapshot writers for rich tool output.
|
||||
//
|
||||
// When a tool commit has a "snap" mode (code, diff, task, todo, question),
|
||||
// snapEntryWriter renders it as a structured JSX tree that OpenTUI converts
|
||||
// into a ScrollbackSnapshot. These snapshots support syntax highlighting,
|
||||
// unified/split diffs, line numbers, and LSP diagnostics.
|
||||
//
|
||||
// The writers use OpenTUI's createScrollbackWriter to produce snapshots.
|
||||
// OpenTUI measures and reflows them when the terminal resizes. The fit()
|
||||
// helper measures actual rendered width so narrow content doesn't claim
|
||||
// the full terminal width.
|
||||
//
|
||||
// Plain text entries (textEntryWriter) also go through here -- they just
|
||||
// produce a simple <text> element with the right color and attributes.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import {
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
type ColorInput,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { createScrollbackWriter, type JSX } from "@opentui/solid"
|
||||
import { For, Show } from "solid-js"
|
||||
import { Filesystem } from "../../../util/filesystem"
|
||||
import { toolDiffView, toolFiletype, toolFrame, toolSnapshot } from "./tool"
|
||||
import { clean, normalizeEntry } from "./scrollback.format"
|
||||
import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
type ToolDict = Record<string, unknown>
|
||||
|
||||
function dict(v: unknown): ToolDict {
|
||||
if (!v || typeof v !== "object") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return v as ToolDict
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
return
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
function diagnostics(meta: ToolDict, file: string): string[] {
|
||||
const all = dict(meta.diagnostics)
|
||||
const key = Filesystem.normalizePath(file)
|
||||
const list = arr(all[key]).map(dict)
|
||||
return list
|
||||
.filter((item) => item.severity === 1)
|
||||
.slice(0, 3)
|
||||
.map((item) => {
|
||||
const range = dict(item.range)
|
||||
const start = dict(range.start)
|
||||
const line = num(start.line)
|
||||
const char = num(start.character)
|
||||
const msg = text(item.message)
|
||||
if (line === undefined || char === undefined) {
|
||||
return `Error ${msg}`.trim()
|
||||
}
|
||||
|
||||
return `Error [${line + 1}:${char + 1}] ${msg}`.trim()
|
||||
})
|
||||
}
|
||||
|
||||
type Flags = {
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
type Paint = {
|
||||
fg: ColorInput
|
||||
attrs?: number
|
||||
}
|
||||
|
||||
type CodeInput = {
|
||||
title: string
|
||||
content: string
|
||||
filetype?: string
|
||||
diagnostics: string[]
|
||||
}
|
||||
|
||||
type DiffInput = {
|
||||
title: string
|
||||
diff?: string
|
||||
filetype?: string
|
||||
deletions?: number
|
||||
diagnostics: string[]
|
||||
}
|
||||
|
||||
type TaskInput = {
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
type TodoInput = {
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
type QuestionInput = {
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
type Measure = {
|
||||
widthColsMax: number
|
||||
}
|
||||
|
||||
type MeasureNode = {
|
||||
textBufferView?: {
|
||||
measureForDimensions(width: number, height: number): Measure | null
|
||||
}
|
||||
getChildren?: () => unknown[]
|
||||
}
|
||||
|
||||
let bare: SyntaxStyle | undefined
|
||||
|
||||
function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
if (style) {
|
||||
return style
|
||||
}
|
||||
|
||||
bare ??= SyntaxStyle.fromTheme([])
|
||||
return bare
|
||||
}
|
||||
|
||||
function failed(commit: StreamCommit): boolean {
|
||||
return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
|
||||
}
|
||||
|
||||
function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
fg: theme.user.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (failed(commit)) {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return {
|
||||
fg: theme.system.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool" && commit.phase === "start") {
|
||||
return {
|
||||
fg: theme.tool.start ?? theme.tool.body,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
return { fg: theme.assistant.body }
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
return {
|
||||
fg: theme.reasoning.body,
|
||||
attrs: TextAttributes.DIM,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "error") {
|
||||
return {
|
||||
fg: theme.error.body,
|
||||
attrs: TextAttributes.BOLD,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return { fg: theme.tool.body }
|
||||
}
|
||||
|
||||
return { fg: theme.system.body }
|
||||
}
|
||||
|
||||
function cols(ctx: ScrollbackRenderContext): number {
|
||||
return Math.max(1, Math.trunc(ctx.width))
|
||||
}
|
||||
|
||||
function leaf(node: unknown): MeasureNode | undefined {
|
||||
if (!node || typeof node !== "object") {
|
||||
return
|
||||
}
|
||||
|
||||
const next = node as MeasureNode
|
||||
if (next.textBufferView) {
|
||||
return next
|
||||
}
|
||||
|
||||
const list = next.getChildren?.() ?? []
|
||||
for (const child of list) {
|
||||
const out = leaf(child)
|
||||
if (out) {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fit(snapshot: ScrollbackSnapshot, ctx: ScrollbackRenderContext) {
|
||||
const node = leaf(snapshot.root)
|
||||
const width = cols(ctx)
|
||||
const box = node?.textBufferView?.measureForDimensions(width, Math.max(1, snapshot.height ?? 1))
|
||||
const rowColumns = Math.max(1, Math.min(width, box?.widthColsMax ?? 0))
|
||||
|
||||
snapshot.width = width
|
||||
snapshot.rowColumns = rowColumns
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function full(node: () => JSX.Element, ctx: ScrollbackRenderContext, flags: Flags) {
|
||||
return createScrollbackWriter(node, {
|
||||
width: cols(ctx),
|
||||
rowColumns: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx)
|
||||
}
|
||||
|
||||
function TextEntry(props: { body: string; fg: ColorInput; attrs?: number }) {
|
||||
return (
|
||||
<text width="100%" wrapMode="word" fg={props.fg} attributes={props.attrs}>
|
||||
{props.body}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function thinking(body: string) {
|
||||
const mark = "Thinking: "
|
||||
if (body.startsWith(mark)) {
|
||||
return {
|
||||
head: mark,
|
||||
tail: body.slice(mark.length),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tail: body,
|
||||
}
|
||||
}
|
||||
|
||||
function ReasoningEntry(props: { body: string; theme: RunEntryTheme }) {
|
||||
const part = thinking(props.body)
|
||||
return (
|
||||
<text
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.reasoning.body}
|
||||
attributes={TextAttributes.DIM | TextAttributes.ITALIC}
|
||||
>
|
||||
<Show when={part.head}>{part.head}</Show>
|
||||
{part.tail}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function Diagnostics(props: { theme: RunTheme; lines: string[] }) {
|
||||
return (
|
||||
<Show when={props.lines.length > 0}>
|
||||
<box>
|
||||
<For each={props.lines}>{(line) => <text fg={props.theme.entry.error.body}>{line}</text>}</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTool(props: { theme: RunTheme; title: string; children: JSX.Element }) {
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.title}
|
||||
</text>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeTool(props: { theme: RunTheme; data: CodeInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<line_number fg={props.theme.block.muted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={props.theme.block.text}
|
||||
filetype={props.data.filetype}
|
||||
syntaxStyle={syntax(props.theme.block.syntax)}
|
||||
content={props.data.content}
|
||||
drawUnstyledText={true}
|
||||
wrapMode="word"
|
||||
/>
|
||||
</line_number>
|
||||
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffTool(props: { theme: RunTheme; data: DiffInput; view: "unified" | "split" }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<Show
|
||||
when={props.data.diff?.trim()}
|
||||
fallback={
|
||||
<text fg={props.theme.block.diffRemoved}>
|
||||
-{props.data.deletions ?? 0} line{props.data.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box>
|
||||
<diff
|
||||
diff={props.data.diff ?? ""}
|
||||
view={props.view}
|
||||
filetype={props.data.filetype}
|
||||
syntaxStyle={syntax(props.theme.block.syntax)}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.block.text}
|
||||
addedBg={props.theme.block.diffAddedBg}
|
||||
removedBg={props.theme.block.diffRemovedBg}
|
||||
contextBg={props.theme.block.diffContextBg}
|
||||
addedSignColor={props.theme.block.diffHighlightAdded}
|
||||
removedSignColor={props.theme.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.theme.block.diffLineNumber}
|
||||
lineNumberBg={props.theme.block.diffContextBg}
|
||||
addedLineNumberBg={props.theme.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.theme.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskTool(props: { theme: RunTheme; data: TaskInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<box>
|
||||
<For each={props.data.rows}>{(line) => <text fg={props.theme.block.text}>{line}</text>}</For>
|
||||
</box>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function todoMark(status: string): string {
|
||||
if (status === "completed") {
|
||||
return "[x]"
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return "[>]"
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
return "[-]"
|
||||
}
|
||||
return "[ ]"
|
||||
}
|
||||
|
||||
function TodoTool(props: { theme: RunTheme; data: TodoInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title="# Todos">
|
||||
<box>
|
||||
<For each={props.data.items}>
|
||||
{(item) => (
|
||||
<text fg={props.theme.block.text}>
|
||||
{todoMark(item.status)} {item.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title="# Questions">
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
<box gap={1}>
|
||||
<For each={props.data.items}>
|
||||
{(item) => (
|
||||
<box flexDirection="column">
|
||||
<text fg={props.theme.block.muted}>{item.question}</text>
|
||||
<text fg={props.theme.block.text}>{item.answer}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function textWriter(body: string, commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
const style = look(commit, theme)
|
||||
return (ctx) =>
|
||||
fit(
|
||||
createScrollbackWriter(() => <TextEntry body={body} fg={style.fg} attrs={style.attrs} />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
function reasoningWriter(body: string, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
fit(
|
||||
createScrollbackWriter(() => <ReasoningEntry body={body} theme={theme} />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
function blankWriter(): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
createScrollbackWriter(() => <text width="100%" />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
})(ctx)
|
||||
}
|
||||
|
||||
function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
full(() => <TextEntry body={body.endsWith("\n") ? body : `${body}\n`} fg={theme.system.body} />, ctx, {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
})
|
||||
}
|
||||
|
||||
function codeWriter(data: CodeInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <CodeTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function diffWriter(list: DiffInput[], theme: RunTheme, flags: Flags, view: "unified" | "split"): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
full(
|
||||
() => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={list}>{(data) => <DiffTool theme={theme} data={data} view={view} />}</For>
|
||||
</box>
|
||||
),
|
||||
ctx,
|
||||
flags,
|
||||
)
|
||||
}
|
||||
|
||||
function taskWriter(data: TaskInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <TaskTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function todoWriter(data: TodoInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <TodoTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function questionWriter(data: QuestionInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <QuestionTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function flags(commit: StreamCommit): Flags {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
|
||||
const body = normalizeEntry(commit)
|
||||
const snap = flags(commit)
|
||||
if (commit.kind === "reasoning") {
|
||||
return reasoningWriter(body, theme, snap)
|
||||
}
|
||||
|
||||
return textWriter(body, commit, theme, snap)
|
||||
}
|
||||
|
||||
export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
|
||||
const snap = toolSnapshot(commit, clean(commit.text))
|
||||
if (!snap) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
const info = toolFrame(commit, clean(commit.text))
|
||||
const style = flags(commit)
|
||||
|
||||
if (snap.kind === "code") {
|
||||
return codeWriter(
|
||||
{
|
||||
title: snap.title,
|
||||
content: snap.content,
|
||||
filetype: toolFiletype(snap.file),
|
||||
diagnostics: diagnostics(info.meta, snap.file ?? ""),
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "diff") {
|
||||
if (snap.items.length === 0) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
const list = snap.items
|
||||
.map((item) => {
|
||||
if (!item.diff.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
diff: item.diff,
|
||||
filetype: toolFiletype(item.file),
|
||||
deletions: item.deletions,
|
||||
diagnostics: diagnostics(info.meta, item.file ?? ""),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
if (list.length === 0) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
return (ctx) => diffWriter(list, theme, style, toolDiffView(ctx.width, opts.diffStyle))(ctx)
|
||||
}
|
||||
|
||||
if (snap.kind === "task") {
|
||||
return taskWriter(
|
||||
{
|
||||
title: snap.title,
|
||||
rows: snap.rows,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "todo") {
|
||||
return todoWriter(
|
||||
{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
return questionWriter(
|
||||
{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
|
||||
return textBlockWriter(clean(text), theme)
|
||||
}
|
||||
|
||||
export function spacerWriter(): ScrollbackWriter {
|
||||
return blankWriter()
|
||||
}
|
||||
881
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
881
packages/opencode/src/cli/cmd/run/session-data.ts
Normal file
@@ -0,0 +1,881 @@
|
||||
// Core reducer for direct interactive mode.
|
||||
//
|
||||
// Takes raw SDK events and produces two outputs:
|
||||
// - StreamCommit[]: append-only scrollback entries (text, tool, error, etc.)
|
||||
// - FooterOutput: status bar patches and view transitions (permission, question)
|
||||
//
|
||||
// The reducer mutates SessionData in place for performance but has no
|
||||
// external side effects -- no IO, no footer calls. The caller
|
||||
// (stream.transport.ts) feeds events in and forwards output to the footer
|
||||
// through stream.ts.
|
||||
//
|
||||
// Key design decisions:
|
||||
//
|
||||
// - Text parts buffer in `data.text` until their message role is confirmed as
|
||||
// "assistant". This prevents echoing user-role text parts. The `ready()`
|
||||
// check gates output: if we see a text delta before the message.updated
|
||||
// event that tells us the role, we stash it and flush later via `replay()`.
|
||||
//
|
||||
// - Tool echo stripping: bash tools may echo their own output in the next
|
||||
// assistant text part. `stashEcho()` records completed bash output, and
|
||||
// `stripEcho()` removes it from the start of the next assistant chunk.
|
||||
//
|
||||
// - Permission and question requests queue in `data.permissions` and
|
||||
// `data.questions`. The footer shows whichever is first. When a reply
|
||||
// event arrives, the queue entry is removed and the footer falls back
|
||||
// to the next pending request or to the prompt view.
|
||||
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { toolView } from "./tool"
|
||||
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
type Tokens = {
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cache?: {
|
||||
read?: number
|
||||
write?: number
|
||||
}
|
||||
}
|
||||
|
||||
type PartKind = "assistant" | "reasoning"
|
||||
type MessageRole = "assistant" | "user"
|
||||
type Dict = Record<string, unknown>
|
||||
type SessionCommit = StreamCommit
|
||||
|
||||
// Mutable accumulator for the reducer. Each field tracks a different aspect
|
||||
// of the stream so we can produce correct incremental output:
|
||||
//
|
||||
// - ids: parts and error keys we've already committed (dedup guard)
|
||||
// - tools: tool parts we've emitted a "start" for but not yet completed
|
||||
// - call: tool call inputs, keyed by msg:call, for enriching permission views
|
||||
// - role: message ID → "assistant" | "user", learned from message.updated
|
||||
// - msg: part ID → message ID
|
||||
// - part: part ID → "assistant" | "reasoning" (text parts only)
|
||||
// - text: part ID → full accumulated text so far
|
||||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||
// - end: part IDs whose time.end has arrived (part is finished)
|
||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||
export type SessionData = {
|
||||
announced: boolean
|
||||
ids: Set<string>
|
||||
tools: Set<string>
|
||||
call: Map<string, Dict>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
role: Map<string, MessageRole>
|
||||
msg: Map<string, string>
|
||||
part: Map<string, PartKind>
|
||||
text: Map<string, string>
|
||||
sent: Map<string, number>
|
||||
end: Set<string>
|
||||
echo: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
export type SessionDataInput = {
|
||||
data: SessionData
|
||||
event: Event
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
export type SessionDataOutput = {
|
||||
data: SessionData
|
||||
commits: SessionCommit[]
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
export function createSessionData(): SessionData {
|
||||
return {
|
||||
announced: false,
|
||||
ids: new Set(),
|
||||
tools: new Set(),
|
||||
call: new Map(),
|
||||
permissions: [],
|
||||
questions: [],
|
||||
role: new Map(),
|
||||
msg: new Map(),
|
||||
part: new Map(),
|
||||
text: new Map(),
|
||||
sent: new Map(),
|
||||
end: new Set(),
|
||||
echo: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function formatUsage(
|
||||
tokens: Tokens | undefined,
|
||||
limit: number | undefined,
|
||||
cost: number | undefined,
|
||||
): string | undefined {
|
||||
const total =
|
||||
(tokens?.input ?? 0) +
|
||||
(tokens?.output ?? 0) +
|
||||
(tokens?.reasoning ?? 0) +
|
||||
(tokens?.cache?.read ?? 0) +
|
||||
(tokens?.cache?.write ?? 0)
|
||||
|
||||
if (total <= 0) {
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return money.format(cost)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const text =
|
||||
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
|
||||
|
||||
if (typeof cost === "number" && cost > 0) {
|
||||
return `${text} · ${money.format(cost)}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function formatError(error: {
|
||||
name?: string
|
||||
message?: string
|
||||
data?: {
|
||||
message?: string
|
||||
}
|
||||
}): string {
|
||||
if (error.data?.message) {
|
||||
return String(error.data.message)
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
return String(error.message)
|
||||
}
|
||||
|
||||
if (error.name) {
|
||||
return String(error.name)
|
||||
}
|
||||
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function isAbort(error: { name?: string } | undefined): boolean {
|
||||
return error?.name === "MessageAbortedError"
|
||||
}
|
||||
|
||||
function msgErr(id: string): string {
|
||||
return `msg:${id}:error`
|
||||
}
|
||||
|
||||
function patch(patch?: FooterPatch, view?: FooterView): FooterOutput | undefined {
|
||||
if (!patch && !view) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
patch,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function out(data: SessionData, commits: SessionCommit[], footer?: FooterOutput): SessionDataOutput {
|
||||
if (!footer) {
|
||||
return {
|
||||
data,
|
||||
commits,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
commits,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
|
||||
function pickView(data: SessionData): FooterView {
|
||||
const permission = data.permissions[0]
|
||||
if (permission) {
|
||||
return { type: "permission", request: permission }
|
||||
}
|
||||
|
||||
const question = data.questions[0]
|
||||
if (question) {
|
||||
return { type: "question", request: question }
|
||||
}
|
||||
|
||||
return { type: "prompt" }
|
||||
}
|
||||
|
||||
function queueFooter(data: SessionData): FooterOutput {
|
||||
const view = pickView(data)
|
||||
if (view.type === "permission") {
|
||||
return {
|
||||
view,
|
||||
patch: { status: "awaiting permission" },
|
||||
}
|
||||
}
|
||||
|
||||
if (view.type === "question") {
|
||||
return {
|
||||
view,
|
||||
patch: { status: "awaiting answer" },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
patch: { status: "" },
|
||||
}
|
||||
}
|
||||
|
||||
function upsert<T extends { id: string }>(list: T[], item: T) {
|
||||
const idx = list.findIndex((entry) => entry.id === item.id)
|
||||
if (idx === -1) {
|
||||
list.push(item)
|
||||
return
|
||||
}
|
||||
|
||||
list[idx] = item
|
||||
}
|
||||
|
||||
function remove<T extends { id: string }>(list: T[], id: string): boolean {
|
||||
const idx = list.findIndex((entry) => entry.id === id)
|
||||
if (idx === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
list.splice(idx, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
function key(msg: string, call: string): string {
|
||||
return `${msg}:${call}`
|
||||
}
|
||||
|
||||
function enrichPermission(data: SessionData, request: PermissionRequest): PermissionRequest {
|
||||
if (!request.tool) {
|
||||
return request
|
||||
}
|
||||
|
||||
const input = data.call.get(key(request.tool.messageID, request.tool.callID))
|
||||
if (!input) {
|
||||
return request
|
||||
}
|
||||
|
||||
const meta = request.metadata ?? {}
|
||||
if (meta.input === input) {
|
||||
return request
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
metadata: {
|
||||
...meta,
|
||||
input,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the active permission request when the matching tool part gets
|
||||
// new input (e.g., a diff). This keeps the permission UI in sync with the
|
||||
// tool's evolving state. Only triggers a footer update if the currently
|
||||
// displayed permission was the one that changed.
|
||||
function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undefined {
|
||||
data.call.set(key(part.messageID, part.callID), part.state.input)
|
||||
if (data.permissions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let active = false
|
||||
data.permissions = data.permissions.map((request, index) => {
|
||||
if (!request.tool || request.tool.messageID !== part.messageID || request.tool.callID !== part.callID) {
|
||||
return request
|
||||
}
|
||||
|
||||
const next = enrichPermission(data, request)
|
||||
if (next === request) {
|
||||
return request
|
||||
}
|
||||
|
||||
changed = true
|
||||
active ||= index === 0
|
||||
return next
|
||||
})
|
||||
|
||||
if (!changed || !active) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
view: pickView(data),
|
||||
}
|
||||
}
|
||||
|
||||
function toolStatus(part: ToolPart): string {
|
||||
if (part.tool !== "task") {
|
||||
return `running ${part.tool}`
|
||||
}
|
||||
|
||||
const state = part.state as {
|
||||
input?: {
|
||||
description?: unknown
|
||||
subagent_type?: unknown
|
||||
}
|
||||
}
|
||||
const desc = state.input?.description
|
||||
if (typeof desc === "string" && desc.trim()) {
|
||||
return `running ${desc.trim()}`
|
||||
}
|
||||
|
||||
const type = state.input?.subagent_type
|
||||
if (typeof type === "string" && type.trim()) {
|
||||
return `running ${type.trim()}`
|
||||
}
|
||||
|
||||
return "running task"
|
||||
}
|
||||
|
||||
// Returns true if we can flush this part's text to scrollback.
|
||||
//
|
||||
// We gate on the message role being "assistant" because user-role messages
|
||||
// also contain text parts (the user's own input) which we don't want to
|
||||
// echo. If we haven't received the message.updated event yet, we return
|
||||
// false and the text stays buffered until replay() flushes it.
|
||||
function ready(data: SessionData, partID: string): boolean {
|
||||
const msg = data.msg.get(partID)
|
||||
if (!msg) {
|
||||
return true
|
||||
}
|
||||
|
||||
const role = data.role.get(msg)
|
||||
if (!role) {
|
||||
return false
|
||||
}
|
||||
|
||||
return role === "assistant"
|
||||
}
|
||||
|
||||
function syncText(data: SessionData, partID: string, next: string) {
|
||||
const prev = data.text.get(partID) ?? ""
|
||||
if (!next) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (!prev || next.length >= prev.length) {
|
||||
data.text.set(partID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
// Records bash tool output for echo stripping. Some models echo bash output
|
||||
// verbatim at the start of their next text part. We save both the raw and
|
||||
// trimmed forms so stripEcho() can match either.
|
||||
function stashEcho(data: SessionData, part: ToolPart) {
|
||||
if (part.tool !== "bash") {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof part.messageID !== "string" || !part.messageID) {
|
||||
return
|
||||
}
|
||||
|
||||
const output = (part.state as { output?: unknown }).output
|
||||
if (typeof output !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const text = output.replace(/^\n+/, "")
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const set = data.echo.get(part.messageID) ?? new Set<string>()
|
||||
set.add(text)
|
||||
const trim = text.replace(/\n+$/, "")
|
||||
if (trim && trim !== text) {
|
||||
set.add(trim)
|
||||
}
|
||||
data.echo.set(part.messageID, set)
|
||||
}
|
||||
|
||||
function stripEcho(data: SessionData, msg: string | undefined, chunk: string): string {
|
||||
if (!msg) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const set = data.echo.get(msg)
|
||||
if (!set || set.size === 0) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
data.echo.delete(msg)
|
||||
const list = [...set].sort((a, b) => b.length - a.length)
|
||||
for (const item of list) {
|
||||
if (!item || !chunk.startsWith(item)) {
|
||||
continue
|
||||
}
|
||||
|
||||
return chunk.slice(item.length).replace(/^\n+/, "")
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
function flushPart(data: SessionData, commits: SessionCommit[], partID: string, interrupted = false) {
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.text.get(partID) ?? ""
|
||||
const sent = data.sent.get(partID) ?? 0
|
||||
let chunk = text.slice(sent)
|
||||
const msg = data.msg.get(partID)
|
||||
|
||||
if (sent === 0) {
|
||||
chunk = chunk.replace(/^\n+/, "")
|
||||
if (kind === "reasoning" && chunk) {
|
||||
chunk = `Thinking: ${chunk.replace(/\[REDACTED\]/g, "")}`
|
||||
}
|
||||
if (kind === "assistant" && chunk) {
|
||||
chunk = stripEcho(data, msg, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk) {
|
||||
data.sent.set(partID, text.length)
|
||||
commits.push({
|
||||
kind,
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
})
|
||||
}
|
||||
|
||||
if (!interrupted) {
|
||||
return
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind,
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
interrupted: true,
|
||||
})
|
||||
}
|
||||
|
||||
function drop(data: SessionData, partID: string) {
|
||||
data.part.delete(partID)
|
||||
data.text.delete(partID)
|
||||
data.sent.delete(partID)
|
||||
data.msg.delete(partID)
|
||||
data.end.delete(partID)
|
||||
}
|
||||
|
||||
// Called when we learn a message's role (from message.updated). Flushes any
|
||||
// buffered text parts that were waiting on role confirmation. User-role
|
||||
// parts are silently dropped.
|
||||
function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) {
|
||||
for (const [partID, msg] of [...data.msg.entries()]) {
|
||||
if (msg !== messageID || data.ids.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
continue
|
||||
}
|
||||
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !thinking) {
|
||||
if (data.end.has(partID)) {
|
||||
data.ids.add(partID)
|
||||
}
|
||||
drop(data, partID)
|
||||
continue
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID)
|
||||
|
||||
if (!data.end.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
}
|
||||
}
|
||||
|
||||
function startTool(part: ToolPart): SessionCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
text: toolStatus(part),
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: "running",
|
||||
}
|
||||
}
|
||||
|
||||
function doneTool(part: ToolPart): SessionCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: "completed",
|
||||
}
|
||||
}
|
||||
|
||||
function failTool(part: ToolPart, text: string): SessionCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
text,
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: "error",
|
||||
toolError: text,
|
||||
}
|
||||
}
|
||||
|
||||
// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted.
|
||||
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
|
||||
for (const partID of data.part.keys()) {
|
||||
if (data.ids.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msg = data.msg.get(partID)
|
||||
if (msg && data.role.get(msg) === "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID, true)
|
||||
}
|
||||
}
|
||||
|
||||
// The main reducer. Takes one SDK event and returns scrollback commits and
|
||||
// footer updates. Called once per event from the stream transport's watch loop.
|
||||
//
|
||||
// Event handling follows the SDK event types:
|
||||
// message.updated → learn role, flush buffered parts, track usage
|
||||
// message.part.delta → accumulate text, flush if ready
|
||||
// message.part.updated → handle text/reasoning/tool state transitions
|
||||
// permission.* → manage the permission queue, drive footer view
|
||||
// question.* → manage the question queue, drive footer view
|
||||
// session.error → emit error scrollback entry
|
||||
export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
||||
const commits: SessionCommit[] = []
|
||||
const data = input.data
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const info = event.properties.info
|
||||
if (typeof info.id === "string") {
|
||||
data.role.set(info.id, info.role)
|
||||
replay(data, commits, info.id, info.role, input.thinking)
|
||||
}
|
||||
|
||||
if (info.role !== "assistant") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
let next: FooterPatch | undefined
|
||||
if (!data.announced) {
|
||||
data.announced = true
|
||||
next = { status: "assistant responding" }
|
||||
}
|
||||
|
||||
const usage = formatUsage(
|
||||
info.tokens,
|
||||
input.limits[modelKey(info.providerID, info.modelID)],
|
||||
typeof info.cost === "number" ? info.cost : undefined,
|
||||
)
|
||||
if (usage) {
|
||||
next = {
|
||||
...(next ?? {}),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof info.id === "string" && info.error && !isAbort(info.error) && !data.ids.has(msgErr(info.id))) {
|
||||
data.ids.add(msgErr(info.id))
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text: formatError(info.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: info.id,
|
||||
})
|
||||
}
|
||||
|
||||
return out(data, commits, patch(next))
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event.properties.partID !== "string" ||
|
||||
typeof event.properties.field !== "string" ||
|
||||
typeof event.properties.delta !== "string"
|
||||
) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.properties.field !== "text") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const partID = event.properties.partID
|
||||
if (data.ids.has(partID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (typeof event.properties.messageID === "string") {
|
||||
data.msg.set(partID, event.properties.messageID)
|
||||
}
|
||||
|
||||
const text = data.text.get(partID) ?? ""
|
||||
data.text.set(partID, text + event.properties.delta)
|
||||
|
||||
const kind = data.part.get(partID)
|
||||
if (!kind) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !input.thinking) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!ready(data, partID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
flushPart(data, commits, partID)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
const view = syncPermission(data, part)
|
||||
|
||||
if (part.state.status === "running") {
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (!data.tools.has(part.id)) {
|
||||
data.tools.add(part.id)
|
||||
commits.push(startTool(part))
|
||||
}
|
||||
|
||||
return out(data, commits, view ?? patch({ status: toolStatus(part) }))
|
||||
}
|
||||
|
||||
if (part.state.status === "completed") {
|
||||
const seen = data.tools.has(part.id)
|
||||
const mode = toolView(part.tool)
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (!seen) {
|
||||
commits.push(startTool(part))
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
stashEcho(data, part)
|
||||
|
||||
const output = part.state.output
|
||||
if (mode.output && typeof output === "string" && output.trim()) {
|
||||
commits.push({
|
||||
kind: "tool",
|
||||
text: output,
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
tool: part.tool,
|
||||
part,
|
||||
toolState: "completed",
|
||||
})
|
||||
}
|
||||
|
||||
if (mode.final) {
|
||||
commits.push(doneTool(part))
|
||||
}
|
||||
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
if (part.state.status === "error") {
|
||||
data.tools.delete(part.id)
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits, view)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
const text =
|
||||
typeof part.state.error === "string" && part.state.error.trim() ? part.state.error : "unknown error"
|
||||
commits.push(failTool(part, text))
|
||||
return out(data, commits, view)
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type !== "text" && part.type !== "reasoning") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (data.ids.has(part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const kind = part.type === "text" ? "assistant" : "reasoning"
|
||||
if (typeof part.messageID === "string") {
|
||||
data.msg.set(part.id, part.messageID)
|
||||
}
|
||||
|
||||
const msg = part.messageID
|
||||
const role = msg ? data.role.get(msg) : undefined
|
||||
if (role === "user") {
|
||||
data.ids.add(part.id)
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !input.thinking) {
|
||||
if (part.time?.end) {
|
||||
data.ids.add(part.id)
|
||||
}
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.part.set(part.id, kind)
|
||||
syncText(data, part.id, part.text)
|
||||
|
||||
if (part.time?.end) {
|
||||
data.end.add(part.id)
|
||||
}
|
||||
|
||||
if (msg && !role) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!ready(data, part.id)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
flushPart(data, commits, part.id)
|
||||
|
||||
if (!part.time?.end) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.ids.add(part.id)
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
upsert(data.permissions, enrichPermission(data, event.properties))
|
||||
return out(data, commits, queueFooter(data))
|
||||
}
|
||||
|
||||
if (event.type === "permission.replied") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!remove(data.permissions, event.properties.requestID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits, queueFooter(data))
|
||||
}
|
||||
|
||||
if (event.type === "question.asked") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
upsert(data.questions, event.properties)
|
||||
return out(data, commits, queueFooter(data))
|
||||
}
|
||||
|
||||
if (event.type === "question.replied" || event.type === "question.rejected") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
if (!remove(data.questions, event.properties.requestID)) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits, queueFooter(data))
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (event.properties.sessionID !== input.sessionID || !event.properties.error) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
commits.push({
|
||||
kind: "error",
|
||||
text: formatError(event.properties.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
return out(data, commits)
|
||||
}
|
||||
192
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
192
packages/opencode/src/cli/cmd/run/session.shared.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Session message extraction and prompt history.
|
||||
//
|
||||
// Fetches session messages from the SDK and extracts user turn text for
|
||||
// the prompt history ring. Also finds the most recently used variant for
|
||||
// the current model so the footer can pre-select it.
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
provider: string | undefined
|
||||
model: string | undefined
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
export type RunSession = {
|
||||
first: boolean
|
||||
turns: Turn[]
|
||||
}
|
||||
|
||||
function copy(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: RunPrompt, b: RunPrompt): boolean {
|
||||
return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
try {
|
||||
const next = new URL(url)
|
||||
if (next.protocol === "file:") {
|
||||
return path.basename(fileURLToPath(next)) || url
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function fileSource(
|
||||
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
|
||||
text: { start: number; end: number; value: string },
|
||||
) {
|
||||
if (part.source) {
|
||||
return {
|
||||
...structuredClone(part.source),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file" as const,
|
||||
path: part.filename ?? part.url,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const files: Array<Extract<SessionMessages[number]["parts"][number], { type: "file" }>> = []
|
||||
const parts: RunPrompt["parts"] = []
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "file") {
|
||||
if (!part.source?.text) {
|
||||
files.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: structuredClone(part.source),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "agent" && part.source) {
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: structuredClone(part.source),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
return part.type === "text" && !part.synthetic
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
let cursor = Bun.stringWidth(text)
|
||||
|
||||
for (const part of files) {
|
||||
const value = "@" + fileName(part.url, part.filename)
|
||||
const gap = text ? " " : ""
|
||||
const start = cursor + Bun.stringWidth(gap)
|
||||
text += gap + value
|
||||
const end = start + Bun.stringWidth(value)
|
||||
cursor = end
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: fileSource(part, {
|
||||
start,
|
||||
end,
|
||||
value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return { text, parts }
|
||||
}
|
||||
|
||||
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
if (msg.info.role !== "user") {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: prompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(messages: SessionMessages): RunSession {
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((msg) => {
|
||||
const item = turn(msg)
|
||||
return item ? [item] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise<RunSession> {
|
||||
const response = await sdk.session.messages({
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
return createSession(response.data ?? [])
|
||||
}
|
||||
|
||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
const out: RunPrompt[] = []
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.prompt.text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (out[out.length - 1] && same(out[out.length - 1], turn.prompt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(copy(turn.prompt))
|
||||
}
|
||||
|
||||
return out.slice(-limit)
|
||||
}
|
||||
|
||||
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return turn.variant
|
||||
}
|
||||
}
|
||||
291
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
291
packages/opencode/src/cli/cmd/run/splash.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
// Entry and exit splash banners for direct interactive mode scrollback.
|
||||
//
|
||||
// Renders the opencode ASCII logo with half-block shadow characters, the
|
||||
// session title, and contextual hints (entry: "/exit to finish", exit:
|
||||
// "opencode -s <id>" to resume). These are scrollback snapshots, so they
|
||||
// become immutable terminal history once committed.
|
||||
//
|
||||
// The logo uses a cell-based renderer. cells() classifies each character
|
||||
// in the logo template as text, full-block, half-block-mix, or
|
||||
// half-block-top, and draw() renders it with foreground/background shadow
|
||||
// colors from the theme.
|
||||
import {
|
||||
BoxRenderable,
|
||||
type ColorInput,
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { logo } from "../../logo"
|
||||
import type { RunEntryTheme } from "./theme"
|
||||
|
||||
export const SPLASH_TITLE_LIMIT = 50
|
||||
export const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
theme: RunEntryTheme
|
||||
background: ColorInput
|
||||
showSession?: boolean
|
||||
}
|
||||
|
||||
export type SplashMeta = {
|
||||
title: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type Cell = {
|
||||
char: string
|
||||
mark: "text" | "full" | "mix" | "top"
|
||||
}
|
||||
|
||||
let id = 0
|
||||
|
||||
function cells(line: string): Cell[] {
|
||||
const list: Cell[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
list.push({ char: " ", mark: "full" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
list.push({ char: "▀", mark: "mix" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
list.push({ char: "▀", mark: "top" })
|
||||
continue
|
||||
}
|
||||
|
||||
list.push({ char, mark: "text" })
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(text.trim(), SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
root: BoxRenderable,
|
||||
ctx: ScrollbackRenderContext,
|
||||
line: {
|
||||
left: number
|
||||
top: number
|
||||
text: string
|
||||
fg: ColorInput
|
||||
bg?: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
): void {
|
||||
if (line.left >= ctx.width) {
|
||||
return
|
||||
}
|
||||
|
||||
root.add(
|
||||
new TextRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-line-${id++}`,
|
||||
position: "absolute",
|
||||
left: line.left,
|
||||
top: line.top,
|
||||
width: Math.max(1, ctx.width - line.left),
|
||||
height: 1,
|
||||
wrapMode: "none",
|
||||
content: line.text,
|
||||
fg: line.fg,
|
||||
bg: line.bg,
|
||||
attributes: line.attrs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function push(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
left: number,
|
||||
top: number,
|
||||
text: string,
|
||||
fg: ColorInput,
|
||||
bg?: ColorInput,
|
||||
attrs?: number,
|
||||
): void {
|
||||
lines.push({ left, top, text, fg, bg, attrs })
|
||||
}
|
||||
|
||||
function color(input: ColorInput, fallback: RGBA): RGBA {
|
||||
if (input instanceof RGBA) {
|
||||
return input
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (input === "transparent" || input === "none") {
|
||||
return RGBA.fromValues(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (input.startsWith("#")) {
|
||||
return RGBA.fromHex(input)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function shade(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
const r = base.r + (overlay.r - base.r) * alpha
|
||||
const g = base.g + (overlay.g - base.g) * alpha
|
||||
const b = base.b + (overlay.b - base.b) * alpha
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function draw(
|
||||
lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
|
||||
row: string,
|
||||
input: {
|
||||
left: number
|
||||
top: number
|
||||
fg: ColorInput
|
||||
shadow: ColorInput
|
||||
attrs?: number
|
||||
},
|
||||
) {
|
||||
let x = input.left
|
||||
for (const cell of cells(row)) {
|
||||
if (cell.mark === "full") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "mix") {
|
||||
push(lines, x, input.top, cell.char, input.fg, input.shadow, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.mark === "top") {
|
||||
push(lines, x, input.top, cell.char, input.shadow, undefined, input.attrs)
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
|
||||
push(lines, x, input.top, cell.char, input.fg, undefined, input.attrs)
|
||||
x += 1
|
||||
}
|
||||
}
|
||||
|
||||
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
|
||||
const width = Math.max(1, ctx.width)
|
||||
const meta = splashMeta(input)
|
||||
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
|
||||
const bg = color(input.background, RGBA.fromValues(0, 0, 0, 0))
|
||||
const left = color(input.theme.system.body, RGBA.fromInts(100, 116, 139))
|
||||
const right = color(input.theme.assistant.body, RGBA.fromInts(248, 250, 252))
|
||||
const leftShadow = shade(bg, left, 0.25)
|
||||
const rightShadow = shade(bg, right, 0.25)
|
||||
let y = 0
|
||||
|
||||
for (let i = 0; i < logo.left.length; i += 1) {
|
||||
const leftText = logo.left[i] ?? ""
|
||||
const rightText = logo.right[i] ?? ""
|
||||
|
||||
draw(lines, leftText, {
|
||||
left: 0,
|
||||
top: y,
|
||||
fg: left,
|
||||
shadow: leftShadow,
|
||||
})
|
||||
draw(lines, rightText, {
|
||||
left: leftText.length + 1,
|
||||
top: y,
|
||||
fg: right,
|
||||
shadow: rightShadow,
|
||||
attrs: TextAttributes.BOLD,
|
||||
})
|
||||
y += 1
|
||||
}
|
||||
|
||||
y += 1
|
||||
|
||||
if (input.showSession !== false) {
|
||||
const label = "Session".padEnd(10, " ")
|
||||
push(lines, 0, y, label, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(lines, label.length, y, meta.title, input.theme.assistant.body, undefined, TextAttributes.BOLD)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "entry") {
|
||||
push(lines, 0, y, "Type /exit or /quit to finish.", input.theme.system.body, undefined, undefined)
|
||||
y += 1
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const next = "Continue".padEnd(10, " ")
|
||||
push(lines, 0, y, next, input.theme.system.body, undefined, TextAttributes.DIM)
|
||||
push(
|
||||
lines,
|
||||
next.length,
|
||||
y,
|
||||
`opencode -s ${meta.session_id}`,
|
||||
input.theme.assistant.body,
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
y += 1
|
||||
}
|
||||
|
||||
const height = Math.max(1, y)
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
id: `run-direct-splash-${kind}-${id++}`,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
for (const line of lines) {
|
||||
write(root, ctx, line)
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
width,
|
||||
height,
|
||||
rowColumns: width,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "entry", ctx)
|
||||
}
|
||||
|
||||
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
|
||||
return (ctx) => build(input, "exit", ctx)
|
||||
}
|
||||
380
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
380
packages/opencode/src/cli/cmd/run/stream.transport.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
// SDK event subscription and prompt turn coordination.
|
||||
//
|
||||
// Creates a long-lived event stream subscription and feeds every event
|
||||
// through the session-data reducer. The reducer produces scrollback commits
|
||||
// and footer patches, which get forwarded to the footer through stream.ts.
|
||||
//
|
||||
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
|
||||
// SDK, arms a deferred Wait, and resolves when a session.status idle event
|
||||
// arrives for this session. If the turn is aborted (user interrupt), it
|
||||
// flushes any in-progress parts as interrupted entries.
|
||||
//
|
||||
// The tick counter prevents stale idle events from resolving the wrong turn
|
||||
// -- each turn gets a monotonically increasing tick, and idle events only
|
||||
// resolve the wait if the tick matches.
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type StreamInput = {
|
||||
sdk: OpencodeClient
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: () => Record<string, number>
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type Wait = {
|
||||
tick: number
|
||||
armed: boolean
|
||||
done: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
export type SessionTurnInput = {
|
||||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
prompt: RunPrompt
|
||||
files: RunFilePart[]
|
||||
includeFiles: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
// Creates a deferred promise tied to a specific turn tick.
|
||||
function defer(tick: number): Wait {
|
||||
let resolve: () => void = () => {}
|
||||
let reject: (error: unknown) => void = () => {}
|
||||
const done = new Promise<void>((next, fail) => {
|
||||
resolve = next
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return {
|
||||
tick,
|
||||
armed: false,
|
||||
done,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
}
|
||||
|
||||
// Races the turn's deferred promise against an abort signal.
|
||||
function waitTurn(done: Promise<void>, signal: AbortSignal): Promise<"idle" | "abort"> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
resolve("abort")
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve("abort")
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
done.then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve("idle")
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function formatUnknownError(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const value = error as { message?: unknown; name?: unknown }
|
||||
if (typeof value.message === "string" && value.message.trim()) {
|
||||
return value.message
|
||||
}
|
||||
|
||||
if (typeof value.name === "string" && value.name.trim()) {
|
||||
return value.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
// Opens an SDK event subscription and returns a SessionTransport.
|
||||
//
|
||||
// The background `watch` loop consumes every SDK event, runs it through the
|
||||
// reducer, and writes output to the footer. When a session.status idle
|
||||
// event arrives, it resolves the current turn's Wait so runPromptTurn()
|
||||
// can return.
|
||||
//
|
||||
// The transport is single-turn: only one runPromptTurn() call can be active
|
||||
// at a time. The prompt queue enforces this from above.
|
||||
export async function createSessionTransport(input: StreamInput): Promise<SessionTransport> {
|
||||
const abort = new AbortController()
|
||||
const halt = () => {
|
||||
abort.abort()
|
||||
}
|
||||
input.signal?.addEventListener("abort", halt, { once: true })
|
||||
|
||||
const events = await input.sdk.event.subscribe(undefined, {
|
||||
signal: abort.signal,
|
||||
})
|
||||
input.trace?.write("recv.subscribe", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const closeStream = () => {
|
||||
// Pass undefined explicitly so TS accepts AsyncGenerator.return().
|
||||
void events.stream.return(undefined).catch(() => {})
|
||||
}
|
||||
|
||||
let data = createSessionData()
|
||||
let wait: Wait | undefined
|
||||
let tick = 0
|
||||
let fault: unknown
|
||||
let closed = false
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (fault) {
|
||||
return
|
||||
}
|
||||
|
||||
fault = error
|
||||
const next = wait
|
||||
wait = undefined
|
||||
next?.reject(error)
|
||||
}
|
||||
|
||||
const mark = (event: Event) => {
|
||||
if (
|
||||
event.type !== "session.status" ||
|
||||
event.properties.sessionID !== input.sessionID ||
|
||||
event.properties.status.type !== "idle"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = wait
|
||||
if (!next || !next.armed) {
|
||||
return
|
||||
}
|
||||
|
||||
tick = next.tick + 1
|
||||
wait = undefined
|
||||
next.resolve()
|
||||
}
|
||||
|
||||
const flush = (type: "turn.abort" | "turn.cancel") => {
|
||||
const commits: StreamCommit[] = []
|
||||
flushInterrupted(data, commits)
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
{
|
||||
data,
|
||||
commits,
|
||||
},
|
||||
)
|
||||
input.trace?.write(type, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const watch = (async () => {
|
||||
try {
|
||||
for await (const item of events.stream) {
|
||||
if (input.footer.isClosed) {
|
||||
break
|
||||
}
|
||||
|
||||
const event = item as Event
|
||||
input.trace?.write("recv.event", event)
|
||||
const next = reduceSessionData({
|
||||
data,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
data = next.data
|
||||
|
||||
if (next.commits.length > 0 || next.footer?.patch || next.footer?.view) {
|
||||
input.trace?.write("reduce.output", {
|
||||
commits: next.commits,
|
||||
footer: next.footer,
|
||||
})
|
||||
}
|
||||
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
next,
|
||||
)
|
||||
|
||||
mark(event)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
fail(error)
|
||||
}
|
||||
} finally {
|
||||
if (!abort.signal.aborted && !fault) {
|
||||
fail(new Error("session event stream closed"))
|
||||
}
|
||||
closeStream()
|
||||
}
|
||||
})()
|
||||
|
||||
const runPromptTurn = async (next: SessionTurnInput): Promise<void> => {
|
||||
if (next.signal?.aborted || input.footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fault) {
|
||||
throw fault
|
||||
}
|
||||
|
||||
if (wait) {
|
||||
throw new Error("prompt already running")
|
||||
}
|
||||
|
||||
const item = defer(tick)
|
||||
wait = item
|
||||
data.announced = false
|
||||
|
||||
const turn = new AbortController()
|
||||
const stop = () => {
|
||||
turn.abort()
|
||||
}
|
||||
next.signal?.addEventListener("abort", stop, { once: true })
|
||||
abort.signal.addEventListener("abort", stop, { once: true })
|
||||
|
||||
try {
|
||||
const req = {
|
||||
sessionID: input.sessionID,
|
||||
agent: next.agent,
|
||||
model: next.model,
|
||||
variant: next.variant,
|
||||
parts: [
|
||||
...(next.includeFiles ? next.files : []),
|
||||
{ type: "text" as const, text: next.prompt.text },
|
||||
...next.prompt.parts,
|
||||
],
|
||||
}
|
||||
input.trace?.write("send.prompt", req)
|
||||
await input.sdk.session.prompt(req, {
|
||||
signal: turn.signal,
|
||||
})
|
||||
input.trace?.write("send.prompt.ok", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
item.armed = true
|
||||
|
||||
if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed) {
|
||||
if (wait === item) {
|
||||
wait = undefined
|
||||
}
|
||||
flush("turn.abort")
|
||||
return
|
||||
}
|
||||
|
||||
if (!input.footer.isClosed && !data.announced) {
|
||||
input.trace?.write("ui.patch", {
|
||||
phase: "running",
|
||||
status: "waiting for assistant",
|
||||
})
|
||||
input.footer.event({
|
||||
type: "turn.wait",
|
||||
})
|
||||
}
|
||||
|
||||
if (tick > item.tick) {
|
||||
if (wait === item) {
|
||||
wait = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const state = await waitTurn(item.done, turn.signal)
|
||||
if (wait === item) {
|
||||
wait = undefined
|
||||
}
|
||||
|
||||
if (state === "abort") {
|
||||
flush("turn.abort")
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (wait === item) {
|
||||
wait = undefined
|
||||
}
|
||||
|
||||
const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed
|
||||
if (canceled) {
|
||||
flush("turn.cancel")
|
||||
return
|
||||
}
|
||||
|
||||
if (error === fault) {
|
||||
throw error
|
||||
}
|
||||
|
||||
input.trace?.write("send.prompt.error", {
|
||||
sessionID: input.sessionID,
|
||||
error: formatUnknownError(error),
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
input.trace?.write("turn.end", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
next.signal?.removeEventListener("abort", stop)
|
||||
abort.signal.removeEventListener("abort", stop)
|
||||
}
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
input.signal?.removeEventListener("abort", halt)
|
||||
abort.abort()
|
||||
closeStream()
|
||||
await watch.catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
runPromptTurn,
|
||||
close,
|
||||
}
|
||||
}
|
||||
59
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
59
packages/opencode/src/cli/cmd/run/stream.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Thin bridge between the session-data reducer output and the footer API.
|
||||
//
|
||||
// The reducer produces StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view change). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterPatch } from "./types"
|
||||
import type { SessionDataOutput } from "./session-data"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type OutputInput = {
|
||||
footer: FooterApi
|
||||
trace?: Trace
|
||||
}
|
||||
|
||||
// Default to "running" phase when a status string arrives without an explicit phase.
|
||||
function patch(next: FooterPatch): FooterPatch {
|
||||
if (typeof next.status === "string" && next.phase === undefined) {
|
||||
return {
|
||||
phase: "running",
|
||||
...next,
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
if (out.footer?.patch) {
|
||||
const next = patch(out.footer.patch)
|
||||
input.trace?.write("ui.patch", next)
|
||||
input.footer.event({
|
||||
type: "stream.patch",
|
||||
patch: next,
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.footer?.view) {
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("ui.patch", {
|
||||
view: out.footer.view,
|
||||
})
|
||||
input.footer.event({
|
||||
type: "stream.view",
|
||||
view: out.footer.view,
|
||||
})
|
||||
}
|
||||
239
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
239
packages/opencode/src/cli/cmd/run/theme.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// Theme resolution for direct interactive mode.
|
||||
//
|
||||
// Derives scrollback and footer colors from the terminal's actual palette.
|
||||
// resolveRunTheme() queries the renderer for the terminal's 16-color palette,
|
||||
// detects dark/light mode, and maps through the TUI's theme system to produce
|
||||
// a RunTheme. Falls back to a hardcoded dark-mode palette if detection fails.
|
||||
//
|
||||
// The theme has three parts:
|
||||
// entry → per-EntryKind colors for plain scrollback text
|
||||
// footer → highlight, muted, text, surface, and line colors for the footer
|
||||
// block → richer text/syntax/diff colors for static tool snapshots
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput } from "@opentui/core"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import type { EntryKind } from "./types"
|
||||
|
||||
type Tone = {
|
||||
body: ColorInput
|
||||
start?: ColorInput
|
||||
}
|
||||
|
||||
export type RunEntryTheme = Record<EntryKind, Tone>
|
||||
|
||||
export type RunFooterTheme = {
|
||||
highlight: ColorInput
|
||||
warning: ColorInput
|
||||
success: ColorInput
|
||||
error: ColorInput
|
||||
muted: ColorInput
|
||||
text: ColorInput
|
||||
shade: ColorInput
|
||||
surface: ColorInput
|
||||
pane: ColorInput
|
||||
border: ColorInput
|
||||
line: ColorInput
|
||||
}
|
||||
|
||||
export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
diffRemovedBg: ColorInput
|
||||
diffContextBg: ColorInput
|
||||
diffHighlightAdded: ColorInput
|
||||
diffHighlightRemoved: ColorInput
|
||||
diffLineNumber: ColorInput
|
||||
diffAddedLineNumberBg: ColorInput
|
||||
diffRemovedLineNumberBg: ColorInput
|
||||
}
|
||||
|
||||
export type RunTheme = {
|
||||
background: ColorInput
|
||||
footer: RunFooterTheme
|
||||
entry: RunEntryTheme
|
||||
block: RunBlockTheme
|
||||
}
|
||||
|
||||
export const transparent = RGBA.fromValues(0, 0, 0, 0)
|
||||
|
||||
function alpha(color: RGBA, value: number): RGBA {
|
||||
const a = Math.max(0, Math.min(1, value))
|
||||
return RGBA.fromValues(color.r, color.g, color.b, a)
|
||||
}
|
||||
|
||||
function rgba(hex: string, value?: number): RGBA {
|
||||
const color = RGBA.fromHex(hex)
|
||||
if (value === undefined) {
|
||||
return color
|
||||
}
|
||||
|
||||
return alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
||||
if (lum > 0.5) {
|
||||
return "light"
|
||||
}
|
||||
|
||||
return "dark"
|
||||
}
|
||||
|
||||
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
|
||||
if (color.a === 0) {
|
||||
return alpha(color, fallback)
|
||||
}
|
||||
|
||||
const target = Math.min(limit, color.a * scale)
|
||||
const mix = Math.min(1, target / color.a)
|
||||
|
||||
return RGBA.fromValues(
|
||||
base.r + (color.r - base.r) * mix,
|
||||
base.g + (color.g - base.g) * mix,
|
||||
base.b + (color.b - base.b) * mix,
|
||||
color.a,
|
||||
)
|
||||
}
|
||||
|
||||
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
|
||||
const bg = theme.background
|
||||
const pane = theme.backgroundElement
|
||||
const shade = fade(pane, bg, 0.12, 0.56, 0.72)
|
||||
const surface = fade(pane, bg, 0.18, 0.76, 0.9)
|
||||
const line = fade(pane, bg, 0.24, 0.9, 0.98)
|
||||
|
||||
return {
|
||||
background: theme.background,
|
||||
footer: {
|
||||
highlight: theme.primary,
|
||||
warning: theme.warning,
|
||||
success: theme.success,
|
||||
error: theme.error,
|
||||
muted: theme.textMuted,
|
||||
text: theme.text,
|
||||
shade,
|
||||
surface,
|
||||
pane,
|
||||
border: theme.border,
|
||||
line,
|
||||
},
|
||||
entry: {
|
||||
system: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
user: {
|
||||
body: theme.primary,
|
||||
},
|
||||
assistant: {
|
||||
body: theme.text,
|
||||
},
|
||||
reasoning: {
|
||||
body: theme.textMuted,
|
||||
},
|
||||
tool: {
|
||||
body: theme.text,
|
||||
start: theme.textMuted,
|
||||
},
|
||||
error: {
|
||||
body: theme.error,
|
||||
},
|
||||
},
|
||||
block: {
|
||||
text: theme.text,
|
||||
muted: theme.textMuted,
|
||||
syntax,
|
||||
diffAdded: theme.diffAdded,
|
||||
diffRemoved: theme.diffRemoved,
|
||||
diffAddedBg: theme.diffAddedBg,
|
||||
diffRemovedBg: theme.diffRemovedBg,
|
||||
diffContextBg: theme.diffContextBg,
|
||||
diffHighlightAdded: theme.diffHighlightAdded,
|
||||
diffHighlightRemoved: theme.diffHighlightRemoved,
|
||||
diffLineNumber: theme.diffLineNumber,
|
||||
diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const seed = {
|
||||
highlight: rgba("#38bdf8"),
|
||||
muted: rgba("#64748b"),
|
||||
text: rgba("#f8fafc"),
|
||||
panel: rgba("#0f172a"),
|
||||
success: rgba("#22c55e"),
|
||||
warning: rgba("#f59e0b"),
|
||||
error: rgba("#ef4444"),
|
||||
}
|
||||
|
||||
function tone(body: ColorInput, start?: ColorInput): Tone {
|
||||
return {
|
||||
body,
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
background: RGBA.fromValues(0, 0, 0, 0),
|
||||
footer: {
|
||||
highlight: seed.highlight,
|
||||
warning: seed.warning,
|
||||
success: seed.success,
|
||||
error: seed.error,
|
||||
muted: seed.muted,
|
||||
text: seed.text,
|
||||
shade: alpha(seed.panel, 0.68),
|
||||
surface: alpha(seed.panel, 0.86),
|
||||
pane: seed.panel,
|
||||
border: seed.muted,
|
||||
line: alpha(seed.panel, 0.96),
|
||||
},
|
||||
entry: {
|
||||
system: tone(seed.muted),
|
||||
user: tone(seed.highlight),
|
||||
assistant: tone(seed.text),
|
||||
reasoning: tone(seed.muted),
|
||||
tool: tone(seed.text, seed.muted),
|
||||
error: tone(seed.error),
|
||||
},
|
||||
block: {
|
||||
text: seed.text,
|
||||
muted: seed.muted,
|
||||
diffAdded: seed.success,
|
||||
diffRemoved: seed.error,
|
||||
diffAddedBg: alpha(seed.success, 0.18),
|
||||
diffRemovedBg: alpha(seed.error, 0.18),
|
||||
diffContextBg: alpha(seed.panel, 0.72),
|
||||
diffHighlightAdded: seed.success,
|
||||
diffHighlightRemoved: seed.error,
|
||||
diffLineNumber: seed.muted,
|
||||
diffAddedLineNumberBg: alpha(seed.success, 0.12),
|
||||
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
|
||||
},
|
||||
}
|
||||
|
||||
export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme> {
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
const bg = colors.defaultBackground ?? colors.palette[0]
|
||||
if (!bg) {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
|
||||
const mod = await import("../tui/context/theme")
|
||||
const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
|
||||
try {
|
||||
return map(theme, mod.generateSyntax(theme))
|
||||
} catch {
|
||||
return map(theme)
|
||||
}
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
}
|
||||
1465
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
1465
packages/opencode/src/cli/cmd/run/tool.ts
Normal file
File diff suppressed because it is too large
Load Diff
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
94
packages/opencode/src/cli/cmd/run/trace.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Dev-only JSONL event trace for direct interactive mode.
|
||||
//
|
||||
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
|
||||
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
|
||||
// a latest.json pointer so you can quickly find the most recent trace.
|
||||
//
|
||||
// The trace captures the full closed loop: outbound prompts, inbound SDK
|
||||
// events, reducer output, footer commits, and turn lifecycle markers.
|
||||
// Useful for debugging stream ordering, permission behavior, and
|
||||
// footer/transcript mismatches.
|
||||
//
|
||||
// Lazy-initialized: the first call to trace() decides whether tracing is
|
||||
// active based on the env var, and subsequent calls return the cached result.
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { Global } from "../../../global"
|
||||
|
||||
export type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
let state: Trace | false | undefined
|
||||
|
||||
function stamp() {
|
||||
return new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d+Z$/, "Z")
|
||||
}
|
||||
|
||||
function file() {
|
||||
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
|
||||
}
|
||||
|
||||
function latest() {
|
||||
return path.join(Global.Path.log, "direct", "latest.json")
|
||||
}
|
||||
|
||||
function text(data: unknown) {
|
||||
return JSON.stringify(
|
||||
data,
|
||||
(_key, value) => {
|
||||
if (typeof value === "bigint") {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function trace() {
|
||||
if (state !== undefined) {
|
||||
return state || undefined
|
||||
}
|
||||
|
||||
if (!process.env.OPENCODE_DIRECT_TRACE) {
|
||||
state = false
|
||||
return
|
||||
}
|
||||
|
||||
const target = file()
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
latest(),
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
argv: process.argv.slice(2),
|
||||
path: target,
|
||||
}) + "\n",
|
||||
)
|
||||
state = {
|
||||
write(type: string, data?: unknown) {
|
||||
fs.appendFileSync(
|
||||
target,
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
type,
|
||||
data,
|
||||
}) + "\n",
|
||||
)
|
||||
},
|
||||
}
|
||||
state.write("trace.start", {
|
||||
argv: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
path: target,
|
||||
})
|
||||
return state
|
||||
}
|
||||
195
packages/opencode/src/cli/cmd/run/types.ts
Normal file
195
packages/opencode/src/cli/cmd/run/types.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// Shared type vocabulary for the direct interactive mode (`run --interactive`).
|
||||
//
|
||||
// Direct mode uses a split-footer terminal layout: immutable scrollback for the
|
||||
// session transcript, and a mutable footer for prompt input, status, and
|
||||
// permission/question UI. Every module in run/* shares these types to stay
|
||||
// aligned on that two-lane model.
|
||||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
|
||||
|
||||
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
|
||||
|
||||
export type RunPrompt = {
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
}
|
||||
|
||||
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
|
||||
|
||||
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
|
||||
|
||||
export type RunResource = RunResourceMap[string]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
directory: string
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
resume?: boolean
|
||||
agent: string | undefined
|
||||
model: PromptModel | undefined
|
||||
variant: string | undefined
|
||||
files: RunFilePart[]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
demo?: RunDemo
|
||||
demoText?: string
|
||||
}
|
||||
|
||||
export type RunDemo = "on" | "permission" | "question" | "mix" | "text"
|
||||
|
||||
// The semantic role of a scrollback entry. Maps 1:1 to theme colors.
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
// Whether the assistant is actively processing a turn.
|
||||
export type FooterPhase = "idle" | "running"
|
||||
|
||||
// Full snapshot of footer status bar state. Every update replaces the whole
|
||||
// object in the SolidJS signal so the view re-renders atomically.
|
||||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
duration: string
|
||||
usage: string
|
||||
first: boolean
|
||||
interrupt: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
// A partial update to FooterState. The footer merges this onto the current state.
|
||||
export type FooterPatch = Partial<FooterState>
|
||||
|
||||
export type RunDiffStyle = "auto" | "stacked"
|
||||
|
||||
export type ScrollbackOptions = {
|
||||
diffStyle?: RunDiffStyle
|
||||
}
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
// "prompt".
|
||||
export type FooterView =
|
||||
| { type: "prompt" }
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
}
|
||||
|
||||
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||
// transport both emit these to update footer state without reaching into
|
||||
// internal signals directly.
|
||||
export type FooterEvent =
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "first"
|
||||
first: boolean
|
||||
}
|
||||
| {
|
||||
type: "model"
|
||||
model: string
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.wait"
|
||||
}
|
||||
| {
|
||||
type: "turn.idle"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.duration"
|
||||
duration: string
|
||||
}
|
||||
| {
|
||||
type: "stream.patch"
|
||||
patch: FooterPatch
|
||||
}
|
||||
| {
|
||||
type: "stream.view"
|
||||
view: FooterView
|
||||
}
|
||||
|
||||
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
|
||||
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
||||
|
||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||
|
||||
export type FooterKeybinds = {
|
||||
leader: string
|
||||
variantCycle: string
|
||||
interrupt: string
|
||||
historyPrevious: string
|
||||
historyNext: string
|
||||
inputSubmit: string
|
||||
inputNewline: string
|
||||
}
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
export type StreamPhase = "start" | "progress" | "final"
|
||||
|
||||
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The session-data reducer produces
|
||||
// these from SDK events, and RunFooter.append() queues them for the next
|
||||
// microtask flush. Once flushed, they become immutable terminal scrollback
|
||||
// rows -- they cannot be rewritten.
|
||||
export type StreamCommit = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
phase: StreamPhase
|
||||
source: StreamSource
|
||||
messageID?: string
|
||||
partID?: string
|
||||
tool?: string
|
||||
part?: ToolPart
|
||||
interrupted?: boolean
|
||||
toolState?: StreamToolState
|
||||
toolError?: string
|
||||
}
|
||||
|
||||
// The public contract between the stream transport / prompt queue and
|
||||
// the footer. RunFooter implements this. The transport and queue never
|
||||
// touch the renderer directly -- they go through this interface.
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
idle(): Promise<void>
|
||||
close(): void
|
||||
destroy(): void
|
||||
}
|
||||
126
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
126
packages/opencode/src/cli/cmd/run/variant.shared.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Model variant resolution and persistence.
|
||||
//
|
||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
||||
//
|
||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import path from "path"
|
||||
import { Global } from "../../../global"
|
||||
import { Filesystem } from "../../../util/filesystem"
|
||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
||||
import type { RunInput } from "./types"
|
||||
|
||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
||||
|
||||
type ModelState = {
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
return `${provider}/${model}`
|
||||
}
|
||||
|
||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
||||
return modelKey(model.providerID, model.modelID)
|
||||
}
|
||||
|
||||
export function formatModelLabel(model: NonNullable<RunInput["model"]>, variant: string | undefined): string {
|
||||
const label = variant ? ` · ${variant}` : ""
|
||||
return `${model.modelID} · ${model.providerID}${label}`
|
||||
}
|
||||
|
||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const idx = variants.indexOf(current)
|
||||
if (idx === -1 || idx === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[idx + 1]
|
||||
}
|
||||
|
||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
||||
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
|
||||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
||||
// history. fitVariant() checks saved and session values against the available
|
||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
saved: string | undefined,
|
||||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await Filesystem.readJson<ModelState>(MODEL_FILE)
|
||||
return state.variant?.[variantKey(model)]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const state = await Filesystem.readJson<ModelState>(MODEL_FILE).catch(() => ({}) as ModelState)
|
||||
const map = {
|
||||
...(state.variant ?? {}),
|
||||
}
|
||||
const key = variantKey(model)
|
||||
if (variant) {
|
||||
map[key] = variant
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
delete map[key]
|
||||
}
|
||||
|
||||
await Filesystem.writeJson(MODEL_FILE, {
|
||||
...state,
|
||||
variant: map,
|
||||
})
|
||||
})().catch(() => {})
|
||||
}
|
||||
@@ -256,6 +256,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { tui } from "./app"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
@@ -70,7 +69,8 @@ export const AttachCommand = cmd({
|
||||
directory: directory && existsSync(directory) ? directory : process.cwd(),
|
||||
fn: () => TuiConfig.get(),
|
||||
})
|
||||
await tui({
|
||||
const app = await import("./app")
|
||||
await app.tui({
|
||||
url: args.url,
|
||||
config,
|
||||
args: {
|
||||
|
||||
@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
|
||||
])
|
||||
|
||||
async function submit() {
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
||||
setStore("prompt", "input", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return
|
||||
if (autocomplete?.visible) return
|
||||
if (!store.prompt.input) return
|
||||
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
|
||||
input.cursorOffset = input.plainText.length
|
||||
}
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onSubmit={() => {
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -509,7 +509,9 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
}
|
||||
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
// TODO: i exported this, just for keeping it simple for now, but this should
|
||||
// probably go into something shared if we decide to use this in opencode run
|
||||
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
@@ -703,11 +705,11 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA {
|
||||
return RGBA.fromInts(grayValue, grayValue, grayValue)
|
||||
}
|
||||
|
||||
function generateSyntax(theme: Theme) {
|
||||
export function generateSyntax(theme: TuiThemeCurrent) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
function generateSubtleSyntax(theme: Theme) {
|
||||
function generateSubtleSyntax(theme: TuiThemeCurrent) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
@@ -731,7 +733,7 @@ function generateSubtleSyntax(theme: Theme) {
|
||||
)
|
||||
}
|
||||
|
||||
function getSyntaxRules(theme: Theme) {
|
||||
function getSyntaxRules(theme: TuiThemeCurrent) {
|
||||
return [
|
||||
{
|
||||
scope: ["default"],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import path from "path"
|
||||
@@ -137,12 +136,18 @@ export const TuiThreadCommand = cmd({
|
||||
),
|
||||
})
|
||||
worker.onerror = (e) => {
|
||||
Log.Default.error(e)
|
||||
Log.Default.error("thread error", {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
error: e.error,
|
||||
})
|
||||
}
|
||||
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
const error = (e: unknown) => {
|
||||
Log.Default.error(e)
|
||||
Log.Default.error("process error", { error: errorMessage(e) })
|
||||
}
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
@@ -202,7 +207,8 @@ export const TuiThreadCommand = cmd({
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
await tui({
|
||||
const app = await import("./app")
|
||||
await app.tui({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
import { Process } from "../util/process"
|
||||
import z from "zod"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe, unique } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fsNode from "fs/promises"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
|
||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
|
||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
|
||||
|
||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
@@ -112,22 +111,4 @@ export namespace FileTime {
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.get(sessionID, file))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromise((s) => s.assert(sessionID, filepath))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
@@ -161,10 +160,4 @@ export namespace FileWatcher {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
||||
import { Log } from "../../util/log"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { CopilotModels } from "./models"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
|
||||
const log = Log.create({ service: "plugin.copilot" })
|
||||
|
||||
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
|
||||
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
||||
}
|
||||
|
||||
function fix(model: Model): Model {
|
||||
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
||||
function imgMsg(msg: any): boolean {
|
||||
if (msg?.role !== "user") return false
|
||||
|
||||
// Handle the 3 api formats
|
||||
|
||||
const content = msg.content
|
||||
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(
|
||||
(part: any) =>
|
||||
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
||||
)
|
||||
}
|
||||
|
||||
function fix(model: Model, url: string): Model {
|
||||
return {
|
||||
...model,
|
||||
api: {
|
||||
...model.api,
|
||||
url,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
}
|
||||
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
id: "github-copilot",
|
||||
async models(provider, ctx) {
|
||||
if (ctx.auth?.type !== "oauth") {
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
||||
}
|
||||
|
||||
const auth = ctx.auth
|
||||
|
||||
return CopilotModels.get(
|
||||
base(ctx.auth.enterpriseUrl),
|
||||
base(auth.enterpriseUrl),
|
||||
{
|
||||
Authorization: `Bearer ${ctx.auth.refresh}`,
|
||||
Authorization: `Bearer ${auth.refresh}`,
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
},
|
||||
provider.models,
|
||||
).catch((error) => {
|
||||
log.error("failed to fetch copilot models", { error })
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
||||
return Object.fromEntries(
|
||||
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const info = await getAuth()
|
||||
if (!info || info.type !== "oauth") return {}
|
||||
|
||||
const baseURL = base(info.enterpriseUrl)
|
||||
|
||||
return {
|
||||
baseURL,
|
||||
apiKey: "",
|
||||
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||
const info = await getAuth()
|
||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(msg: any) =>
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
(item: any) =>
|
||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||
),
|
||||
isAgent: last?.role !== "user",
|
||||
isAgent: last?.role !== "user" || imgMsg(last),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
part.content.some((nested: any) => nested?.type === "image")),
|
||||
),
|
||||
),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -52,13 +52,15 @@ export namespace CopilotModels {
|
||||
(remote.capabilities.supports.vision ?? false) ||
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
|
||||
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||
|
||||
return {
|
||||
id: key,
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: remote.id,
|
||||
url,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
url: isMsgApi ? `${url}/v1` : url,
|
||||
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||
},
|
||||
// API response wins
|
||||
status: "active",
|
||||
|
||||
@@ -124,7 +124,7 @@ export namespace Plugin {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
const input: PluginInput = {
|
||||
@@ -210,13 +210,15 @@ export namespace Plugin {
|
||||
return message
|
||||
},
|
||||
}).pipe(
|
||||
Effect.catch((message) =>
|
||||
bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
}),
|
||||
),
|
||||
Effect.catch(() => {
|
||||
// TODO: make proper events for this
|
||||
// bus.publish(Session.Event.Error, {
|
||||
// error: new NamedError.Unknown({
|
||||
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
// }).toObject(),
|
||||
// })
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Project } from "./project"
|
||||
import { Vcs } from "./vcs"
|
||||
@@ -11,6 +10,7 @@ import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Log } from "@/util/log"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
@@ -20,7 +20,7 @@ export async function InstanceBootstrap() {
|
||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
||||
await LSP.init()
|
||||
File.init()
|
||||
FileWatcher.init()
|
||||
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||
Vcs.init()
|
||||
Snapshot.init()
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { createVenice } from "venice-ai-sdk-provider"
|
||||
import { createAlibaba } from "@ai-sdk/alibaba"
|
||||
import {
|
||||
createGitLab,
|
||||
VERSION as GITLAB_PROVIDER_VERSION,
|
||||
@@ -145,6 +146,7 @@ export namespace Provider {
|
||||
"@ai-sdk/togetherai": createTogetherAI,
|
||||
"@ai-sdk/perplexity": createPerplexity,
|
||||
"@ai-sdk/vercel": createVercel,
|
||||
"@ai-sdk/alibaba": createAlibaba,
|
||||
"gitlab-ai-provider": createGitLab,
|
||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||
"venice-ai-sdk-provider": createVenice,
|
||||
|
||||
@@ -209,6 +209,9 @@ export namespace ProviderTransform {
|
||||
copilot: {
|
||||
copilot_cache_control: { type: "ephemeral" },
|
||||
},
|
||||
alibaba: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
|
||||
model.api.id.includes("claude") ||
|
||||
model.id.includes("anthropic") ||
|
||||
model.id.includes("claude") ||
|
||||
model.api.npm === "@ai-sdk/anthropic") &&
|
||||
model.api.npm === "@ai-sdk/anthropic" ||
|
||||
model.api.npm === "@ai-sdk/alibaba") &&
|
||||
model.api.npm !== "@ai-sdk/gateway"
|
||||
) {
|
||||
msgs = applyCaching(msgs, model)
|
||||
@@ -774,7 +778,10 @@ export namespace ProviderTransform {
|
||||
result["chat_template_args"] = { enable_thinking: true }
|
||||
}
|
||||
|
||||
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
|
||||
if (
|
||||
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
|
||||
input.model.api.npm === "@ai-sdk/openai-compatible"
|
||||
) {
|
||||
result["thinking"] = {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
@@ -199,26 +198,4 @@ export namespace Question {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID) {
|
||||
return runPromise((s) => s.reject(requestID))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
40
packages/opencode/src/server/adapter.bun.ts
Normal file
40
packages/opencode/src/server/adapter.bun.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Hono } from "hono"
|
||||
import { createBunWebSocket } from "hono/bun"
|
||||
import type { Adapter } from "./adapter"
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createBunWebSocket()
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
async listen(opts) {
|
||||
const args = {
|
||||
fetch: app.fetch,
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
websocket: ws.websocket,
|
||||
} as const
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) {
|
||||
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}
|
||||
if (!server.port) {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
return {
|
||||
port: server.port,
|
||||
stop(close?: boolean) {
|
||||
return Promise.resolve(server.stop(close))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
66
packages/opencode/src/server/adapter.node.ts
Normal file
66
packages/opencode/src/server/adapter.node.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import type { Hono } from "hono"
|
||||
import type { Adapter } from "./adapter"
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
async listen(opts) {
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: app.fetch })
|
||||
ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
port: addr.port,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
21
packages/opencode/src/server/adapter.ts
Normal file
21
packages/opencode/src/server/adapter.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
export type Opts = {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export type Listener = {
|
||||
port: number
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
listen(opts: Opts): Promise<Listener>
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
create(app: Hono): Runtime
|
||||
}
|
||||
150
packages/opencode/src/server/control/index.ts
Normal file
150
packages/opencode/src/server/control/index.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { Log } from "@/util/log"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { GlobalRoutes } from "../instance/global"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
return app
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import { Session } from "../../session"
|
||||
import { Config } from "../../config/config"
|
||||
import { ConsoleState } from "../../config/console-state"
|
||||
import { Account, AccountID, OrgID } from "../../account"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
import { Agent } from "@/agent/agent"
|
||||
|
||||
@@ -55,11 +57,20 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
|
||||
return c.json({
|
||||
...consoleState,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
})
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -80,17 +91,24 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
|
||||
|
||||
const orgs = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
|
||||
})),
|
||||
const orgs = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
return groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return c.json({ orgs })
|
||||
},
|
||||
@@ -115,7 +133,12 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", ConsoleSwitchBody),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -1,53 +1,33 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { createHash } from "node:crypto"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Global } from "../global"
|
||||
import { LSP } from "../lsp"
|
||||
import { Command } from "../command"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
import { ExperimentalRoutes } from "./routes/experimental"
|
||||
import { ProviderRoutes } from "./routes/provider"
|
||||
import { EventRoutes } from "./routes/event"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Vcs } from "../../project/vcs"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Skill } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Command } from "../../command"
|
||||
import { QuestionRoutes } from "./question"
|
||||
import { PermissionRoutes } from "./permission"
|
||||
import { ProjectRoutes } from "./project"
|
||||
import { SessionRoutes } from "./session"
|
||||
import { PtyRoutes } from "./pty"
|
||||
import { McpRoutes } from "./mcp"
|
||||
import { FileRoutes } from "./file"
|
||||
import { ConfigRoutes } from "./config"
|
||||
import { ExperimentalRoutes } from "./experimental"
|
||||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
||||
app
|
||||
.onError(errorHandler(log))
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
new Hono()
|
||||
.use(WorkspaceRouterMiddleware(upgrade))
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes(upgrade))
|
||||
.route("/config", ConfigRoutes())
|
||||
@@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
c.header("Content-Type", mime)
|
||||
if (mime.startsWith("text/html")) {
|
||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||
}
|
||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||
} else {
|
||||
return c.json({ error: "Not Found" }, 404)
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
})
|
||||
@@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { ServerProxy } from "./proxy"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { ServerProxy } from "../proxy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
@@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||
|
||||
return async (c) => {
|
||||
return async (c, next) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
@@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
return next()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||
// made sure that it will run without an instance
|
||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
return next()
|
||||
}
|
||||
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
@@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
return next()
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
return next()
|
||||
}
|
||||
|
||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Question } from "../../question"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await Question.list()
|
||||
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
return c.json(questions)
|
||||
},
|
||||
)
|
||||
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await Question.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Question.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Question.reject(params.requestID)
|
||||
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
permission: Permission.Ruleset.optional(),
|
||||
time: z
|
||||
.object({
|
||||
archived: z.number().optional(),
|
||||
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const current = await Session.get(sessionID)
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
await Session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await SessionRunState.assertNotBusy(params.sessionID)
|
||||
await Session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(params.sessionID)
|
||||
yield* session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import { Session } from "../session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler } from "hono"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import type { Log } from "../util/log"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
|
||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
||||
return (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (!skip) {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
}
|
||||
const timer = log.time("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) timer.stop()
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||
return input
|
||||
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zipped = compress()
|
||||
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||
const path = c.req.path
|
||||
const method = c.req.method
|
||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||
return zipped(c, next)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { generateSpecs } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { compress } from "hono/compress"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { cors } from "hono/cors"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "./router"
|
||||
import { errors } from "./error"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { adapter } from "#hono"
|
||||
import { MDNS } from "./mdns"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { initProjectors } from "./projectors"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { Log } from "@/util/log"
|
||||
import { ControlPlaneRoutes } from "./control"
|
||||
import { UIRoutes } from "./ui"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
@@ -33,231 +25,31 @@ export namespace Server {
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const zipped = compress()
|
||||
|
||||
const skipCompress = (path: string, method: string) => {
|
||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const Default = lazy(() => create({}))
|
||||
|
||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
||||
return app
|
||||
.onError(errorHandler(log))
|
||||
.use((c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (!skip) {
|
||||
log.info("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
}
|
||||
const timer = log.time("request", {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) timer.stop()
|
||||
})
|
||||
.use(
|
||||
cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (
|
||||
input === "tauri://localhost" ||
|
||||
input === "http://tauri.localhost" ||
|
||||
input === "https://tauri.localhost"
|
||||
)
|
||||
return input
|
||||
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use((c, next) => {
|
||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
||||
return zipped(c, next)
|
||||
})
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.use(WorkspaceRouterMiddleware(upgrade))
|
||||
}
|
||||
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const app = new Hono()
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const runtime = adapter.create(app)
|
||||
return {
|
||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
||||
ws,
|
||||
app: app
|
||||
.onError(ErrorMiddleware)
|
||||
.use(AuthMiddleware)
|
||||
.use(LoggerMiddleware)
|
||||
.use(CompressionMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(opts: { cors?: string[] }) {
|
||||
return create(opts).app
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
// Build a fresh app with all routes registered directly so
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
// strips the metadata symbol).
|
||||
const { app, ws } = create({})
|
||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
||||
const { app } = create({})
|
||||
const result = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
@@ -281,46 +73,21 @@ export namespace Server {
|
||||
cors?: string[]
|
||||
}): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
const server = await built.runtime.listen(opts)
|
||||
|
||||
const next = new URL("http://localhost")
|
||||
next.hostname = opts.hostname
|
||||
next.port = String(addr.port)
|
||||
next.port = String(server.port)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns &&
|
||||
addr.port &&
|
||||
server.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
MDNS.publish(server.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
@@ -328,27 +95,13 @@ export namespace Server {
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
port: server.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
closing ??= (async () => {
|
||||
if (mdns) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
await server.stop(close)
|
||||
})()
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
55
packages/opencode/src/server/ui/index.ts
Normal file
55
packages/opencode/src/server/ui/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export const UIRoutes = (): Hono =>
|
||||
new Hono().all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
c.header("Content-Type", mime)
|
||||
if (mime.startsWith("text/html")) {
|
||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||
}
|
||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||
} else {
|
||||
return c.json({ error: "Not Found" }, 404)
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import z from "zod"
|
||||
import { type ProviderMetadata } from "ai"
|
||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
@@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { Effect, Layer, Option, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -240,7 +239,7 @@ export namespace Session {
|
||||
|
||||
export const getUsage = (input: {
|
||||
model: Provider.Model
|
||||
usage: LanguageModelV2Usage
|
||||
usage: LanguageModelUsage
|
||||
metadata?: ProviderMetadata
|
||||
}) => {
|
||||
const safe = (value: number) => {
|
||||
@@ -249,11 +248,14 @@ export namespace Session {
|
||||
}
|
||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
|
||||
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
||||
|
||||
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
||||
const cacheReadInputTokens = safe(
|
||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||
)
|
||||
const cacheWriteInputTokens = safe(
|
||||
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
(input.usage.inputTokenDetails?.cacheWriteTokens ??
|
||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
// google-vertex-anthropic returns metadata under "vertex" key
|
||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
||||
@@ -274,7 +276,7 @@ export namespace Session {
|
||||
const tokens = {
|
||||
total,
|
||||
input: adjustedInputTokens,
|
||||
output: outputTokens - reasoningTokens,
|
||||
output: safe(outputTokens - reasoningTokens),
|
||||
reasoning: reasoningTokens,
|
||||
cache: {
|
||||
write: cacheWriteInputTokens,
|
||||
@@ -708,6 +710,10 @@ export namespace Session {
|
||||
runPromise((svc) => svc.setArchived(input)),
|
||||
)
|
||||
|
||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||
runPromise((svc) => svc.setPermission(input)),
|
||||
)
|
||||
|
||||
export const setRevert = fn(
|
||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||
(input) =>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
@@ -238,21 +237,7 @@ export namespace Instruction {
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function clear(messageID: MessageID) {
|
||||
return runPromise((svc) => svc.clear(messageID))
|
||||
}
|
||||
|
||||
export async function systemPaths() {
|
||||
return runPromise((svc) => svc.systemPaths())
|
||||
}
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
return extract(messages)
|
||||
}
|
||||
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
|
||||
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
|
||||
}
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||
|
||||
export function isMedia(mime: string) {
|
||||
return mime.startsWith("image/") || mime === "application/pdf"
|
||||
}
|
||||
@@ -808,7 +810,7 @@ export namespace MessageV2 {
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Attached image(s) from tool result:",
|
||||
text: SYNTHETIC_ATTACHMENT_PROMPT,
|
||||
},
|
||||
...media.map((attachment) => ({
|
||||
type: "file" as const,
|
||||
|
||||
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
|
||||
const instruction = yield* Instruction.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
|
||||
const run = {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
|
||||
const msgs = onlySubtasks
|
||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
||||
const text = yield* Effect.promise(async (signal) => {
|
||||
const result = await LLM.stream({
|
||||
const text = yield* llm
|
||||
.stream({
|
||||
agent: ag,
|
||||
user: firstInfo,
|
||||
system: [],
|
||||
small: true,
|
||||
tools: {},
|
||||
model: mdl,
|
||||
abort: signal,
|
||||
sessionID: input.session.id,
|
||||
retries: 2,
|
||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||
})
|
||||
return result.text
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||
Stream.map((e) => e.text),
|
||||
Stream.mkString,
|
||||
Effect.orDie,
|
||||
)
|
||||
const cleaned = text
|
||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||
.split("\n")
|
||||
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
||||
Effect.promise(() => SystemPrompt.environment(model)),
|
||||
sys.skills(agent),
|
||||
Effect.sync(() => sys.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
@@ -1687,9 +1692,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Bus.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Runner } from "@/effect/runner"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Effect, Layer, Scope, Context } from "effect"
|
||||
import { Session } from "."
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -106,9 +105,4 @@ export namespace SessionRunState {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function assertNotBusy(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
|
||||
return [PROMPT_DEFAULT]
|
||||
}
|
||||
|
||||
export async function environment(model: Provider.Model) {
|
||||
const project = Instance.project
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${Instance.directory}`,
|
||||
` Workspace root folder: ${Instance.worktree}`,
|
||||
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
`<directories>`,
|
||||
` ${
|
||||
project.vcs === "git" && false
|
||||
? await Ripgrep.tree({
|
||||
cwd: Instance.directory,
|
||||
limit: 50,
|
||||
})
|
||||
: ""
|
||||
}`,
|
||||
`</directories>`,
|
||||
].join("\n"),
|
||||
]
|
||||
export interface Interface {
|
||||
readonly environment: (model: Provider.Model) => string[]
|
||||
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export async function skills(agent: Agent.Info) {
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
|
||||
|
||||
const list = await Skill.available(agent)
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
return [
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||
Skill.fmt(list, { verbose: true }),
|
||||
].join("\n")
|
||||
}
|
||||
return Service.of({
|
||||
environment(model) {
|
||||
const project = Instance.project
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${Instance.directory}`,
|
||||
` Workspace root folder: ${Instance.worktree}`,
|
||||
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
].join("\n"),
|
||||
]
|
||||
},
|
||||
|
||||
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
|
||||
const list = yield* skill.available(agent)
|
||||
|
||||
return [
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||
Skill.fmt(list, { verbose: true }),
|
||||
].join("\n")
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -177,8 +177,39 @@ export namespace Snapshot {
|
||||
const all = Array.from(new Set([...tracked, ...untracked]))
|
||||
if (!all.length) return
|
||||
|
||||
// Filter out files that are now gitignored even if previously tracked
|
||||
// Files may have been tracked before being gitignored, so we need to check
|
||||
// against the source project's current gitignore rules
|
||||
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...all,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
const ignored =
|
||||
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
|
||||
const filtered = all.filter((item) => !ignored.has(item))
|
||||
|
||||
// Remove newly-ignored files from snapshot index to prevent re-adding
|
||||
if (ignored.size > 0) {
|
||||
const ignoredFiles = Array.from(ignored)
|
||||
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
||||
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
|
||||
cwd: state.directory,
|
||||
})
|
||||
}
|
||||
|
||||
if (!filtered.length) return
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
filtered.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
@@ -259,14 +290,39 @@ export namespace Snapshot {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
const files = result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
// Filter out files that are now gitignored
|
||||
if (files.length > 0) {
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...files,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
if (check.code === 0) {
|
||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||
const filtered = files.filter((item) => !ignored.has(item))
|
||||
return {
|
||||
hash,
|
||||
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -616,6 +672,30 @@ export namespace Snapshot {
|
||||
} satisfies Row,
|
||||
]
|
||||
})
|
||||
|
||||
// Filter out files that are now gitignored
|
||||
if (rows.length > 0) {
|
||||
const files = rows.map((r) => r.file)
|
||||
const checkArgs = [
|
||||
...quote,
|
||||
"--git-dir",
|
||||
path.join(state.worktree, ".git"),
|
||||
"--work-tree",
|
||||
state.worktree,
|
||||
"check-ignore",
|
||||
"--no-index",
|
||||
"--",
|
||||
...files,
|
||||
]
|
||||
const check = yield* git(checkArgs, { cwd: state.directory })
|
||||
if (check.code === 0) {
|
||||
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
|
||||
const filtered = rows.filter((r) => !ignored.has(r.file))
|
||||
rows.length = 0
|
||||
rows.push(...filtered)
|
||||
}
|
||||
}
|
||||
|
||||
const step = 100
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
|
||||
@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
|
||||
}
|
||||
})
|
||||
|
||||
return async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
return () =>
|
||||
Effect.sync(() => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
: Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = yield* parse(params.command, ps)
|
||||
const scan = yield* collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
yield* ask(ctx, scan)
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
: Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = yield* parse(params.command, ps)
|
||||
const scan = yield* collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
yield* ask(ctx, scan)
|
||||
|
||||
return yield* run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
return yield* run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ export const LspTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
@@ -42,7 +41,17 @@ export const LspTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, file)
|
||||
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
|
||||
yield* ctx.ask({
|
||||
permission: "lsp",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
operation: args.operation,
|
||||
filePath: file,
|
||||
line: args.line,
|
||||
character: args.character,
|
||||
},
|
||||
})
|
||||
|
||||
const uri = pathToFileURL(file).href
|
||||
const position = { file, line: args.line - 1, character: args.character - 1 }
|
||||
@@ -85,7 +94,7 @@ export const LspTool = Tool.define(
|
||||
metadata: { result },
|
||||
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
|
||||
}
|
||||
}),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
|
||||
"multiedit",
|
||||
Effect.gen(function* () {
|
||||
const editInfo = yield* EditTool
|
||||
const edit = yield* Effect.promise(() => editInfo.init())
|
||||
const edit = yield* editInfo.init()
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
|
||||
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
||||
|
||||
return async () => {
|
||||
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
|
||||
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((s) => s.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
info.content.trim(),
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((s) => s.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
"",
|
||||
info.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -47,9 +47,13 @@ export namespace Tool {
|
||||
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
||||
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||
}
|
||||
|
||||
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||
| DefWithoutID<Parameters, M>
|
||||
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||
|
||||
export type InferParameters<T> =
|
||||
T extends Info<infer P, any>
|
||||
? z.infer<P>
|
||||
@@ -68,58 +72,66 @@ export namespace Tool {
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
init: Init<Parameters, Result>,
|
||||
truncate: Truncate.Interface,
|
||||
agents: Agent.Interface,
|
||||
) {
|
||||
return async () => {
|
||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => toolInfo.parameters.parse(args),
|
||||
catch: (error) => {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
return new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
},
|
||||
})
|
||||
const result = yield* execute(args, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
return toolInfo
|
||||
}
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => toolInfo.parameters.parse(args),
|
||||
catch: (error) => {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
return new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
},
|
||||
})
|
||||
const result = yield* execute(args, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const agent = yield* agents.get(ctx.agent)
|
||||
const truncated = yield* truncate.output(result.output, {}, agent)
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
return toolInfo
|
||||
})
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
id: ID,
|
||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
||||
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||
return Object.assign(
|
||||
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* init
|
||||
const truncate = yield* Truncate.Service
|
||||
const agents = yield* Agent.Service
|
||||
return { id, init: wrap(id, resolved, truncate, agents) }
|
||||
}),
|
||||
{ id },
|
||||
)
|
||||
}
|
||||
|
||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||
return Effect.gen(function* () {
|
||||
const init = yield* Effect.promise(() => info.init())
|
||||
const init = yield* info.init()
|
||||
return {
|
||||
...init,
|
||||
id: info.id,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
@@ -135,10 +134,4 @@ export namespace Truncate {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
return runPromise((s) => s.output(text, options, agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export namespace Log {
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
}
|
||||
const keep = 10
|
||||
|
||||
let level: Level = "INFO"
|
||||
|
||||
@@ -78,15 +79,19 @@ export namespace Log {
|
||||
}
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
const files = await Glob.scan("????-??-??T??????.log", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
if (files.length <= 5) return
|
||||
const files = (
|
||||
await Glob.scan("????-??-??T??????.log", {
|
||||
cwd: dir,
|
||||
absolute: false,
|
||||
include: "file",
|
||||
}).catch(() => [])
|
||||
)
|
||||
.filter((file) => path.basename(file) === file)
|
||||
.sort()
|
||||
if (files.length <= keep) return
|
||||
|
||||
const filesToDelete = files.slice(0, -10)
|
||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
||||
const doomed = files.slice(0, -keep)
|
||||
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
|
||||
}
|
||||
|
||||
function formatError(error: Error, depth = 0): string {
|
||||
|
||||
115
packages/opencode/src/v2/message.ts
Normal file
115
packages/opencode/src/v2/message.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
|
||||
export namespace Message {
|
||||
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.ascending("message")),
|
||||
prefix: "msg",
|
||||
})),
|
||||
)
|
||||
|
||||
export class Source extends Schema.Class<Source>("Message.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(url: string) {
|
||||
return new FileAttachment({
|
||||
uri: url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Message.User")({
|
||||
id: ID,
|
||||
type: Schema.Literal("user"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
|
||||
const msg = new User({
|
||||
id: ID.create(),
|
||||
type: "user",
|
||||
...input,
|
||||
time: {
|
||||
created: Effect.runSync(DateTime.now),
|
||||
},
|
||||
})
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
|
||||
id: ID,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Message.Request")({
|
||||
id: ID,
|
||||
type: Schema.Literal("start"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Text extends Schema.Class<Text>("Message.Text")({
|
||||
id: ID,
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Complete extends Schema.Class<Complete>("Message.Complete")({
|
||||
id: ID,
|
||||
type: Schema.Literal("complete"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
cost: Schema.Number,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.Number,
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([User, Text])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
}
|
||||
71
packages/opencode/src/v2/session.ts
Normal file
71
packages/opencode/src/v2/session.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Context, Layer, Schema, Effect } from "effect"
|
||||
import { Message } from "./message"
|
||||
import { Struct } from "effect"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
export namespace SessionV2 {
|
||||
export const ID = SessionID
|
||||
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(Message.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(Message.ID),
|
||||
sessionID: SessionV2.ID,
|
||||
}) {}
|
||||
|
||||
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
|
||||
id: Schema.optionalKey(SessionV2.ID),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: SessionV2.ID,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<Message.User>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
|
||||
export const layer = Layer.effect(Service)(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
|
||||
const match = yield* session.get(id)
|
||||
return fromV1(match)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
prompt,
|
||||
fromID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function fromV1(input: Session.Info): Info {
|
||||
return new Info({
|
||||
id: SessionV2.ID.make(input.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
|
||||
- Directories are created in the system temp folder with prefix `opencode-test-`
|
||||
- Use `await using` for automatic cleanup when the variable goes out of scope
|
||||
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
|
||||
|
||||
## Testing With Effects
|
||||
|
||||
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
|
||||
|
||||
### Core Pattern
|
||||
|
||||
```typescript
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||
|
||||
describe("my service", () => {
|
||||
it.live("does the thing", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
const out = yield* svc.run()
|
||||
expect(out).toEqual("ok")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### `it.effect` vs `it.live`
|
||||
|
||||
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
|
||||
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
|
||||
- Most integration-style tests in this package use `it.live(...)`.
|
||||
|
||||
### Effect Fixtures
|
||||
|
||||
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
|
||||
|
||||
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
|
||||
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
|
||||
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
|
||||
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
|
||||
|
||||
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
|
||||
|
||||
### Style
|
||||
|
||||
- Define `const it = testEffect(...)` near the top of the file.
|
||||
- Keep the test body inside `Effect.gen(function* () { ... })`.
|
||||
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
|
||||
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
|
||||
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user