From 6b38614163e09b0eb7be0fabbc12a2fb2a9be30f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 20:00:22 -0400 Subject: [PATCH 01/10] fix(core): simplify opencode connection flow --- packages/core/src/plugin/provider/opencode.ts | 24 ++++++------------- .../test/plugin/provider-opencode.test.ts | 14 +++-------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 192fe7740d9a..327c88e70f2e 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -85,26 +85,16 @@ function oauth(http: HttpClient.HttpClient) { method: { id: methodID, type: "oauth", - label: "Sign in with OpenCode", - prompts: [ - { - type: "text", - key: "server", - message: "OpenCode server", - placeholder: defaultServer, - }, - ], + label: "OpenCode Console account", }, - authorize: (inputs) => + authorize: () => Effect.gen(function* () { - const url = new URL(inputs.server || defaultServer) - const server = `${url.origin}${url.pathname.replace(/\/+$/, "")}` - const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device) + const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) return { mode: "auto" as const, - url: `${server}${device.verification_uri_complete}`, + url: `${defaultServer}${device.verification_uri_complete}`, instructions: `Enter code: ${device.user_code}`, - callback: poll(http, server, device.device_code, Duration.seconds(device.interval)), + callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), } }), refresh: (credential) => @@ -157,7 +147,7 @@ export const OpencodePlugin = define a.id.localeCompare(b.id))[0] + const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] return new Credential.OAuth({ type: "oauth" as const, methodID, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index d152e45984d5..bb087897b72c 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -49,24 +49,16 @@ function withEnv(vars: Record, effect: () = const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] describe("OpencodePlugin", () => { - it.effect("registers OAuth and API key methods", () => + it.effect("registers account and service account methods", () => Effect.gen(function* () { yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([ { id: Integration.MethodID.make("device"), type: "oauth", - label: "Sign in with OpenCode", - prompts: [ - { - type: "text", - key: "server", - message: "OpenCode server", - placeholder: "https://console.opencode.ai", - }, - ], + label: "OpenCode Console account", }, - { type: "key", label: "API key" }, + { type: "key", label: "Service account" }, ]) }), ) From e05fceb1e85ef07845569b8d7d4eefcb42fd9011 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 20:08:45 -0400 Subject: [PATCH 02/10] fix(core): clarify opencode key label --- packages/core/src/plugin/provider/opencode.ts | 2 +- packages/core/test/plugin/provider-opencode.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 327c88e70f2e..2a7f7750b9e6 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -147,7 +147,7 @@ export const OpencodePlugin = define { type: "oauth", label: "OpenCode Console account", }, - { type: "key", label: "Service account" }, + { type: "key", label: "API key (service account)" }, ]) }), ) From 2a926ad322df773c7cc8f6865179b91cd5d2d50b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 20:34:21 -0400 Subject: [PATCH 03/10] Merge remote-tracking branch 'origin/dev' into tui-v2 # Conflicts: # packages/tui/src/routes/session/index.tsx --- packages/cli/AGENTS.md | 82 ++++ packages/cli/src/commands/commands.ts | 7 + packages/cli/src/commands/handlers/default.ts | 5 +- packages/cli/src/commands/handlers/serve.ts | 19 +- .../src/commands/handlers/service/status.ts | 2 +- packages/cli/src/framework/runtime.ts | 7 +- packages/cli/src/index.ts | 12 + packages/cli/src/services/daemon.ts | 32 +- packages/cli/src/services/standalone.ts | 38 ++ packages/cli/src/tui.ts | 10 +- packages/core/src/session/info.ts | 3 + packages/core/src/session/schema.ts | 3 + packages/opencode/src/plugin/tui/internal.ts | 7 +- packages/opencode/src/plugin/tui/runtime.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 6 + packages/server/src/handlers.ts | 16 +- packages/tui/src/app.tsx | 26 +- packages/tui/src/component/dialog-agent.tsx | 8 +- .../tui/src/component/dialog-integration.tsx | 394 ++++++++++++++++++ packages/tui/src/component/dialog-model.tsx | 133 +++--- .../tui/src/component/prompt/autocomplete.tsx | 8 +- packages/tui/src/component/prompt/index.tsx | 113 +++-- packages/tui/src/component/use-connected.tsx | 11 +- packages/tui/src/config/keybind.ts | 2 +- packages/tui/src/context/data.tsx | 20 +- packages/tui/src/context/local.tsx | 71 ++-- packages/tui/src/feature-plugins/builtins.ts | 45 +- .../tui/src/feature-plugins/system/scrap.tsx | 66 +++ packages/tui/src/routes/session/index.tsx | 283 ++++++++----- .../cli/cmd/tui/integration-options.test.ts | 40 ++ 30 files changed, 1145 insertions(+), 326 deletions(-) create mode 100644 packages/cli/AGENTS.md create mode 100644 packages/cli/src/services/standalone.ts create mode 100644 packages/tui/src/component/dialog-integration.tsx create mode 100644 packages/tui/src/feature-plugins/system/scrap.tsx create mode 100644 packages/tui/test/cli/cmd/tui/integration-options.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md new file mode 100644 index 000000000000..f546d1dc482d --- /dev/null +++ b/packages/cli/AGENTS.md @@ -0,0 +1,82 @@ +# V2 CLI and TUI development guide + +## Migration context + +- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. +- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI. +- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: + +```bash +# From packages/cli: local V2 TUI +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone + +# Released legacy TUI behavior reference +termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest + +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png +termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png +``` + +- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. +- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. + +## Interactive debugging + +- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. +- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service. +- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. +- Use a dedicated session name and do not reuse or kill an unrelated session. + +```bash +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone +termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 +termctrl show opencode-v2-dev +``` + +- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. +- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. + +```bash +termctrl send opencode-v2-dev 'text:example prompt' enter +termctrl send opencode-v2-dev ctrl-c +``` + +- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. +- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. + +```bash +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png +``` + +- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: + +```bash +termctrl resize opencode-v2-dev --cols 100 --rows 30 +termctrl show opencode-v2-dev +``` + +- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. +- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`. +- Always clean up the Terminal Control session when the check is complete: + +```bash +termctrl stop opencode-v2-dev +``` + +## Debugger + +- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: + +```bash +termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ + bun run --inspect=ws://localhost:6499/ src/index.ts --standalone +``` + +- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. +- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. + +## Verification + +- Run `bun typecheck` from `packages/cli` after CLI adapter changes. +- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. +- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 39594e995154..8164459943b1 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -5,6 +5,12 @@ declare const OPENCODE_CLI_NAME: string | undefined export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", + params: { + standalone: Flag.boolean("standalone").pipe( + Flag.withDescription("Run with a private server instead of the background service"), + Flag.withDefault(false), + ), + }, commands: [ Spec.make("debug", { description: "Debugging and troubleshooting tools", @@ -30,6 +36,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), port: Flag.integer("port").pipe(Flag.optional), register: Flag.boolean("register").pipe(Flag.withDefault(false)), + stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)), }, }), ], diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d0a9968e5d8e..919124c02d18 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -2,11 +2,12 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Effect } from "effect" import { Daemon } from "../../services/daemon" +import { Standalone } from "../../services/standalone" -export default Runtime.handler(Commands, () => +export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const daemon = yield* Daemon.Service - const transport = yield* daemon.transport() + const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) const { runTui } = yield* Effect.promise(() => import("../../tui")) yield* runTui(transport) }), diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index d4ecfed9745d..c95dfe73a983 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -6,6 +6,8 @@ import * as Effect from "effect/Effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { createServer } from "node:http" import { createRoutes } from "@opencode-ai/server/routes" +import { ServerAuth } from "@opencode-ai/server/auth" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" @@ -16,11 +18,22 @@ export default Runtime.handler( return yield* Effect.scoped( Effect.gen(function* () { const daemon = yield* Daemon.Service - const address = yield* listen(input.hostname, input.port, yield* daemon.password()) + const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD + if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD + const password = input.stdio ? standalonePassword : yield* daemon.password() + if (!password) return yield* Effect.fail(new Error("Missing server password")) + const address = yield* listen(input.hostname, input.port, password) + yield* Effect.tryPromise(() => + createOpencodeClient({ + baseUrl: HttpServer.formatAddress(address), + headers: ServerAuth.headers({ password }), + }).v2.location.get(undefined, { throwOnError: true }), + ) if (input.register) yield* daemon.register(address) - console.log(`server listening on ${HttpServer.formatAddress(address)}`) + const url = HttpServer.formatAddress(address) + console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) return yield* Effect.never - }), + }).pipe(Effect.annotateLogs({ role: "server" })), ) }), ) diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index d409970e8bcd..779d456c785f 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -8,6 +8,6 @@ export default Runtime.handler( Commands.commands.service.commands.status, Effect.fn("cli.service.status")(function* () { const url = yield* (yield* Daemon.Service).status() - process.stdout.write((url ? `running ${url}` : "stopped") + EOL) + process.stdout.write((url ? url : "stopped") + EOL) }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 97247e4d6b81..060b5a0d894c 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect" import * as Command from "effect/unstable/cli/Command" import { Spec } from "./spec" import { Daemon } from "../services/daemon" +import { Scope } from "effect" export type Input = Value extends Spec.Node @@ -10,11 +11,11 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = (input: unknown) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c15362ac4fef..8a9780a5ba93 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,10 +2,19 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeServices from "@effect/platform-node/NodeServices" +import { NodeFileSystem } from "@effect/platform-node" import * as Effect from "effect/Effect" +import { Layer, Logger, References } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Daemon } from "./services/daemon" +import { Logging } from "@opencode-ai/core/observability/logging" + +const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), +) const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -24,8 +33,11 @@ const Handlers = Runtime.handlers(Commands, { }) Runtime.run(Commands, Handlers, { version: "local" }).pipe( + Effect.annotateLogs({ role: "cli" }), Effect.provide(Daemon.defaultLayer), + Effect.provide(LoggingLayer), Effect.provide(NodeServices.layer), Effect.scoped, + Effect.tap(() => Effect.sync(() => process.exit(0))), NodeRuntime.runMain, ) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 2e1f5bee4ead..027f1782ac72 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -28,6 +28,10 @@ const Registration = Schema.Struct({ }) type Registration = typeof Registration.Type +const Config = Schema.Struct({ + password: Schema.optional(Schema.String), +}) + function sameRegistration(left: Registration, right: Registration) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid } @@ -38,21 +42,29 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem const directory = Global.Path.state const file = path.join(directory, "server.json") - const passwordFile = path.join(directory, "password") + const configFile = path.join(Global.Path.config, "service.json") + const legacyPasswordFile = path.join(directory, "password") const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) + const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config)) const password = Effect.fn("cli.daemon.password")(function* (value?: string) { - const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (value === undefined && existing) return existing + const config = yield* fs + .readFileString(configFile) + .pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined))) + if (value === undefined && config?.password) return config.password + + const legacy = yield* fs + .readFileString(legacyPasswordFile) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + const next = value ?? legacy ?? randomBytes(32).toString("base64url") // Keep one private credential across server restarts so discovered clients // can reconnect without exposing a password flag or environment variable. - const generated = value ?? randomBytes(32).toString("base64url") - const temp = passwordFile + ".tmp" - yield* fs.makeDirectory(directory, { recursive: true }) - yield* fs.writeFileString(temp, generated, { mode: 0o600 }) - yield* fs.rename(temp, passwordFile) - return generated + const temp = configFile + ".tmp" + yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, configFile) + if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore) + return next }) const registration = Effect.fnUntraced(function* () { @@ -111,7 +123,7 @@ export const layer = Layer.effect( const existing = yield* healthy().pipe(Effect.option) const found = Option.getOrUndefined(existing) const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" - if (found?.version === InstallationVersion && compiled) return found.url + if (found?.version === InstallationVersion) return found.url if (found) yield* stopProcess(found).pipe(Effect.ignore) const entrypoint = compiled ? undefined : process.argv[1] diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts new file mode 100644 index 000000000000..3c87fd5f90da --- /dev/null +++ b/packages/cli/src/services/standalone.ts @@ -0,0 +1,38 @@ +import { ServerAuth } from "@opencode-ai/server/auth" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Schema, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { randomBytes } from "node:crypto" +import path from "node:path" + +const Ready = Schema.Struct({ url: Schema.String }) +const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready)) + +function command(password: string) { + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] + if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") + return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { + cwd: process.cwd(), + env: { OPENCODE_SERVER_PASSWORD: password }, + extendEnv: true, + stdin: "ignore", + stderr: "ignore", + killSignal: "SIGKILL", + }) +} + +export const transport = Effect.fn("cli.standalone.transport")( + function* () { + const password = randomBytes(32).toString("base64url") + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const proc = yield* spawner.spawn(command(password)) + const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) + if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) + const ready = yield* Effect.tryPromise(() => decodeReady(output)) + return { url: ready.url, headers: ServerAuth.headers({ password }) } + }, + Effect.provide(CrossSpawnSpawner.defaultLayer), +) + +export * as Standalone from "./standalone" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 4722441b2c8f..46e0b86300ff 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -2,17 +2,23 @@ import { run } from "@opencode-ai/tui" import { TuiConfig } from "@opencode-ai/tui/config" import { Effect } from "effect" import { Global } from "@opencode-ai/core/global" +import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) + let disposeSlots: (() => void) | undefined return run({ ...transport, args: {}, config, fetch: gracefulFetch, pluginHost: { - async start() {}, - async dispose() {}, + async start(input) { + disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) + }, + async dispose() { + disposeSlots?.() + }, }, }).pipe(Effect.provide(Global.defaultLayer)) } diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 2308d0646043..d629a2a06afa 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,12 +8,15 @@ import { AbsolutePath, RelativePath } from "../schema" import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" +import { SessionMessageID } from "./message-id" export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ id: SessionSchema.ID.make(row.id), projectID: ProjectV2.ID.make(row.project_id), title: row.title, + share: row.share_url ? { url: row.share_url } : undefined, + revert: row.revert ? { messageID: SessionMessageID.ID.make(row.revert.messageID) } : undefined, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, agent: row.agent ? AgentV2.ID.make(row.agent) : undefined, model: row.model diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index 8509cabee41d..e266d65d3bc5 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -8,6 +8,7 @@ import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withS import { Identifier } from "../util/identifier" import { V2Schema } from "../v2-schema" import { AgentV2 } from "../agent" +import { SessionMessageID } from "./message-id" export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), @@ -44,6 +45,8 @@ export class Info extends Schema.Class("SessionV2.Info")({ archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), }), title: Schema.String, + share: Schema.Struct({ url: Schema.String }).pipe(optionalOmitUndefined), + revert: Schema.Struct({ messageID: SessionMessageID.ID }).pipe(optionalOmitUndefined), location: Location.Ref, subpath: RelativePath.pipe(Schema.optional), }) {} diff --git a/packages/opencode/src/plugin/tui/internal.ts b/packages/opencode/src/plugin/tui/internal.ts index cc33b15beeef..1dd2213136cd 100644 --- a/packages/opencode/src/plugin/tui/internal.ts +++ b/packages/opencode/src/plugin/tui/internal.ts @@ -1,10 +1,7 @@ import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" -import type { RuntimeFlags } from "@/effect/runtime-flags" export type InternalTuiPlugin = BuiltinTuiPlugin -export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { - return createBuiltinPlugins({ - experimentalEventSystem: flags.experimentalEventSystem, - }) +export function internalTuiPlugins(): InternalTuiPlugin[] { + return createBuiltinPlugins() } diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 4673805cf3bc..1e6cc696f295 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -1089,7 +1089,7 @@ async function load(input: { if (Flag.OPENCODE_PURE && pluginOrigins.length) { } - for (const item of internalTuiPlugins(flags)) { + for (const item of internalTuiPlugins()) { const entry = loadInternalPlugin(item) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) addPluginEntry(next, { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7b3ba780dae2..22b3044a4661 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3739,6 +3739,12 @@ export type SessionV2Info = { archived?: number } title: string + share?: { + url: string + } + revert?: { + messageID: string + } location: LocationRef subpath?: string } diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index d26c1618f0d6..adc79b719517 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -25,6 +25,20 @@ import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { ProjectV2 } from "@opencode-ai/core/project" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" + +const sessionLayer = SessionV2.layer.pipe( + Layer.provide(SessionStore.defaultLayer), + Layer.provide(SessionProjector.defaultLayer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.orDie, +) export const handlers = Layer.mergeAll( HealthHandler, @@ -48,7 +62,7 @@ export const handlers = Layer.mergeAll( ).pipe( Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), - Layer.provide(SessionV2.defaultLayer), + Layer.provide(sessionLayer), Layer.provide(SessionExecutionLocal.defaultLayer), Layer.provide(PermissionSaved.defaultLayer), Layer.provide(PtyTicket.defaultLayer), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 39aa35993f76..9343d178c5b5 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -21,11 +21,10 @@ import { onCleanup, batch, Show, - on, } from "solid-js" import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" -import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -265,7 +264,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > @@ -526,17 +529,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) - createEffect( - on( - () => sync.status === "complete" && sync.data.provider.length === 0, - (isEmpty, wasEmpty) => { - // only trigger when we transition into an empty-provider state - if (!isEmpty || wasEmpty) return - dialog.replace(() => ) - }, - ), - ) - const connected = useConnected() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() @@ -726,13 +718,13 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { name: "provider.connect", - title: "Connect provider", + title: "Connect integration", suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => ) + dialog.replace(() => ) }, - category: "Provider", + category: "Integration", }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ diff --git a/packages/tui/src/component/dialog-agent.tsx b/packages/tui/src/component/dialog-agent.tsx index 3d2928910bec..f586cb078064 100644 --- a/packages/tui/src/component/dialog-agent.tsx +++ b/packages/tui/src/component/dialog-agent.tsx @@ -10,9 +10,9 @@ export function DialogAgent() { const options = createMemo(() => local.agent.list().map((item) => { return { - value: item.name, - title: item.name, - description: item.native ? "native" : item.description, + value: item.id, + title: item.id, + description: item.description, } }), ) @@ -20,7 +20,7 @@ export function DialogAgent() { return ( { local.agent.set(option.value) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx new file mode 100644 index 000000000000..97d0bf2be5f6 --- /dev/null +++ b/packages/tui/src/component/dialog-integration.tsx @@ -0,0 +1,394 @@ +import { TextAttributes } from "@opentui/core" +import type { IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" +import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import { useClipboard } from "../context/clipboard" +import { useData } from "../context/data" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useBindings } from "../keymap" +import { useDialog } from "../ui/dialog" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect } from "../ui/dialog-select" +import { Link } from "../ui/link" +import { useToast } from "../ui/toast" + +const INTEGRATION_PRIORITY: Record = { + opencode: 0, + "opencode-go": 1, + openai: 2, + "github-copilot": 3, + anthropic: 4, + google: 5, +} + +type ConnectMethod = Exclude + +export function integrationOptions(list: IntegrationInfo[]) { + return list.toSorted( + (a, b) => + (INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) || + a.name.localeCompare(b.name) || + a.id.localeCompare(b.id), + ) +} + +export function connectMethods(integration: IntegrationInfo): ConnectMethod[] { + return integration.methods.filter((method): method is ConnectMethod => method.type !== "env") +} + +export function DialogIntegration() { + const data = useData() + const dialog = useDialog() + const { theme } = useTheme() + const options = createMemo(() => + integrationOptions(data.location.integration.list() ?? []).map((integration) => { + const methods = connectMethods(integration) + const connected = integration.connections.length > 0 + return { + title: integration.name, + value: integration.id, + description: methods.length ? undefined : "Environment only", + category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", + disabled: methods.length === 0, + gutter: connected ? () => : undefined, + onSelect: () => selectMethod(integration, methods, dialog), + } + }), + ) + + return ( + No integrations available} + /> + ) +} + +function selectMethod( + integration: IntegrationInfo, + methods: ConnectMethod[], + dialog: ReturnType, +) { + if (methods.length === 1) return openMethod(integration, methods[0], dialog) + dialog.replace(() => ( + ({ + title: method.type === "key" ? (method.label ?? "API key") : method.label, + value: method.type === "key" ? "key" : method.id, + description: method.type === "oauth" ? "OAuth" : undefined, + onSelect: () => openMethod(integration, method, dialog), + }))} + /> + )) +} + +function openMethod( + integration: IntegrationInfo, + method: ConnectMethod, + dialog: ReturnType, +) { + if (method.type === "key") { + dialog.replace(() => ) + return + } + void beginOAuth(integration, method, dialog) +} + +function KeyMethod(props: { integration: IntegrationInfo; method: Extract }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() + const { theme } = useTheme() + const [error, setError] = createSignal() + + return ( + { + if (!key) return + void sdk.client.v2.integration.connect + .key( + { + integrationID: props.integration.id, + location: location(data), + key, + }, + { throwOnError: true }, + ) + .then(() => connected(props.integration.name, data, dialog, toast)) + .catch((cause) => setError(message(cause))) + }} + description={() => ( + {(value) => {value()}} + )} + /> + ) +} + +async function beginOAuth( + integration: IntegrationInfo, + method: IntegrationOAuthMethod, + dialog: ReturnType, +) { + const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} + if (inputs === null) return + dialog.replace(() => ) +} + +function OAuthStarting(props: { + integration: IntegrationInfo + method: IntegrationOAuthMethod + inputs: Record +}) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() + + onMount(() => { + void sdk.client.v2.integration.connect + .oauth( + { + integrationID: props.integration.id, + location: location(data), + methodID: props.method.id, + inputs: props.inputs, + }, + { throwOnError: true }, + ) + .then((result) => { + if (result.data.data.mode === "code") { + dialog.replace(() => ( + + )) + return + } + dialog.replace(() => ( + + )) + }) + .catch((cause) => { + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + }) + + return +} + +function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() + const clipboard = useClipboard() + let timer: ReturnType | undefined + let settled = false + + useBindings(() => ({ + bindings: [ + { + key: "c", + desc: "Copy authorization details", + group: "Dialog", + cmd: () => { + const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url + clipboard + .write?.(value) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], + })) + + const poll = () => { + void sdk.client.v2.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true }) + .then((result) => { + const status = result.data.data + if (status.status === "pending") { + timer = setTimeout(poll, 500) + return + } + settled = true + if (status.status === "complete") { + void connected(props.integration.name, data, dialog, toast) + return + } + toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) + dialog.clear() + }) + .catch((cause) => { + settled = true + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + } + + onMount(poll) + onCleanup(() => { + if (timer) clearTimeout(timer) + if (settled) return + void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + }) + + return ( + + ) +} + +function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { + const data = useData() + const dialog = useDialog() + const sdk = useSDK() + const toast = useToast() + const { theme } = useTheme() + const [error, setError] = createSignal() + let settled = false + + onCleanup(() => { + if (settled) return + void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + }) + + return ( + { + if (!code) return + void sdk.client.v2.integration.attempt + .complete( + { attemptID: props.attempt.attemptID, location: location(data), code }, + { throwOnError: true }, + ) + .then(() => { + settled = true + return connected(props.integration.name, data, dialog, toast) + }) + .catch((cause) => setError(message(cause))) + }} + description={() => ( + + {props.attempt.instructions} + + {(value) => {value()}} + + )} + /> + ) +} + +function OAuthView(props: { + title: string + url?: string + instructions?: string + message: string + copy?: boolean +}) { + const dialog = useDialog() + const { theme } = useTheme() + return ( + + + + {props.title} + + dialog.clear()}> + esc + + + + {(url) => ( + + + {(instructions) => {instructions()}} + + )} + + {props.message} + + + c copy + + + + ) +} + +async function promptInputs( + dialog: ReturnType, + prompts: NonNullable, +) { + const inputs: Record = {} + for (const prompt of prompts) { + if (prompt.when) { + const value = inputs[prompt.when.key] + if (value === undefined) continue + const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value + if (!matches) continue + } + if (prompt.type === "select") { + const value = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: option.label, + value: option.value, + description: option.hint, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + if (value === null) return null + inputs[prompt.key] = value + continue + } + const value = await new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(null), + ) + }) + if (value === null) return null + inputs[prompt.key] = value + } + return inputs +} + +async function connected( + name: string, + data: ReturnType, + dialog: ReturnType, + toast: ReturnType, +) { + await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) + toast.show({ variant: "success", message: `Connected ${name}` }) + dialog.clear() +} + +function location(data: ReturnType) { + const current = data.location.default() + return { directory: current.directory, workspace: current.workspaceID } +} + +function message(cause: unknown) { + if (cause instanceof Error) return cause.message + return "Authentication failed" +} diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ff8715ef85f3..09f8bb5ea62f 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,22 +1,23 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" +import { sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { DialogIntegration } from "./dialog-integration" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useSync } from "../context/sync" +import { useData } from "../context/data" export function DialogModel(props: { providerID?: string }) { const local = useLocal() - const sync = useSync() + const data = useData() const dialog = useDialog() const [query, setQuery] = createSignal("") const connected = useConnected() - const providers = createDialogProviderOptions() + const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item]))) + const models = createMemo(() => data.location.model.list() ?? []) const showExtra = createMemo(() => connected() && !props.providerID) @@ -29,21 +30,20 @@ export function DialogModel(props: { providerID?: string }) { function toOptions(items: typeof favorites, category: string) { if (!showSections) return [] return items.flatMap((item) => { - const provider = sync.data.provider.find((provider) => provider.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] + const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID) if (!model) return [] + const provider = providers().get(model.providerID) return [ { key: item, - value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, - description: provider.name, + value: { providerID: model.providerID, modelID: model.id }, + title: model.name, + releaseDate: model.time.released, + description: provider?.name ?? model.providerID, category, - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + footer: free(model) ? "Free" : undefined, onSelect: () => { - onSelect(provider.id, model.id) + onSelect(model.providerID, model.id) }, }, ] @@ -58,77 +58,48 @@ export function DialogModel(props: { providerID?: string }) { "Recent", ) - const providerOptions = pipe( - sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - filter(([_, info]) => info.status !== "deprecated"), - filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - onSelect(provider.id, model) - }, - })), - filter((option) => { - if (!showSections) return true - if ( - favorites.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - if ( - recents.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) + const modelOptions = sortModelOptions( + models() + .filter((model) => model.status !== "deprecated") + .filter((model) => (props.providerID ? model.providerID === props.providerID : true)) + .map((model) => ({ + value: { providerID: model.providerID, modelID: model.id }, + title: model.name, + releaseDate: model.time.released, + description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) + ? "(Favorite)" + : undefined, + category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined, + footer: free(model) ? "Free" : undefined, + onSelect() { + onSelect(model.providerID, model.id) + }, + })) + .filter((option) => { + if (!showSections) return true + if ( + favorites.some( + (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, ) - return false - return true - }), - (options) => sortModelOptions(options, props.providerID !== undefined), - ), - ), + ) + return false + if ( + recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID) + ) + return false + return true + }), + props.providerID !== undefined, ) - const popularProviders = !connected() - ? pipe( - providers(), - map((option) => ({ - ...option, - category: "Popular providers", - })), - take(6), - ) - : [] - if (needle) { - return [ - ...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), - ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj), - ] + return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj) } - return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] + return [...favoriteOptions, ...recentOptions, ...modelOptions] }) - const provider = createMemo(() => - props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null, - ) + const provider = createMemo(() => (props.providerID ? providers().get(props.providerID) : undefined)) const title = createMemo(() => { const value = provider() @@ -157,9 +128,9 @@ export function DialogModel(props: { providerID?: string }) { actions={[ { command: "model.dialog.provider", - title: connected() ? "Connect provider" : "View all providers", + title: connected() ? "Connect integration" : "View all integrations", onTrigger() { - dialog.replace(() => ) + dialog.replace(() => ) }, }, { @@ -191,3 +162,7 @@ export function sortModelOptions option.title, ) } + +function free(model: { cost: Array<{ input: number }> }) { + return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0) +} diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index ffa6ae9f8246..8cf614f54244 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -404,15 +404,15 @@ export function Autocomplete(props: { }) const agents = createMemo(() => { - return sync.data.agent + return (data.location.agent.list() ?? []) .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ - display: "@" + agent.name, + display: "@" + agent.id, onSelect: () => { - insertPart(agent.name, { + insertPart(agent.id, { type: "agent", - name: agent.name, + name: agent.id, source: { start: 0, end: 0, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1dd..011f64d0d856 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -43,7 +43,8 @@ import { errorMessage } from "../../util/error" import { formatDuration } from "../../util/format" import { createColors, createFrames } from "../../ui/spinner" import { useDialog } from "../../ui/dialog" -import { DialogProvider as DialogProviderConnect } from "../dialog-provider" +import { DialogIntegration } from "../dialog-integration" +import { useConnected } from "../use-connected" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" @@ -56,6 +57,7 @@ import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" +import { useData } from "../../context/data" export type PromptProps = { sessionID?: string @@ -205,8 +207,10 @@ export function Prompt(props: PromptProps) { const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) + const data = useData() const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) + const connected = useConnected() const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { @@ -215,8 +219,8 @@ export function Prompt(props: PromptProps) { message: "Connect a provider to send prompts", duration: 3000, }) - if (sync.data.provider.length === 0) { - dialog.replace(() => ) + if (!connected()) { + dialog.replace(() => ) } } @@ -315,7 +319,7 @@ export function Prompt(props: PromptProps) { syncedSessionID = sessionID // Only set agent if it's a primary agent (not a subagent) - const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) + const isPrimaryAgent = local.agent.list().some((agent) => agent.id === msg.agent) if (msg.agent && isPrimaryAgent) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) @@ -982,6 +986,7 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID + let session = sessionID ? data.session.get(sessionID) : undefined let finishMoveProgress = false if (sessionID == null) { const selectedWorkspace = workspace.selection() @@ -991,10 +996,9 @@ export function Prompt(props: PromptProps) { if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) - const res = await sdk.client.session.create({ - directory, - workspace: workspaceID, - agent: agent.name, + const res = await sdk.client.v2.session.create({ + location: directory ? { directory, workspaceID } : undefined, + agent: agent.id, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, @@ -1004,8 +1008,6 @@ export function Prompt(props: PromptProps) { if (res.error) { if (finishMoveProgress) move.finishSubmit() - console.log("Creating a session failed:", res.error) - toast.show({ message: "Creating a session failed. Open console for more details.", variant: "error", @@ -1014,7 +1016,8 @@ export function Prompt(props: PromptProps) { return true } - sessionID = res.data.id + sessionID = res.data.data.id + session = res.data.data } const inputText = expandTrackedPastedText( @@ -1054,7 +1057,7 @@ export function Prompt(props: PromptProps) { move.startSubmit() void sdk.client.session.shell({ sessionID, - agent: agent.name, + agent: agent.id, model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID, @@ -1078,39 +1081,73 @@ export function Prompt(props: PromptProps) { sessionID, command: command.slice(1), arguments: args, - agent: agent.name, + agent: agent.id, model: `${selectedModel.providerID}/${selectedModel.modelID}`, variant, parts: nonTextParts.filter((x) => x.type === "file"), }) } else { move.startSubmit() - sdk.client.session - .prompt( + if (!session) { + await data.session.refresh(sessionID) + session = data.session.get(sessionID) + } + if (session?.agent !== agent.id) { + await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true }) + } + if ( + session?.model?.providerID !== selectedModel.providerID || + session.model.id !== selectedModel.modelID || + session.model.variant !== variant + ) { + await sdk.client.v2.session.switchModel( { sessionID, - ...selectedModel, - agent: agent.name, - model: selectedModel, - variant, - parts: [ - ...editorParts, - { - type: "text", - text: inputText, - }, - ...nonTextParts, - ], + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, }, { throwOnError: true }, ) - .catch((error) => { - toast.show({ - title: "Failed to send prompt", - message: errorMessage(error), - variant: "error", - }) - }) + } + const result = await sdk.client.v2.session.prompt({ + sessionID, + prompt: { + text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), + files: nonTextParts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }, + ] + : [], + ), + agents: nonTextParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), + }, + }) + if (result.error) { + toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" }) + return false + } if (editorParts.length > 0) editor.markSelectionSent() } history.append({ @@ -1284,7 +1321,7 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border - return local.agent.color(agent.name) + return local.agent.color(agent.id) }) const showVariant = createMemo(() => { @@ -1316,9 +1353,9 @@ export function Prompt(props: PromptProps) { const spinnerDef = createMemo(() => { const agent = status().type !== "idle" - ? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current()) + ? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current()) : local.agent.current() - const color = agent ? local.agent.color(agent.name) : theme.border + const color = agent ? local.agent.color(agent.id) : theme.border return { frames: createFrames({ color, @@ -1439,7 +1476,7 @@ export function Prompt(props: PromptProps) { {(agent) => ( <> - {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)} diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index f59e95c05ff6..4a54ae68e1dd 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -1,12 +1,13 @@ import { createMemo } from "solid-js" +import { useData } from "../context/data" import { useSync } from "../context/sync" export function useConnected() { + const data = useData() const sync = useSync() - return createMemo(() => - sync.data.provider.some( - (provider) => - provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0), - ), + return createMemo( + () => + (data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) || + sync.data.console_state.consoleManagedProviders.length > 0, ) } diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index bab39083d14c..f4d7e4da5cd8 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -122,7 +122,7 @@ export const Definitions = { model_cycle_favorite: keybind("none", "Next favorite model"), model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), mcp_list: keybind("none", "List MCP servers"), - provider_connect: keybind("none", "Connect provider"), + provider_connect: keybind("none", "Connect integration"), console_org_switch: keybind("none", "Switch console organization"), agent_list: keybind("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907ad9..0cd8d2206040 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -21,7 +21,6 @@ import type { import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { useEvent } from "./event" import { createSignal, onCleanup, onMount } from "solid-js" type LocationData = { @@ -72,7 +71,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const sdk = useSDK() - const events = useEvent() const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), }) @@ -130,6 +128,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break case "session.next.agent.switched": + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, "agent", event.data.agent) message.update(event.data.sessionID, (draft) => { message.prepend(draft, { id: event.data.messageID, @@ -140,6 +140,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.model.switched": + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, "model", event.data.model) message.update(event.data.sessionID, (draft) => { message.prepend(draft, { id: event.data.messageID, @@ -403,14 +405,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } onMount(() => { - const unsub = events.subscribe((event, metadata) => { - handleEvent({ - ...event, - data: event.properties, - location: { directory: metadata.directory, workspaceID: metadata.workspace }, - } as V2Event) - }) - onCleanup(unsub) + const controller = new AbortController() + onCleanup(() => controller.abort()) + void (async () => { + const events = await sdk.client.v2.event.subscribe({ signal: controller.signal }) + for await (const event of events.stream) handleEvent(event) + })().catch(() => {}) }) const result = { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 39e7bc111d55..fbea43e4f74c 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence" import { useTheme } from "./theme" import { useToast } from "../ui/toast" import { useRoute } from "./route" +import { useData } from "./data" export type LocalTheme = { secondary: RGBA @@ -51,6 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { const sync = useSync() + const data = useData() const sdk = useSDK() const toast = useToast() const theme = useTheme().theme @@ -58,8 +60,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const paths = useTuiPaths() function isModelValid(model: { providerID: string; modelID: string }) { - const provider = sync.data.provider.find((item) => item.id === model.providerID) - return !!provider?.models[model.modelID] + return !!data.location.model + .list() + ?.some((item) => item.providerID === model.providerID && item.id === model.modelID) } function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) { @@ -71,8 +74,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } function createAgent() { - const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden)) - const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden)) + const agents = createMemo(() => + (data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), + ) + const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden)) const [agentStore, setAgentStore] = createStore({ current: undefined as string | undefined, }) @@ -90,30 +95,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((x) => x.name === agentStore.current) ?? agents().at(0) + return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0) }, - set(name: string) { - if (!agents().some((x) => x.name === name)) + set(id: string) { + if (!agents().some((agent) => agent.id === id)) return toast.show({ variant: "warning", - message: `Agent not found: ${name}`, + message: `Agent not found: ${id}`, duration: 3000, }) - setAgentStore("current", name) + setAgentStore("current", id) }, move(direction: 1 | -1) { batch(() => { const current = this.current() if (!current) return - let next = agents().findIndex((x) => x.name === current.name) + direction + let next = agents().findIndex((agent) => agent.id === current.id) + direction if (next < 0) next = agents().length - 1 if (next >= agents().length) next = 0 const value = agents()[next] - setAgentStore("current", value.name) + setAgentStore("current", value.id) }) }, - color(name: string) { - const index = visibleAgents().findIndex((x) => x.name === name) + color(id: string) { + const index = visibleAgents().findIndex((agent) => agent.id === id) if (index === -1) return colors()[0] const agent = visibleAgents()[index] @@ -218,15 +223,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const provider = sync.data.provider[0] - if (!provider) return undefined - const defaultModel = sync.data.provider_default[provider.id] - const firstModel = Object.values(provider.models)[0] - const model = defaultModel ?? firstModel?.id + const model = data.location.model.list()?.[0] if (!model) return undefined return { - providerID: provider.id, - modelID: model, + providerID: model.providerID, + modelID: model.id, } }) @@ -234,8 +235,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() return ( getFirstValidModel( - () => a && modelStore.model[a.name], - () => a && a.model, + () => a && modelStore.model[a.id], + () => a?.model && { providerID: a.model.providerID, modelID: a.model.id }, fallbackModel, ) ?? undefined ) @@ -261,12 +262,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ reasoning: false, } } - const provider = sync.data.provider.find((item) => item.id === value.providerID) - const info = provider?.models[value.modelID] + const provider = data.location.provider.list()?.find((item) => item.id === value.providerID) + const info = data.location.model + .list() + ?.find((item) => item.providerID === value.providerID && item.id === value.modelID) return { provider: provider?.name ?? value.providerID, model: info?.name ?? value.modelID, - reasoning: info?.capabilities?.reasoning ?? false, + reasoning: info?.variants.length !== 0, } }), cycle(direction: 1 | -1) { @@ -282,7 +285,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!val) return const a = agent.current() if (!a) return - setModelStore("model", a.name, { ...val }) + setModelStore("model", a.id, { ...val }) }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -310,7 +313,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!next) return const a = agent.current() if (!a) return - setModelStore("model", a.name, { ...next }) + setModelStore("model", a.id, { ...next }) setModelStore("recent", recentModels(next, modelStore.recent)) save() }, @@ -326,7 +329,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const a = agent.current() if (!a) return - setModelStore("model", a.name, model) + setModelStore("model", a.id, model) if (options?.recent) { setModelStore("recent", recentModels(model, modelStore.recent)) save() @@ -372,10 +375,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ list() { const m = currentModel() if (!m) return [] - const provider = sync.data.provider.find((item) => item.id === m.providerID) - const info = provider?.models[m.modelID] - if (!info?.variants) return [] - return Object.keys(info.variants) + const info = data.location.model + .list() + ?.find((item) => item.providerID === m.providerID && item.id === m.modelID) + return info?.variants.map((variant) => variant.id) ?? [] }, set(value: string | undefined) { const m = currentModel() @@ -521,10 +524,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ createEffect(() => { const value = agent.current() if (!value?.model) return - if (isModelValid(value.model)) return + if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return toast.show({ variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`, duration: 3000, }) }) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index b67923f3c5d1..569f3db020a8 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { PluginRuntime } from "../plugin/runtime" import HomeFooter from "./home/footer" import HomeTips from "./home/tips" import SidebarContext from "./sidebar/context" @@ -11,6 +12,7 @@ import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" +import Scrap from "./system/scrap" export type BuiltinTuiPlugin = Omit & { id: string @@ -18,7 +20,7 @@ export type BuiltinTuiPlugin = Omit & { enabled?: boolean } -export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] { +export function createBuiltinPlugins(): BuiltinTuiPlugin[] { return [ HomeFooter, HomeTips, @@ -31,6 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean Notifications, PluginManager, WhichKey, + Scrap, DiffViewer, ] } + +export async function loadBuiltinPlugins( + api: TuiPluginApi, + runtime: PluginRuntime, +) { + const slots = runtime.setupSlots(api) + const dispose: Array<() => void> = [] + + for (const plugin of createBuiltinPlugins()) { + if (plugin.enabled === false) continue + const scoped = Object.assign(Object.create(api), { + slots: { + register(input: Parameters[0]) { + dispose.push(slots.register({ ...input, id: plugin.id })) + return plugin.id + }, + }, + }) as TuiPluginApi + const now = Date.now() + await plugin.tui(scoped, undefined, { + id: plugin.id, + source: "internal", + spec: plugin.id, + target: plugin.id, + first_time: now, + last_time: now, + time_changed: now, + load_count: 1, + fingerprint: plugin.id, + state: "first", + }) + } + + return () => { + for (const fn of dispose.reverse()) fn() + slots.dispose() + } +} diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx new file mode 100644 index 000000000000..7327ef71cdb0 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -0,0 +1,66 @@ +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import { useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "../../context/theme" +import { useBindings } from "../../keymap" +import type { BuiltinTuiPlugin } from "../builtins" + +const id = "internal:scrap" +const route = "scrap" + +function Scrap(props: { api: TuiPluginApi }) { + const dimensions = useTerminalDimensions() + const { theme } = useTheme() + + useBindings(() => ({ + bindings: [ + { + key: "escape", + desc: "Back home", + group: "Scrap", + cmd() { + props.api.route.navigate("home") + }, + }, + ], + })) + + return ( + + + + ~/code/anomalyco/opencode + + esc home + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.route.register([{ name: route, render: () => }]) + api.keymap.registerLayer({ + commands: [ + { + name: "app.scrap", + title: "Open scrap screen", + category: "Debug", + namespace: "palette", + run() { + api.route.navigate(route) + api.ui.dialog.clear() + }, + }, + ], + }) +} + +const plugin: BuiltinTuiPlugin = { id, tui } + +export default plugin diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b57a1e5ddae2..0cde022e1e10 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -20,6 +20,7 @@ import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" import { useSync } from "../../context/sync" +import { useData } from "../../context/data" import { useEvent } from "../../context/event" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -35,6 +36,9 @@ import type { UserMessage, TextPart, ReasoningPart, + SessionMessage as SessionMessageV2, + SessionMessageAssistant as SessionMessageAssistantV2, + SessionMessageUser as SessionMessageUserV2, SessionStatus, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" @@ -186,6 +190,7 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() + const data = useData() const event = useEvent() const project = useProject() const paths = useTuiPaths() @@ -193,11 +198,9 @@ export function Session() { const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() - const session = createMemo(() => sync.session.get(route.sessionID)) - const location = createMemo(() => { - const current = session() - return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined - }) + const session = createMemo(() => data.session.get(route.sessionID)) + const sessionMessages = createMemo(() => (data.session.message.list(route.sessionID) ?? []).toReversed()) + const location = createMemo(() => session()?.location) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -280,8 +283,14 @@ export function Session() { const sessionID = route.sessionID void (async () => { const previousWorkspace = untrack(() => project.workspace.current()) - const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) - if (!result.data) { + await Promise.all([ + data.session.refresh(sessionID), + data.session.message.refresh(sessionID), + data.session.permission.refresh(sessionID), + data.session.question.refresh(sessionID), + ]) + const info = data.session.get(sessionID) + if (!info) { toast.show({ message: `Session not found: ${sessionID}`, variant: "error", @@ -291,8 +300,8 @@ export function Session() { return } - if (result.data.workspaceID !== previousWorkspace) { - project.workspace.set(result.data.workspaceID) + if (info.location.workspaceID !== previousWorkspace) { + project.workspace.set(info.location.workspaceID) // Sync all the data for this workspace. Note that this // workspace may not exist anymore which is why this is not @@ -302,8 +311,7 @@ export function Session() { await sync.bootstrap({ fatal: false }) } catch {} } - editor.reconnect(result.data.directory) - await sync.session.sync(sessionID) + editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -1119,7 +1127,7 @@ export function Session() { const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) - const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? "")) + const revertDiffFiles = createMemo>(() => []) const revertRevertedMessages = createMemo(() => { const messageID = revertMessageID() @@ -1134,7 +1142,7 @@ export function Session() { return { messageID: info.messageID, reverted: revertRevertedMessages(), - diff: info.diff, + diff: undefined, diffFiles: revertDiffFiles(), } }) @@ -1184,98 +1192,13 @@ export function Session() { scrollAcceleration={scrollAcceleration()} > - + {(message, index) => ( - - - {(function () { - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - keymap.dispatchCommand("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - + )} @@ -1347,6 +1270,158 @@ export function Session() { ) } +function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean; last: boolean }) { + return ( + + + + + + + + + + {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} + + + + + + + + + + + + + ) +} + +function SessionUserMessageV2(props: { message: SessionMessageUserV2; first: boolean }) { + const { theme } = useTheme() + return ( + + {props.message.text} + + + + {(file) => ( + + {file.name ?? file.uri} ({file.mime}) + + )} + + + + + ) +} + +function SessionAssistantMessageV2(props: { message: SessionMessageAssistantV2; last: boolean }) { + const { theme, syntax } = useTheme() + const local = useLocal() + return ( + <> + + {(content) => ( + + + + + + + + + {content.type === "reasoning" ? content.text.trim() : ""} + + + + } + /> + + + )} + + + + {props.message.error?.message} + + + + + + + {Locale.titlecase(props.message.agent)} · {props.message.model.providerID}/{props.message.model.id} + + + + + ) +} + +function SessionToolMessageV2(props: { + content: Extract +}) { + const { theme } = useTheme() + const output = createMemo(() => + props.content.state.status === "error" + ? props.content.state.error.message + : props.content.state.status === "pending" + ? "" + : props.content.state.content + .flatMap((item) => (item.type === "text" ? [item.text] : [item.name ?? item.uri])) + .join("\n"), + ) + return ( + + + {props.content.state.status === "pending" || props.content.state.status === "running" ? "∙" : "✓"}{" "} + {props.content.name} + + {(value) => {value()}} + + ) +} + +function SessionSwitchMessageV2(props: { message: SessionMessageV2 }) { + const { theme } = useTheme() + const text = () => { + if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}` + if (props.message.type === "model-switched") + return `Switched model to ${props.message.model.providerID}/${props.message.model.id}` + return "" + } + return {text()} +} + +function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) { + const { theme } = useTheme() + return ( + + {props.message.type === "system" || props.message.type === "synthetic" ? props.message.text : ""} + + ) +} + const MIME_BADGE: Record = { "text/plain": "txt", "image/png": "img", diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts new file mode 100644 index 000000000000..beb85f64cf7f --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import type { IntegrationInfo } from "@opencode-ai/sdk/v2" +import { connectMethods, integrationOptions } from "../../../../src/component/dialog-integration" + +const integration = (value: Partial & Pick): IntegrationInfo => ({ + methods: [], + connections: [], + ...value, +}) + +describe("integrationOptions", () => { + test("keeps popular integrations first and sorts the rest alphabetically", () => { + expect( + integrationOptions([ + integration({ id: "mistral", name: "Mistral" }), + integration({ id: "openai", name: "OpenAI" }), + integration({ id: "custom-z", name: "Zebra" }), + integration({ id: "anthropic", name: "Anthropic" }), + ]).map((item) => item.id), + ).toEqual(["openai", "anthropic", "mistral", "custom-z"]) + }) +}) + +describe("connectMethods", () => { + test("offers key and OAuth methods but not environment discovery", () => { + expect( + connectMethods( + integration({ + id: "example", + name: "Example", + methods: [ + { type: "env", names: ["EXAMPLE_KEY"] }, + { type: "key", label: "API key" }, + { type: "oauth", id: "account", label: "Account" }, + ], + }), + ).map((method) => method.type), + ).toEqual(["key", "oauth"]) + }) +}) From 65d213185c2fce0a1718658479dd5b141760fa5c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 20:21:42 -0400 Subject: [PATCH 04/10] feat(tui): manage integration connections --- .../tui/src/component/dialog-integration.tsx | 77 ++++++++++++++++++- .../cli/cmd/tui/integration-options.test.ts | 43 ++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 97d0bf2be5f6..0939fedcda72 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from "@opentui/core" -import type { IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" +import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" @@ -33,7 +33,21 @@ export function integrationOptions(list: IntegrationInfo[]) { } export function connectMethods(integration: IntegrationInfo): ConnectMethod[] { - return integration.methods.filter((method): method is ConnectMethod => method.type !== "env") + return integration.methods + .filter((method): method is ConnectMethod => method.type !== "env") + .toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key")) +} + +export function credentialConnections(integration: IntegrationInfo) { + return integration.connections.filter( + (connection): connection is Extract => connection.type === "credential", + ) +} + +export function connectionSummary(integration: IntegrationInfo) { + return integration.connections + .map((connection) => (connection.type === "credential" ? connection.label : `$${connection.name}`)) + .join(", ") } export function DialogIntegration() { @@ -48,10 +62,14 @@ export function DialogIntegration() { title: integration.name, value: integration.id, description: methods.length ? undefined : "Environment only", + footer: connectionSummary(integration) || undefined, category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", disabled: methods.length === 0, gutter: connected ? () => : undefined, - onSelect: () => selectMethod(integration, methods, dialog), + onSelect: () => + credentialConnections(integration).length + ? manageConnections(integration, methods, dialog) + : selectMethod(integration, methods, dialog), } }), ) @@ -65,6 +83,47 @@ export function DialogIntegration() { ) } +function manageConnections( + integration: IntegrationInfo, + methods: ConnectMethod[], + dialog: ReturnType, +) { + dialog.replace(() => { + const data = useData() + const sdk = useSDK() + const toast = useToast() + return ( + selectMethod(integration, methods, dialog), + }, + ] + : []), + ...credentialConnections(integration).map((connection) => ({ + title: `Disconnect ${connection.label}`, + value: connection.id, + onSelect: () => { + void sdk.client.v2.credential + .remove( + { credentialID: connection.id, location: location(data) }, + { throwOnError: true }, + ) + .then(() => disconnected(integration.name, data, dialog, toast)) + .catch(toast.error) + }, + })), + ]} + /> + ) + }) +} + function selectMethod( integration: IntegrationInfo, methods: ConnectMethod[], @@ -77,7 +136,6 @@ function selectMethod( options={methods.map((method) => ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, - description: method.type === "oauth" ? "OAuth" : undefined, onSelect: () => openMethod(integration, method, dialog), }))} /> @@ -383,6 +441,17 @@ async function connected( dialog.clear() } +async function disconnected( + name: string, + data: ReturnType, + dialog: ReturnType, + toast: ReturnType, +) { + await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) + toast.show({ variant: "success", message: `Disconnected ${name}` }) + dialog.clear() +} + function location(data: ReturnType) { const current = data.location.default() return { directory: current.directory, workspace: current.workspaceID } diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index beb85f64cf7f..f9cc482c6f02 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" import type { IntegrationInfo } from "@opencode-ai/sdk/v2" -import { connectMethods, integrationOptions } from "../../../../src/component/dialog-integration" +import { + connectionSummary, + connectMethods, + credentialConnections, + integrationOptions, +} from "../../../../src/component/dialog-integration" const integration = (value: Partial & Pick): IntegrationInfo => ({ methods: [], @@ -35,6 +40,40 @@ describe("connectMethods", () => { ], }), ).map((method) => method.type), - ).toEqual(["key", "oauth"]) + ).toEqual(["oauth", "key"]) + }) +}) + +describe("credentialConnections", () => { + test("returns removable credential connections only", () => { + expect( + credentialConnections( + integration({ + id: "example", + name: "Example", + connections: [ + { type: "env", name: "EXAMPLE_KEY" }, + { type: "credential", id: "cred_1", label: "Work" }, + ], + }), + ), + ).toEqual([{ type: "credential", id: "cred_1", label: "Work" }]) + }) +}) + +describe("connectionSummary", () => { + test("shows credential labels and environment variables", () => { + expect( + connectionSummary( + integration({ + id: "example", + name: "Example", + connections: [ + { type: "credential", id: "cred_1", label: "Work" }, + { type: "env", name: "EXAMPLE_KEY" }, + ], + }), + ), + ).toBe("Work, $EXAMPLE_KEY") }) }) From f7bd26194ce32ce08ab0c263cfbfb62462c37d9d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 22:58:37 -0400 Subject: [PATCH 05/10] fix(core): align session info projection --- packages/core/src/session/info.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index d629a2a06afa..2308d0646043 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,15 +8,12 @@ import { AbsolutePath, RelativePath } from "../schema" import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" -import { SessionMessageID } from "./message-id" export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ id: SessionSchema.ID.make(row.id), projectID: ProjectV2.ID.make(row.project_id), title: row.title, - share: row.share_url ? { url: row.share_url } : undefined, - revert: row.revert ? { messageID: SessionMessageID.ID.make(row.revert.messageID) } : undefined, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, agent: row.agent ? AgentV2.ID.make(row.agent) : undefined, model: row.model From 4086b77eb2c1d30beba497725707bc960eb9aa73 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 24 Jun 2026 19:20:36 -0400 Subject: [PATCH 06/10] feat(tui): migrate session views to v2 data --- packages/core/src/public-event-manifest.ts | 6 +- packages/core/src/session.ts | 8 +- packages/core/src/session/runner/model.ts | 2 +- packages/core/test/session-create.test.ts | 22 +- .../core/test/session-runner-model.test.ts | 22 + .../src/cli/cmd/run/scrollback.writer.tsx | 1 - .../opencode/src/cli/cmd/run/turn-summary.ts | 2 +- .../test/cli/run/scrollback.surface.test.ts | 2 +- .../test/cli/run/session-replay.test.ts | 6 +- packages/server/src/handlers/event.ts | 13 +- packages/tui/src/app.tsx | 9 +- packages/tui/src/component/dialog-model.tsx | 4 +- .../tui/src/component/dialog-session-list.tsx | 264 +--- packages/tui/src/component/dialog-variant.tsx | 30 +- packages/tui/src/component/prompt/index.tsx | 28 +- packages/tui/src/context/data.tsx | 35 +- packages/tui/src/context/local.tsx | 13 +- .../tui/src/routes/session/dialog-message.tsx | 95 +- packages/tui/src/routes/session/index.tsx | 1113 ++++------------- .../tui/src/routes/session/permission.tsx | 75 +- packages/tui/src/routes/session/question.tsx | 22 +- packages/tui/src/routes/session/sidebar.tsx | 14 +- .../src/routes/session/subagent-footer.tsx | 52 +- .../inline-tool-wrap-snapshot.test.tsx.snap | 2 +- .../tui/inline-tool-wrap-snapshot.test.tsx | 2 +- 25 files changed, 559 insertions(+), 1283 deletions(-) diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts index 99c208d9067b..11b84f790546 100644 --- a/packages/core/src/public-event-manifest.ts +++ b/packages/core/src/public-event-manifest.ts @@ -1,3 +1,7 @@ export * as PublicEventManifest from "./public-event-manifest" -export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest" +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" + +export const Definitions = EventManifest.ServerDefinitions +export const Latest = Event.latest(Definitions) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 888052411a87..6283ab305d50 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -361,7 +361,13 @@ export const layer = Layer.effect( }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + if ( + session.model?.providerID === input.model.providerID && + session.model.id === input.model.id && + (session.model.variant ?? "default") === (input.model.variant ?? "default") + ) + return yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, messageID: SessionMessage.ID.create(), diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b864df8ba38a..11addb07bb63 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -132,7 +132,7 @@ export const fromCatalogModel = ( credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 85c70542136f..b89a167891b8 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -375,7 +375,7 @@ describe("SessionV2.create", () => { }), ) - it.effect("persists repeated switches as distinct durable Session events", () => + it.effect("ignores a model switch when the selected model is unchanged", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) @@ -387,11 +387,29 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service expect( yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), - ).toHaveLength(3) + ).toHaveLength(2) expect(yield* session.get(created.id)).toMatchObject({ model }) }), ) + it.effect("treats an omitted variant as the default variant", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }) + const created = yield* session.create({ location, model }) + + yield* session.switchModel({ + sessionID: created.id, + model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }), + }) + + const { db } = yield* Database.Service + expect( + yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + it.effect("rejects a model switch for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 94669b24be92..49bbce95a381 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("does not project OAuth account metadata into the request body", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "secret", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { server: "https://console.example", orgID: "org_123" }, + }), + ) + + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 43176ded79bb..be814dbbfac2 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio () => ( - {input.agent} {" "} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index 95284dfa0c26..aadac67afc74 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -10,7 +10,7 @@ export function turnSummaryCommit(input: { }): StreamCommit { return { kind: "system", - text: `▣ ${input.agent} · ${input.model} · ${input.duration}`, + text: `${input.agent} · ${input.model} · ${input.duration}`, phase: "final", source: "system", summary: { diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 52ff5a354d58..7406d36cbb1c 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => { const commits = claim(out.renderer) try { - expect(renderRows(commits.at(-1)!)[0]).toBe("▣ Build · Little Frank · 2.2s") + expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s") } finally { destroy(commits) } diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index e3356d18313c..7f0f272d63e6 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -279,7 +279,7 @@ describe("run session replay", () => { }), expect.objectContaining({ kind: "system", - text: "▣ Build · gpt-5 · 2.8s", + text: "Build · gpt-5 · 2.8s", phase: "final", source: "system", messageID: "msg-1", @@ -314,7 +314,7 @@ describe("run session replay", () => { expect(out.commits.at(-1)).toEqual( expect.objectContaining({ kind: "system", - text: "▣ Build · Little Frank · 2.8s", + text: "Build · Little Frank · 2.8s", summary: { agent: "Build", model: "Little Frank", @@ -346,7 +346,7 @@ describe("run session replay", () => { expect(out.commits.filter((commit) => commit.summary)).toEqual([ expect.objectContaining({ kind: "system", - text: "▣ Build · gpt-5 · 2.0s", + text: "Build · gpt-5 · 2.0s", messageID: "msg-step-2", }), ]) diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb874812..590e84a4cd70 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,16 +1,25 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Stream } from "effect" +import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest" +import { Effect, Schema, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" function eventData(data: unknown): Sse.Event { + const event = data as EventV2.Payload + const definition = PublicEventManifest.Latest.get(event.type) + const encoded = definition + ? { + ...event, + data: Schema.encodeUnknownSync(definition.data as Schema.Codec)(event.data), + } + : event return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(encoded), } } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 92a4094976c9..19473b7031d6 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -33,7 +33,7 @@ import { useEvent } from "./context/event" import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" import { SyncProvider, useSync } from "./context/sync" -import { DataProvider } from "./context/data" +import { DataProvider, useData } from "./context/data" import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { DialogModel } from "./component/dialog-model" @@ -371,6 +371,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() + const data = useData() const project = useProject() const exit = useExit() const promptRef = usePromptRef() @@ -495,9 +496,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi createEffect(() => { // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" if (continued || sync.status === "loading" || !args.continue) return - const match = sync.data.session - .toSorted((a, b) => b.time.updated - a.time.updated) - .find((x) => x.parentID === undefined)?.id + const match = data.session.list().find((session) => !session.parentID)?.id if (match) { continued = true if (args.fork) { @@ -553,7 +552,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi name: "session.list", title: "Switch session", category: "Session", - suggested: sync.data.session.length > 0, + suggested: data.session.list().length > 0, slashName: "sessions", slashAliases: ["resume", "continue"], run: () => { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ca2608ce46b6..065338f1cd72 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -110,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) { function onSelect(providerID: string, modelID: string) { local.model.set({ providerID, modelID }, { recent: true }) const list = local.model.variant.list() - const cur = local.model.variant.selected() - if (cur === "default" || (cur && list.includes(cur))) { + const cur = local.model.variant.current() + if (cur && list.includes(cur)) { dialog.clear() return } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e94..df08e32b93ff 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,149 +1,53 @@ +import { createMemo, createResource, onMount } from "solid-js" +import path from "path" +import type { SessionV2Info } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" -import { useSync } from "../context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" -import path from "path" +import { useData } from "../context/data" import { Locale } from "../util/locale" import { useProject } from "../context/project" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { useLocal } from "../context/local" -import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" -import { Spinner } from "./spinner" -import { errorMessage } from "../util/error" -import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { useCommandShortcut } from "../keymap" export function DialogSessionList() { const dialog = useDialog() const route = useRoute() - const sync = useSync() + const data = useData() const project = useProject() const { theme } = useTheme() const sdk = useSDK() const local = useLocal() const toast = useToast() - const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("session.delete") const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") - const [searchResults, { refetch }] = createResource( - () => ({ query: search(), filter: sync.session.query() }), - async (input) => { - if (!input.query) return undefined - const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) - return result.data ?? [] - }, - ) + const [searchResults] = createResource(search, async (query) => { + if (!query) return + const response = await sdk.client.v2.session.list( + { search: query, limit: 50, order: "desc" }, + { throwOnError: true }, + ) + return { query, sessions: response.data.data } + }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) - - function recover(session: NonNullable[number]>) { - const workspace = project.workspace.get(session.workspaceID!) - const list = () => dialog.replace(() => ) - const warp = async (selection: WorkspaceSelection) => { - const workspaceID = await (async () => { - if (selection.type === "none") return null - if (selection.type === "existing") return selection.workspaceID - let result - try { - result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) - } catch (err) { - toast.show({ - title: "Failed to create workspace", - message: errorMessage(err), - variant: "error", - }) - return - } - const workspace = result?.data - if (!workspace) { - toast.show({ - title: "Failed to create workspace", - message: errorMessage(result?.error ?? "no response"), - variant: "error", - }) - return - } - await project.workspace.sync() - return workspace.id - })() - if (workspaceID === undefined) return - await warpWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - sourceWorkspaceID: session.workspaceID, - workspaceID, - sessionID: session.id, - copyChanges: false, - done: list, - }) - } - dialog.replace(() => ( - { - const current = currentSessionID() - const info = current ? sync.data.session.find((item) => item.id === current) : undefined - const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! }) - if (result.error) { - toast.show({ - variant: "error", - title: "Failed to delete workspace", - message: errorMessage(result.error), - }) - return false - } - await project.workspace.sync() - await sync.session.refresh() - if (search()) await refetch() - if (info?.workspaceID === session.workspaceID) { - route.navigate({ type: "home" }) - } - return true - }} - onRestore={() => { - void openWorkspaceSelect({ - dialog, - sdk, - sync, - project, - toast, - onSelect: (selection) => { - void warp(selection) - }, - }) - return false - }} - /> - )) - } - - function orderByRecency(sessionsList: NonNullable>) { - return sessionsList - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => x.id) - } - - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const sessions = createMemo(() => { + const query = search() + if (!query) return data.session.list() + const result = searchResults() + return result?.query === query ? result.sessions : [] + }) const quickSwitchHint = createMemo(() => { const first = quickSwitch1() const last = quickSwitch9() - if (!first || !last) return undefined + if (!first || !last) return return quickSwitchRange(first, last) }) const quickSwitchFooterHints = createMemo(() => { @@ -153,65 +57,38 @@ export function DialogSessionList() { const options = createMemo(() => { const today = new Date().toDateString() - const sessionMap = new Map( - sessions() - .filter((x) => x.parentID === undefined) - .map((x) => [x.id, x]), - ) - - const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - - const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) + const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session])) + const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID)) const pinnedSet = new Set(pinned) - const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1])) - - function buildOption(id: string, category: string) { - const x = sessionMap.get(id) - if (!x) return undefined - const directory = x.path - ? x.directory.endsWith(x.path) - ? x.directory.slice(0, -x.path.length).replace(/\/$/, "") - : undefined - : x.directory - const footer = - directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" + const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" || status?.type === "retry" - const slot = slotByID.get(x.id) - const gutter = isWorking - ? () => - : slot !== undefined - ? () => {slot} - : undefined + const option = (session: SessionV2Info, category: string) => { + const directory = session.location.directory + const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" + const slot = slotByID.get(session.id) return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, + title: session.title, + value: session.id, category, footer, - gutter, + gutter: slot === undefined ? undefined : () => {slot}, } } - const remaining = displayOrder - .filter((id) => !pinnedSet.has(id)) - .map((id) => { - const x = sessionMap.get(id) - if (!x) return undefined - const label = new Date(x.time.updated).toDateString() - return buildOption(id, label === today ? "Today" : label) + const remaining = sessions() + .filter((session) => !session.parentID && !pinnedSet.has(session.id)) + .map((session) => { + const date = new Date(session.time.updated).toDateString() + return option(session, date === today ? "Today" : date) }) - .filter((x) => x !== undefined) - return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining] + return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining] }) - onMount(() => { - dialog.setSize("large") - }) + onMount(() => dialog.setSize("large")) + + const unavailable = (feature: string) => + toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) return ( { - setToDelete(undefined) - }} onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) + route.navigate({ type: "session", sessionID: option.value }) dialog.clear() }} actions={[ { command: "session.pin.toggle", title: "pin/unpin", - onTrigger: (option: { value: string }) => { - local.session.togglePin(option.value) - }, + onTrigger: (option: { value: string }) => local.session.togglePin(option.value), }, { command: "session.delete", title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const session = sessions().find((item) => item.id === option.value) - const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined - - try { - const result = await sdk.client.session.delete({ - sessionID: option.value, - }) - if (result.error) { - if (session?.workspaceID) { - recover(session) - } else { - toast.show({ - variant: "error", - title: "Failed to delete session", - message: errorMessage(result.error), - }) - } - setToDelete(undefined) - return - } - } catch (err) { - if (session?.workspaceID) { - recover(session) - } else { - toast.show({ - variant: "error", - title: "Failed to delete session", - message: errorMessage(err), - }) - } - setToDelete(undefined) - return - } - if (status && status !== "connected") { - await sync.session.refresh() - } - if (search()) await refetch() - setToDelete(undefined) - return - } - setToDelete(option.value) - }, + onTrigger: () => unavailable("Deleting"), }, { command: "session.rename", title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, + onTrigger: () => unavailable("Renaming"), }, ]} footerHints={quickSwitchFooterHints()} diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx index cbff7cd4fe63..1ca224254d36 100644 --- a/packages/tui/src/component/dialog-variant.tsx +++ b/packages/tui/src/component/dialog-variant.tsx @@ -7,32 +7,22 @@ export function DialogVariant() { const local = useLocal() const dialog = useDialog() - const options = createMemo(() => { - return [ - { - value: "default", - title: "Default", - onSelect: () => { - dialog.clear() - local.model.variant.set(undefined) - }, + const options = createMemo(() => + local.model.variant.list().map((variant) => ({ + value: variant, + title: variant, + onSelect: () => { + dialog.clear() + local.model.variant.set(variant) }, - ...local.model.variant.list().map((variant) => ({ - value: variant, - title: variant, - onSelect: () => { - dialog.clear() - local.model.variant.set(variant) - }, - })), - ] - }) + })), + ) return ( options={options()} title={"Select variant"} - current={local.model.variant.selected()} + current={local.model.variant.current()} flat={true} /> ) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 011f64d0d856..b65ddc9ad6f0 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -307,28 +307,20 @@ export function Prompt(props: PromptProps) { ), ) - // Initialize agent/model/variant from last user message when session changes + // Initialize agent/model/variant from the durable V2 Session state. let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID - const msg = lastUserMessage() - - if (sessionID !== syncedSessionID) { - if (!sessionID || !msg) return - - syncedSessionID = sessionID - - // Only set agent if it's a primary agent (not a subagent) - const isPrimaryAgent = local.agent.list().some((agent) => agent.id === msg.agent) - if (msg.agent && isPrimaryAgent) { - // Keep command line --agent if specified. - if (!args.agent) local.agent.set(msg.agent) - if (msg.model) { - local.model.set(msg.model) - local.model.variant.set(msg.model.variant) - } - } + if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return + const session = data.session.get(sessionID) + if (!session) return + const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) + if (agent && !args.agent) local.agent.set(agent.id) + if (session.model) { + local.model.set({ providerID: session.model.providerID, modelID: session.model.id }) + local.model.variant.set(session.model.variant) } + syncedSessionID = sessionID }) const promptCommands = createMemo(() => diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0cd8d2206040..b24ee9a267c1 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -415,6 +415,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const result = { session: { + list() { + return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) + }, get(sessionID: string) { return store.session.info[sessionID] }, @@ -427,8 +430,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.message[sessionID] }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true }) - setStore("session", "message", sessionID, result.data.data) + setStore("session", "message", sessionID, []) + const load = async (cursor?: string, messages: SessionMessage[] = []): Promise => { + const result = await sdk.client.v2.session.messages( + cursor ? { sessionID, limit: 200, cursor } : { sessionID, limit: 200, order: "desc" }, + { throwOnError: true }, + ) + const next = [...messages, ...result.data.data] + return result.data.cursor.next ? load(result.data.cursor.next, next) : next + } + const loaded = await load() + const live = new Map((store.session.message[sessionID] ?? []).map((message) => [message.id, message])) + setStore( + "session", + "message", + sessionID, + [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] + .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) + .toSorted((a, b) => b.time.created - a.time.created), + ) }, }, permission: { @@ -550,6 +570,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onMount(() => { void Promise.allSettled([ + sdk.client.v2.session + .list({ limit: 50, order: "desc" }, { throwOnError: true }) + .then((response) => + setStore( + "session", + "info", + produce((draft) => { + for (const session of response.data.data) draft[session.id] = session + }), + ), + ), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index fbea43e4f74c..57ed0e54a819 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -364,13 +364,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const m = currentModel() if (!m) return undefined const key = `${m.providerID}/${m.modelID}` - return modelStore.variant[key] + return modelStore.variant[key] ?? "default" }, current() { const v = this.selected() if (!v) return undefined - if (!this.list().includes(v)) return undefined - return v + if (v !== "default" && this.list().includes(v)) return v + const m = currentModel()! + return ( + data.location.model + .list() + ?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default" + ) }, list() { const m = currentModel() @@ -397,7 +402,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const index = variants.indexOf(current) if (index === -1 || index === variants.length - 1) { - this.set(undefined) + this.set(variants[0]) return } this.set(variants[index + 1]) diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b7d01842060b..b1216b977538 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -1,22 +1,16 @@ import { createMemo } from "solid-js" -import { useSync } from "../../context/sync" +import { useData } from "../../context/data" import { DialogSelect } from "../../ui/dialog-select" -import { useSDK } from "../../context/sdk" -import { useRoute } from "../../context/route" import { useClipboard } from "../../context/clipboard" -import type { PromptInfo } from "../../component/prompt/history" -import { stripPromptPartIDs as strip } from "../../prompt/part" +import { useToast } from "../../ui/toast" -export function DialogMessage(props: { - messageID: string - sessionID: string - setPrompt?: (prompt: PromptInfo) => void -}) { - const sync = useSync() - const sdk = useSDK() - const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) - const route = useRoute() +export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) { + const data = useData() const clipboard = useClipboard() + const toast = useToast() + const message = createMemo(() => + data.session.message.list(props.sessionID)?.find((message) => message.id === props.messageID), + ) return ( { - const msg = message() - if (!msg) return - - void sdk.client.session.revert({ - sessionID: props.sessionID, - messageID: msg.id, - }) - - if (props.setPrompt) { - const parts = sync.data.part[msg.id] - const promptInfo = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(strip(part)) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - props.setPrompt(promptInfo) - } - + toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) dialog.clear() }, }, @@ -58,17 +30,19 @@ export function DialogMessage(props: { value: "message.copy", description: "message text to clipboard", onSelect: async (dialog) => { - const msg = message() - if (!msg) return - - const parts = sync.data.part[msg.id] - const text = parts.reduce((agg, part) => { - if (part.type === "text" && !part.synthetic) { - agg += part.text - } - return agg - }, "") - + const value = message() + if (!value) return + const text = + value.type === "user" + ? value.text + : value.type === "assistant" + ? value.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("\n") + : "text" in value + ? value.text + : "" await clipboard.write?.(text) dialog.clear() }, @@ -77,29 +51,8 @@ export function DialogMessage(props: { title: "Fork", value: "session.fork", description: "create a new session", - onSelect: async (dialog) => { - const result = await sdk.client.session.fork({ - sessionID: props.sessionID, - messageID: props.messageID, - }) - const msg = message() - const prompt = msg - ? sync.data.part[msg.id].reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - : undefined - route.navigate({ - sessionID: result.data!.id, - type: "session", - prompt, - }) + onSelect: (dialog) => { + toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) dialog.clear() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d1a41e4a90cc..7ecb682fbdc8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -11,35 +11,28 @@ import { onMount, Show, Switch, - untrack, useContext, } from "solid-js" -import { Dynamic } from "solid-js/web" import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" import { useData } from "../../context/data" -import { useEvent } from "../../context/event" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" -import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" +import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { - AssistantMessage, - Part, - Provider, - ToolPart, - UserMessage, - TextPart, - ReasoningPart, - SessionMessage as SessionMessageV2, - SessionMessageAssistant as SessionMessageAssistantV2, - SessionMessageUser as SessionMessageUserV2, - SessionStatus, + ModelV2Info, + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageUser, + SessionV2Info, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" @@ -49,14 +42,8 @@ import { useSDK } from "../../context/sdk" import { useEditorContext } from "../../context/editor" import { openEditor } from "../../editor" import { useDialog } from "../../ui/dialog" -import { DialogAlert } from "../../ui/dialog-alert" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" -import type { PromptInfo } from "../../component/prompt/history" -import { DialogConfirm } from "../../ui/dialog-confirm" -import { DialogTimeline } from "./dialog-timeline" -import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" -import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" @@ -71,8 +58,6 @@ import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" -import * as Model from "../../util/model" -import { formatTranscript } from "../../util/transcript" import { sessionEpilogue } from "../../util/presentation" import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" @@ -81,42 +66,14 @@ import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" -import { DialogRetryAction } from "../../component/dialog-retry-action" -import { getRevertDiffFiles } from "../../util/revert-diff" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" addDefaultParsers(parsers.parsers) -const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" -const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show" -const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at" -const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" -const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs -const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) - export const alwaysSeparate = new WeakSet() -type RetryAction = Extract["action"] - -function goUpsellKeys(action: RetryAction) { - if (!action) return - if (!GO_UPSELL_PROVIDERS.has(action.provider)) return - if (action.reason === "free_tier_limit") { - return { - lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, - dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, - } - } - if (action.reason === "account_rate_limit") { - return { - lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, - dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, - } - } -} - const sessionBindingCommands = [ "session.share", "session.rename", @@ -141,6 +98,7 @@ const sessionBindingCommands = [ "messages.copy", "session.copy", "session.export", + "session.background", "session.child.first", "session.parent", "session.child.next", @@ -168,8 +126,7 @@ const context = createContext<{ showDetails: () => boolean showGenericToolOutput: () => boolean diffWrapMode: () => "word" | "none" - providers: () => ReadonlyMap - sync: ReturnType + models: () => ModelV2Info[] tui: ReturnType }>() @@ -189,9 +146,7 @@ export function Session() { const pluginRuntime = usePluginRuntime() const route = useRouteData("session") const { navigate } = useRoute() - const sync = useSync() const data = useData() - const event = useEvent() const project = useProject() const paths = useTuiPaths() const tuiConfig = useTuiConfig() @@ -207,45 +162,26 @@ export function Session() { setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) - const children = createMemo(() => { - const parentID = session()?.parentID ?? session()?.id - return sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }) - const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) - const foregroundTasks = createMemo(() => - sync.data.capabilities.experimentalBackgroundSubagents - ? messages().flatMap((message) => - (sync.data.part[message.id] ?? []).filter( - (part): part is ToolPart => - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - part.state.metadata?.background !== true, - ), - ) - : [], - ) + const messages = sessionMessages const permissions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return data.session.permission.list(route.sessionID) ?? [] }) const questions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return data.session.question.list(route.sessionID) ?? [] }) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { - const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id - return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed)) + const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id + return messages().findLast((x) => x.type === "assistant" && !x.time.completed && (!completed || x.id > completed)) ?.id }) const lastAssistant = createMemo(() => { - return messages().findLast((x) => x.role === "assistant") + return messages().findLast((x) => x.type === "assistant") }) const dimensions = useTerminalDimensions() @@ -272,7 +208,7 @@ export function Session() { }) const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) - const providers = createMemo(() => Model.index(sync.data.provider)) + const models = createMemo(() => data.location.model.list(location()) ?? []) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const toast = useToast() @@ -282,7 +218,6 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = untrack(() => project.workspace.current()) await Promise.all([ data.session.refresh(sessionID), data.session.message.refresh(sessionID), @@ -300,17 +235,7 @@ export function Session() { return } - if (info.location.workspaceID !== previousWorkspace) { - project.workspace.set(info.location.workspaceID) - - // Sync all the data for this workspace. Note that this - // workspace may not exist anymore which is why this is not - // fatal. If it doesn't we still want to show the session - // (which will be non-interactive) - try { - await sync.bootstrap({ fatal: false }) - } catch {} - } + project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { @@ -324,23 +249,6 @@ export function Session() { }) }) - let lastSwitch: string | undefined = undefined - event.on("message.part.updated", (evt) => { - const part = evt.properties.part - if (part.type !== "tool") return - if (part.sessionID !== route.sessionID) return - if (part.state.status !== "completed") return - if (part.id === lastSwitch) return - - if (part.tool === "plan_exit") { - local.agent.set("build") - lastSwitch = part.id - } else if (part.tool === "plan_enter") { - local.agent.set("plan") - lastSwitch = part.id - } - }) - let seeded = false let scroll: ScrollBoxRenderable let prompt: PromptRef | undefined @@ -351,29 +259,12 @@ export function Session() { seeded = true r.set(route.prompt) } - const keymap = useOpencodeKeymap() const dialog = useDialog() const renderer = useRenderer() - - event.on("session.status", (evt) => { - if (evt.properties.sessionID !== route.sessionID) return - if (evt.properties.status.type !== "retry") return - if (!evt.properties.status.action) return - if (dialog.stack.length > 0) return - - const keys = goUpsellKeys(evt.properties.status.action) - if (!keys) return - - const seen = kv.get(keys.lastSeenAt) - if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - - if (kv.get(keys.dontShow)) return - - void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(keys.dontShow, true) - kv.set(keys.lastSeenAt, Date.now()) - }) - }) + const unavailable = (feature: string) => { + toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) + dialog.clear() + } // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { @@ -388,11 +279,8 @@ export function Session() { const message = messagesList.find((m) => m.id === c.id) if (!message) return false - // Check if message has valid non-synthetic, non-ignored text parts - const parts = sync.data.part[message.id] - if (!parts || !Array.isArray(parts)) return false - - return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored) + if (message.type === "user") return Boolean(message.text.trim()) + return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim()) }) .sort((a, b) => a.y - b.y) @@ -428,79 +316,20 @@ export function Session() { }, 50) } - const local = useLocal() - - function enterChild(sessionID: string) { - navigate({ - type: "session", - sessionID, - }) - const status = sync.data.session_status[sessionID] - if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message) - } - - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) enterChild(next.id) - } - - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) - direction - - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) enterChild(sessions[next].id) - } - - function childSessionHandler(func: () => void) { - return () => { - if (!session()?.parentID || dialog.stack.length > 0) return - func() - } - } - const sessionCommandList = createMemo(() => [ { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", suggested: route.type === "session", category: "Session", - enabled: sync.data.config.share !== "disabled", - slash: { - name: "share", - }, - run: async () => { - const copy = (url: string) => - clipboard - .write?.(url) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) + slash: { name: "share" }, + run: () => { const url = session()?.share?.url - if (url) { - await copy(url) - dialog.clear() - return - } - if (!kv.get("share_consent", false)) { - const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?") - if (ok !== true) return - kv.set("share_consent", true) - } - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => copy(res.data!.share!.url)) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to share session", - variant: "error", - }) - }) + if (!url) return unavailable("Sharing") + void clipboard + .write?.(url) + .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) dialog.clear() }, }, @@ -508,56 +337,22 @@ export function Session() { title: "Rename session", value: "session.rename", category: "Session", - slash: { - name: "rename", - }, - run: () => { - dialog.replace(() => ) - }, + slash: { name: "rename" }, + run: () => unavailable("Renaming"), }, { title: "Jump to message", value: "session.timeline", category: "Session", - slash: { - name: "timeline", - }, - run: () => { - dialog.replace(() => ( - { - const child = scroll.getChildren().find((child) => { - return child.id === messageID - }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) - }} - sessionID={route.sessionID} - setPrompt={(promptInfo) => prompt?.set(promptInfo)} - /> - )) - }, + slash: { name: "timeline" }, + run: () => unavailable("The message timeline"), }, { title: "Fork session", value: "session.fork", category: "Session", - slash: { - name: "fork", - }, - run: () => { - dialog.replace(() => ( - { - if (!messageID) return - const child = scroll.getChildren().find((child) => { - return child.id === messageID - }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) - }} - sessionID={route.sessionID} - /> - )) - }, + slash: { name: "fork" }, + run: () => unavailable("Forking"), }, { title: "Compact session", @@ -568,20 +363,7 @@ export function Session() { aliases: ["summarize"], }, run: () => { - const selectedModel = local.model.current() - if (!selectedModel) { - toast.show({ - variant: "warning", - message: "Connect a provider to summarize this session", - duration: 3000, - }) - return - } - void sdk.client.session.summarize({ - sessionID: route.sessionID, - modelID: selectedModel.modelID, - providerID: selectedModel.providerID, - }) + void sdk.client.v2.session.compact({ sessionID: route.sessionID }) dialog.clear() }, }, @@ -590,86 +372,23 @@ export function Session() { value: "session.unshare", category: "Session", enabled: !!session()?.share?.url, - slash: { - name: "unshare", - }, - run: async () => { - await sdk.client.session - .unshare({ - sessionID: route.sessionID, - }) - .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) - .catch((error) => { - toast.show({ - message: error instanceof Error ? error.message : "Failed to unshare session", - variant: "error", - }) - }) - dialog.clear() - }, + slash: { name: "unshare" }, + run: () => unavailable("Unsharing"), }, { title: "Undo previous message", value: "session.undo", category: "Session", - slash: { - name: "undo", - }, - run: async () => { - const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) - const revert = session()?.revert?.messageID - const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") - if (!message) return - void sdk.client.session - .revert({ - sessionID: route.sessionID, - messageID: message.id, - }) - .then(() => { - toBottom() - }) - const parts = sync.data.part[message.id] - prompt?.set( - parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ), - ) - dialog.clear() - }, + slash: { name: "undo" }, + run: () => unavailable("Undo"), }, { title: "Redo", value: "session.redo", category: "Session", enabled: !!session()?.revert?.messageID, - slash: { - name: "redo", - }, - run: () => { - dialog.clear() - const messageID = session()?.revert?.messageID - if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) - if (!message) { - void sdk.client.session.unrevert({ - sessionID: route.sessionID, - }) - prompt?.set({ input: "", parts: [] }) - return - } - void sdk.client.session.revert({ - sessionID: route.sessionID, - messageID: message.id, - }) - }, + slash: { name: "redo" }, + run: () => unavailable("Redo"), }, { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", @@ -836,22 +555,14 @@ export function Session() { category: "Session", hidden: true, run: () => { - const messages = sync.data.message[route.sessionID] + const messages = sessionMessages() if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] - if (!message || message.role !== "user") continue - - const parts = sync.data.part[message.id] - if (!parts || !Array.isArray(parts)) continue - - const hasValidTextPart = parts.some( - (part) => part && part.type === "text" && !part.synthetic && !part.ignored, - ) - - if (hasValidTextPart) { + if (!message || message.type !== "user" || !message.text.trim()) continue + { const child = scroll.getChildren().find((child) => { return child.id === message.id }) @@ -882,7 +593,7 @@ export function Session() { run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( - (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), + (msg): msg is SessionMessageAssistant => msg.type === "assistant" && (!revertID || msg.id < revertID), ) if (!lastAssistantMessage) { toast.show({ message: "No assistant messages found", variant: "error" }) @@ -890,8 +601,7 @@ export function Session() { return } - const parts = sync.data.part[lastAssistantMessage.id] ?? [] - const textParts = parts.filter((part) => part.type === "text") + const textParts = lastAssistantMessage.content.filter((part) => part.type === "text") if (textParts.length === 0) { toast.show({ message: "No text parts found in last assistant message", variant: "error" }) dialog.clear() @@ -929,17 +639,7 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const sessionMessages = messages() - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: showThinking(), - toolDetails: showDetails(), - assistantMetadata: showAssistantMetadata(), - providers: sync.data.provider, - }, - ) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -959,8 +659,6 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const sessionMessages = messages() - const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( @@ -974,16 +672,7 @@ export function Session() { if (options === null) return - const transcript = formatTranscript( - sessionData, - sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), - { - thinking: options.thinking, - toolDetails: options.toolDetails, - assistantMetadata: options.assistantMetadata, - providers: sync.data.provider, - }, - ) + const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) if (options.openWithoutSaving) { // Just open in editor without saving @@ -1028,24 +717,14 @@ export function Session() { value: "session.background", category: "Session", hidden: true, - enabled: foregroundTasks().length > 0, - run: () => { - void sdk.client.experimental.session.background({ - sessionID: route.sessionID, - workspace: project.workspace.current(), - }) - dialog.clear() - }, + run: () => unavailable("Backgrounding subagents"), }, { title: "Go to child session", value: "session.child.first", category: "Session", hidden: true, - run: () => { - dialog.clear() - moveFirstChild() - }, + run: () => unavailable("Child session discovery"), }, { title: "Go to parent session", @@ -1053,7 +732,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { + run: () => { const parentID = session()?.parentID if (parentID) { navigate({ @@ -1062,7 +741,7 @@ export function Session() { }) } dialog.clear() - }), + }, }, { title: "Next child session", @@ -1070,10 +749,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { - dialog.clear() - moveChild(1) - }), + run: () => unavailable("Sibling session navigation"), }, { title: "Previous child session", @@ -1081,10 +757,7 @@ export function Session() { category: "Session", hidden: true, enabled: !!session()?.parentID, - run: childSessionHandler(() => { - dialog.clear() - moveChild(-1) - }), + run: () => unavailable("Sibling session navigation"), }, ]) @@ -1117,36 +790,6 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, - enabled: foregroundTasks().length > 0, - priority: 1, - bindings: tuiConfig.keybinds.get("session.background"), - })) - - const revertInfo = createMemo(() => session()?.revert) - const revertMessageID = createMemo(() => revertInfo()?.messageID) - - const revertDiffFiles = createMemo>(() => []) - - const revertRevertedMessages = createMemo(() => { - const messageID = revertMessageID() - if (!messageID) return [] - return messages().filter((x) => x.id >= messageID && x.role === "user") - }) - - const revert = createMemo(() => { - const info = revertInfo() - if (!info) return - if (!info.messageID) return - return { - messageID: info.messageID, - reverted: revertRevertedMessages(), - diff: undefined, - diffFiles: revertDiffFiles(), - } - }) - // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) @@ -1165,8 +808,7 @@ export function Session() { showDetails, showGenericToolOutput, diffWrapMode, - providers, - sync, + models, tui: tuiConfig, }} > @@ -1193,26 +835,43 @@ export function Session() { > - {(message, index) => ( - - )} + {(message, index) => { + const reverted = () => session()?.revert?.messageID + return ( + + + item.id >= message.id && item.type === "user", + ).length} + /> + + = reverted()!}> + <> + + + + + + ) + }} 0}> 0}> @@ -1270,14 +929,14 @@ export function Session() { ) } -function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean; last: boolean }) { +function SessionMessageView(props: { message: SessionMessage; first: boolean; last: boolean }) { return ( - + - + @@ -1291,118 +950,13 @@ function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean - + ) } -function SessionUserMessageV2(props: { message: SessionMessageUserV2; first: boolean }) { - const { theme } = useTheme() - return ( - - {props.message.text} - - - - {(file) => ( - - {file.name ?? file.uri} ({file.mime}) - - )} - - - - - ) -} - -function SessionAssistantMessageV2(props: { message: SessionMessageAssistantV2; last: boolean }) { - const { theme, syntax } = useTheme() - const local = useLocal() - return ( - <> - - {(content) => ( - - - - - - - - - {content.type === "reasoning" ? content.text.trim() : ""} - - - - } - /> - - - )} - - - - {props.message.error?.message} - - - - - - - {Locale.titlecase(props.message.agent)} · {props.message.model.providerID}/{props.message.model.id} - - - - - ) -} - -function SessionToolMessageV2(props: { - content: Extract -}) { - const { theme } = useTheme() - const output = createMemo(() => - props.content.state.status === "error" - ? props.content.state.error.message - : props.content.state.status === "pending" - ? "" - : props.content.state.content - .flatMap((item) => (item.type === "text" ? [item.text] : [item.name ?? item.uri])) - .join("\n"), - ) - return ( - - - {props.content.state.status === "pending" || props.content.state.status === "running" ? "∙" : "✓"}{" "} - {props.content.name} - - {(value) => {value()}} - - ) -} - -function SessionSwitchMessageV2(props: { message: SessionMessageV2 }) { +function SessionSwitchMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() const text = () => { if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}` @@ -1413,7 +967,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageV2 }) { return {text()} } -function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) { +function SessionNoticeMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() return ( @@ -1422,48 +976,64 @@ function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) { ) } -const MIME_BADGE: Record = { - "text/plain": "txt", - "image/png": "img", - "image/jpeg": "img", - "image/gif": "img", - "image/webp": "img", - "application/pdf": "pdf", - "application/x-directory": "dir", +function CompactionMessage() { + const { theme } = useTheme() + return ( + + ) +} + +function RevertMessage(props: { count: number }) { + const { theme } = useTheme() + const dialog = useDialog() + const renderer = useRenderer() + const toast = useToast() + const [hover, setHover] = createSignal(false) + return ( + alwaysSeparate.add(element)} + onMouseOver={() => setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) + dialog.clear() + }} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {props.count} message{props.count === 1 ? "" : "s"} reverted + Redo is not implemented for V2 sessions yet + + + ) } + function UserMessage(props: { - message: UserMessage - parts: Part[] - onMouseUp: () => void + message: SessionMessageUser index: number - pending?: string }) { const ctx = use() const local = useLocal() - const text = createMemo(() => { - const texts = props.parts - .map((x) => { - if (x.type === "text" && !x.synthetic) { - return x.text - } - return null - }) - .filter(Boolean) - return texts.join("\n\n") - }) - const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) + const files = createMemo(() => props.message.files ?? []) const { theme } = useTheme() const [hover, setHover] = createSignal(false) - const queued = createMemo(() => props.pending && props.message.id > props.pending) - const color = createMemo(() => local.agent.color(props.message.agent)) - const queuedFg = createMemo(() => selectedForeground(theme, color())) - const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) - - const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) + const color = createMemo(() => local.agent.color(useData().session.get(ctx.sessionID)?.agent ?? "build")) + const dialog = useDialog() + const renderer = useRenderer() return ( - <> - + alwaysSeparate.add(el)} @@ -1479,16 +1049,19 @@ function UserMessage(props: { onMouseOut={() => { setHover(false) }} - onMouseUp={props.onMouseUp} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ) + }} paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + {props.message.text} - + {(file) => { const directory = file.mime === "application/x-directory" @@ -1497,52 +1070,34 @@ function UserMessage(props: { {directory ? " Directory " : " File "} - {file.filename} + {file.name ?? file.uri} ) }} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - } - > + - QUEUED + {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - - + ) } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { +function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { const ctx = use() const local = useLocal() const { theme } = useTheme() - const sync = useSync() - const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) - const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) + const model = createMemo( + () => + ctx.models().find( + (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, + )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) @@ -1551,56 +1106,34 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const duration = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 - const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) - if (!user || !user.time) return 0 - return props.message.time.completed - user.time.created + return props.message.time.completed - props.message.time.created }) - const childShortcut = useCommandShortcut("session.child.first") - const backgroundShortcut = useCommandShortcut("session.background") - return ( <> - - {(part, index) => { - const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) - return ( - - + {(content, index) => ( + + + + + + - - ) - }} + + + + + + )} - x.type === "tool" && x.tool === "task")}> - - - {childShortcut()} - view subagents - - x.type === "tool" && - x.tool === "task" && - x.state.status === "running" && - x.state.metadata?.background !== true, - ) - } - > - · - {backgroundShortcut()} - background - - - - - + alwaysSeparate.add(el)} border={["left"]} @@ -1616,27 +1149,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las - + alwaysSeparate.add(el)} paddingLeft={3}> - - ▣{" "} - {" "} - {Locale.titlecase(props.message.mode)} + + {Locale.titlecase(props.message.agent)} + · {model()} · {Locale.duration(duration())} - - · interrupted - @@ -1645,15 +1167,13 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) } -const PART_MAPPING = { - text: TextPart, - tool: ToolPart, - reasoning: ReasoningPart, -} - const INLINE_TOOL_ICON_WIDTH = 2 -function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { +function ReasoningPart(props: { + last: boolean + part: SessionMessageAssistantReasoning + message: SessionMessageAssistant +}) { const { theme } = useTheme() const ctx = use() // Collapsed by default in hide mode: a single line throughout, so the @@ -1664,13 +1184,11 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass // OpenRouter encrypts some reasoning blocks; drop the placeholder. return props.part.text.replace("[REDACTED]", "").trim() }) - // Reasoning is finalized when the server sets `time.end` (see processor.ts). - // Flips independently of the parent message completing. - const isDone = createMemo(() => props.part.time.end !== undefined) + const isDone = createMemo(() => props.message.time.completed !== undefined) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const duration = createMemo(() => { - const end = props.part.time.end - return end === undefined ? 0 : Math.max(0, end - props.part.time.start) + const end = props.message.time.completed + return end === undefined ? 0 : Math.max(0, end - props.message.time.created) }) const summary = createMemo(() => reasoningSummary(content())) const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) @@ -1760,7 +1278,7 @@ function ReasoningHeader(props: { ) } -function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { +function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { const ctx = use() const { theme, syntax } = useTheme() return ( @@ -1783,9 +1301,9 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess // Pending messages moved to individual tool pending functions -function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { +function ToolPart(props: { part: SessionMessageAssistantTool }) { const ctx = use() - const display = createMemo(() => toolDisplay(props.part.tool)) + const display = createMemo(() => toolDisplay(props.part.name)) // Hide tool if showDetails is false and tool completed successfully const shouldHide = createMemo(() => { @@ -1796,16 +1314,19 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + return props.part.state.status === "pending" ? {} : props.part.state.structured }, get input() { - return props.part.state.input ?? {} + return typeof props.part.state.input === "string" ? {} : props.part.state.input }, get output() { - return props.part.state.status === "completed" ? props.part.state.output : undefined + if (props.part.state.status === "pending") return undefined + return props.part.state.content + .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) + .join("\n") }, get tool() { - return props.part.tool + return props.part.name }, get part() { return props.part @@ -1867,7 +1388,7 @@ type ToolProps = { metadata: Record tool: string output?: string - part: ToolPart + part: SessionMessageAssistantTool } function GenericTool(props: ToolProps) { const { theme } = useTheme() @@ -1917,23 +1438,23 @@ function InlineTool(props: { spinner?: boolean separate?: boolean children: JSX.Element - part: ToolPart + part: SessionMessageAssistantTool onClick?: () => void }) { const { theme } = useTheme() const ctx = use() - const sync = useSync() + const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { - const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID + const callID = data.session.permission.list(ctx.sessionID)?.at(0)?.source?.callID if (!callID) return false - return callID === props.part.callID + return callID === props.part.id }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const denied = createMemo( () => @@ -2069,13 +1590,13 @@ function BlockTool(props: { title?: string children: JSX.Element onClick?: () => void - part?: ToolPart + part?: SessionMessageAssistantTool spinner?: boolean }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) - const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) return ( alwaysSeparate.add(el)} @@ -2186,27 +1707,27 @@ function Write(props: ToolProps) { return ( - + - + - Write {pathFormatter.format(stringValue(props.input.filePath))} + Write {pathFormatter.format(stringValue(props.input.path))} @@ -2232,7 +1753,6 @@ function Read(props: ToolProps) { const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] - if (props.part.state.time.compacted) return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") @@ -2242,11 +1762,11 @@ function Read(props: ToolProps) { - Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} + Read {pathFormatter.format(stringValue(props.input.path))} {input(props.input, ["path"])} {(filepath) => ( @@ -2292,99 +1812,28 @@ function WebSearch(props: ToolProps) { } function Task(props: ToolProps) { - const { theme } = useTheme() const { navigate } = useRoute() - const sync = useSync() - const dialog = useDialog() - - onMount(() => { - const sessionID = stringValue(props.metadata.sessionId) - if (sessionID && !sync.data.message[sessionID]?.length) void sync.session.sync(sessionID) - }) - - const sessionID = createMemo(() => stringValue(props.metadata.sessionId)) - const messages = createMemo(() => sync.data.message[sessionID() ?? ""] ?? []) - - const tools = createMemo(() => { - return messages().flatMap((msg) => - (sync.data.part[msg.id] ?? []) - .filter((part): part is ToolPart => part.type === "tool") - .map((part) => ({ tool: part.tool, state: part.state })), - ) - }) - - const current = createMemo(() => - tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title), - ) - - const status = createMemo(() => sync.data.session_status[sessionID() ?? ""]) - const isRunning = createMemo(() => { - const value = status() - return ( - props.part.state.status === "running" || - (props.metadata.background === true && value !== undefined && value.type !== "idle") - ) - }) - const retry = createMemo(() => { - const value = status() - if (value?.type !== "retry") return - return value - }) - - const duration = createMemo(() => { - const first = messages().find((x) => x.role === "user")?.time.created - const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed - if (!first || !assistant) return 0 - return assistant - first - }) - - const content = createMemo(() => { - const description = stringValue(props.input.description) - if (!description) return "" - let content = [ - formatSubagentTitle( - Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), - description, - props.metadata.background === true, - ), - ] - - const retrying = retry() - if (isRunning() && retrying) { - content.push(`↳ ${formatSubagentRetry(retrying.attempt, Locale.truncate(retrying.message, 80))}`) - } else if (isRunning() && tools().length > 0) { - if (current()) { - const state = current()!.state - const title = state.status === "running" || state.status === "completed" ? state.title : undefined - content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`) - } else content.push(`↳ ${formatSubagentToolcalls(tools().length)}`) - } - - if (!isRunning() && props.part.state.status === "completed") { - content.push(`↳ ${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`) - } - - return content.join("\n") - }) + const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId)) + const description = createMemo(() => stringValue(props.input.description)) return ( { - if (sessionID()) { - navigate({ type: "session", sessionID: sessionID()! }) - } - const status = retry() - if (status) void DialogAlert.show(dialog, "Retry Error", status.message) + const id = sessionID() + if (id) navigate({ type: "session", sessionID: id }) }} > - {content()} + {formatSubagentTitle( + Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"), + description() ?? "Task", + props.metadata.background === true, + )} ) } @@ -2418,14 +1867,14 @@ function Edit(props: ToolProps) { return ctx.width > 120 ? "split" : "unified" }) - const ft = createMemo(() => filetype(stringValue(props.input.filePath))) + const ft = createMemo(() => filetype(stringValue(props.input.path))) const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "") return ( - + - + - - Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })} + + Edit {pathFormatter.format(stringValue(props.input.path))} {input({ replaceAll: props.input.replaceAll })} @@ -2460,68 +1909,28 @@ function Edit(props: ToolProps) { } function ApplyPatch(props: ToolProps) { - const ctx = use() - const { theme, syntax } = useTheme() - const pathFormatter = usePathFormatter() - - const files = createMemo(() => parseApplyPatchFiles(props.metadata.files)) - - const view = createMemo(() => { - const diffStyle = ctx.tui.diff_style - if (diffStyle === "stacked") return "unified" - return ctx.width > 120 ? "split" : "unified" + const { theme } = useTheme() + const files = createMemo(() => { + const applied = props.metadata.applied + if (!Array.isArray(applied)) return [] + return applied.flatMap((value) => { + const item = recordValue(value) + const type = stringValue(item?.type) + const resource = stringValue(item?.resource) + return type && resource ? [{ type, resource }] : [] + }) }) - function Diff(p: { diff: string; filePath: string }) { - return ( - - - - ) - } - - function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { - if (file.type === "delete") return "# Deleted " + file.relativePath - if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath - return "← Patched " + file.relativePath - } - return ( 0}> {(file) => ( - - - -{file.deletions} line{file.deletions !== 1 ? "s" : ""} - - } - > - - - + + {file.resource} )} @@ -2673,6 +2082,36 @@ function recordValue(value: unknown): Record | undefined { return value as Record } +function formatSessionTranscript( + session: SessionV2Info, + messages: SessionMessage[], + thinking: boolean, + toolDetails: boolean, +) { + const body = messages.flatMap((message) => { + if (message.type === "user") return [`## User\n\n${message.text}`] + if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output}\n\`\`\``] + if (message.type !== "assistant") return [] + const content = message.content.flatMap((item) => { + if (item.type === "text") return [item.text] + if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : [] + if (!toolDetails) return [`**Tool: ${item.name}**`] + const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2) + const output = + item.state.status === "error" + ? item.state.error.message + : item.state.status === "pending" + ? "" + : item.state.content + .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) + .join("\n") + return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] + }) + return [`## Assistant\n\n${content.join("\n\n")}`] + }) + return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` +} + export function parseApplyPatchFiles(value: unknown) { if (!Array.isArray(value)) return [] return value.flatMap((item) => { diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 766ed3c116ce..1e20ffac60b7 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -4,11 +4,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useTheme, selectedForeground } from "../../context/theme" -import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import type { PermissionV2Request } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" -import { useSync } from "../../context/sync" -import { useProject } from "../../context/project" +import { useData } from "../../context/data" import { filetype } from "../../util/filetype" import { Locale } from "../../util/locale" import { webSearchProviderLabel } from "../../util/tool-display" @@ -19,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function EditBody(props: { request: PermissionRequest }) { +function EditBody(props: { request: PermissionV2Request }) { const themeState = useTheme() const theme = themeState.theme const syntax = themeState.syntax @@ -27,8 +26,7 @@ function EditBody(props: { request: PermissionRequest }) { const dimensions = useTerminalDimensions() const filepath = createMemo(() => { - const value = props.request.metadata?.filepath - return typeof value === "string" ? value : "" + return props.request.resources[0] ?? "" }) const diff = createMemo(() => { const value = props.request.metadata?.diff @@ -108,26 +106,24 @@ function TextBody(props: { title: string; description?: string; icon?: string }) ) } -export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { +export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) { const sdk = useSDK() - const project = useProject() - const sync = useSync() + const data = useData() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) const pathFormatter = usePathFormatter() - - const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) + const session = createMemo(() => data.session.get(props.request.sessionID)) const input = createMemo(() => { - const tool = props.request.tool + const tool = props.request.source if (!tool) return {} - const parts = sync.data.part[tool.messageID] ?? [] - for (const part of parts) { - if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") { - return part.state.input ?? {} - } - } + const message = data.session + .message.list(props.request.sessionID) + ?.find((message) => message.type === "assistant" && message.id === tool.messageID) + if (message?.type !== "assistant") return {} + const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) + if (part?.type === "tool" && part.state.status !== "pending") return part.state.input return {} }) @@ -140,14 +136,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? title="Always allow" body={ - - + + This will allow the following patterns until OpenCode is restarted - + {(pattern) => ( {"- "} @@ -165,11 +161,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void sdk.client.permission.reply({ + void sdk.client.v2.session.permission.reply({ + sessionID: props.request.sessionID, reply: "always", requestID: props.request.id, - directory: props.directory, - workspace: project.workspace.current(), }) }} /> @@ -177,12 +172,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? { - void sdk.client.permission.reply({ + void sdk.client.v2.session.permission.reply({ + sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, - directory: props.directory, message: message || undefined, - workspace: project.workspace.current(), }) }} onCancel={() => { @@ -193,12 +187,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? {(() => { const info = () => { - const permission = props.request.permission + const permission = props.request.action const data = input() if (permission === "edit") { - const raw = props.request.metadata?.filepath - const filepath = typeof raw === "string" ? raw : "" + const filepath = props.request.resources[0] ?? "" return { icon: "→", title: `Edit ${pathFormatter.format(filepath)}`, @@ -207,7 +200,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "read") { - const raw = data.filePath + const raw = data.path const filePath = typeof raw === "string" ? raw : "" return { icon: "→", @@ -333,13 +326,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? const meta = props.request.metadata ?? {} const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined - const pattern = props.request.patterns?.[0] + const pattern = props.request.resources[0] const derived = typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived const dir = pathFormatter.format(raw) - const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") + const patterns = props.request.resources.filter((p): p is string => typeof p === "string") return { icon: "←", @@ -402,7 +395,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? title="Permission required" header={header()} body={current.body} - options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + options={ + props.request.save?.length + ? { once: "Allow once", always: "Allow always", reject: "Reject" } + : { once: "Allow once", reject: "Reject" } + } escapeKey="reject" fullscreen onSelect={(option) => { @@ -415,19 +412,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? setStore("stage", "reject") return } - void sdk.client.permission.reply({ + void sdk.client.v2.session.permission.reply({ + sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, - directory: props.directory, - workspace: project.workspace.current(), }) return } - void sdk.client.permission.reply({ + void sdk.client.v2.session.permission.reply({ + sessionID: props.request.sessionID, reply: "once", requestID: props.request.id, - directory: props.directory, - workspace: project.workspace.current(), }) }} /> diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a91..ded2c6db4f4c 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j import { useRenderer } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { selectedForeground, tint, useTheme } from "../../context/theme" -import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useTuiConfig } from "../../config" @@ -11,7 +11,7 @@ import { useBindings, useOpencodeModeStack } from "../../keymap" const QUESTION_MODE = "question" -export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) { +export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() @@ -24,7 +24,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st const [tabHover, setTabHover] = createSignal(null) const [store, setStore] = createStore({ tab: 0, - answers: [] as QuestionAnswer[], + answers: [] as QuestionV2Answer[], custom: [] as string[], selected: 0, editing: false, @@ -47,17 +47,17 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.client.question.reply({ + void sdk.client.v2.session.question.reply({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, - answers, + questionV2Reply: { answers }, }) } function reject() { - void sdk.client.question.reject({ + void sdk.client.v2.session.question.reject({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, }) } @@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st setStore("custom", inputs) } if (single()) { - void sdk.client.question.reply({ + void sdk.client.v2.session.question.reply({ + sessionID: props.request.sessionID, requestID: props.request.id, - directory: props.directory, - answers: [[answer]], + questionV2Reply: { answers: [[answer]] }, }) return } diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 0c5d2b313967..baf70cd576e8 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -1,5 +1,5 @@ import { useProject } from "../../context/project" -import { useSync } from "../../context/sync" +import { useData } from "../../context/data" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../config" @@ -12,12 +12,12 @@ import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const pluginRuntime = usePluginRuntime() const project = useProject() - const sync = useSync() + const data = useData() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const session = createMemo(() => sync.session.get(props.sessionID)) + const session = createMemo(() => data.session.get(props.sessionID)) const workspace = () => { - const workspaceID = session()?.workspaceID + const workspaceID = session()?.location.workspaceID if (!workspaceID) return return project.workspace.get(workspaceID) } @@ -60,11 +60,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {props.sessionID} - + } + fallback={ + + } > {(item) => ( sync.data.message[route.sessionID] ?? []) - const session = createMemo(() => sync.session.get(route.sessionID)) + const data = useData() + const session = createMemo(() => data.session.get(route.sessionID)) const subagentInfo = createMemo(() => { const s = session() - if (!s) return { label: "Subagent", index: 0, total: 0 } + if (!s) return "Subagent" const agentMatch = s.title.match(/@(\w+) subagent/) - const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - - if (!s.parentID) return { label, index: 0, total: 0 } - - const siblings = sync.data.session - .filter((x) => x.parentID === s.parentID) - .toSorted((a, b) => a.time.created - b.time.created) - const index = siblings.findIndex((x) => x.id === s.id) - - return { label, index: index + 1, total: siblings.length } + return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" }) const usage = createMemo(() => { - const msg = messages() - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) - if (!last) return - + const current = session() + if (!current) return const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + current.tokens.input + + current.tokens.output + + current.tokens.reasoning + + current.tokens.cache.read + + current.tokens.cache.write if (tokens <= 0) return - const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = data.location + .model.list(current.location) + ?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = session()?.cost ?? 0 + const cost = current.cost const money = new Intl.NumberFormat("en-US", { style: "currency", @@ -78,13 +71,8 @@ export function SubagentFooter() { - {subagentInfo().label} + {subagentInfo()} - 0}> - - ({subagentInfo().index} of {subagentInfo().total}) - - {(item) => ( @@ -95,10 +83,10 @@ export function SubagentFooter() { setHover("parent")} - onMouseOut={() => setHover(null)} + onMouseOver={() => setHover("parent")} + onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatchCommand("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > Parent {parentShortcut()} diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 46c48ef325f5..4b9ec9cccef5 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -76,7 +76,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d `; exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = ` -" ▣ Build · Little Frank · 53.1s +" Build · Little Frank · 53.1s ✓ Build Task — Review changes ↳ 48 toolcalls · 1m 40s" diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 8ba730906ae0..e1ad5dc70c68 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -144,7 +144,7 @@ function AssistantSummaryBeforeInlineFixture() { return ( alwaysSeparate.add(el)} paddingLeft={3}> - ▣ Build · Little Frank · 53.1s + Build · Little Frank · 53.1s {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} From 4d8f3a4183e5882916c6fd5babd79cb12ff4f6e1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 24 Jun 2026 20:24:20 -0400 Subject: [PATCH 07/10] fix(core): lower OpenAI reasoning options --- packages/core/src/v1/config/provider-options.ts | 9 +++++++++ packages/core/test/config/config.test.ts | 4 ++-- packages/core/test/config/provider-options.test.ts | 8 +++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 9506a6a2b0d4..6bf0bd9e1aa5 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -42,6 +42,15 @@ const openai: Lowerer = { }, request(options) { const result = snake(options) + if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) { + result.reasoning = { + ...(isRecord(result.reasoning) ? result.reasoning : {}), + ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}), + ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}), + } + delete result.reasoning_effort + delete result.reasoning_summary + } if (options.textVerbosity !== undefined) { result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } delete result.text_verbosity diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index b092bb55829f..73d8afb5180a 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -599,9 +599,9 @@ describe("Config", () => { models: { model: { request: { - body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" }, + body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" }, }, - variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }], + variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }], }, }, }) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 3edd9ed07484..44a1c0c9db52 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => { expect( lowerer.request({ reasoningEffort: "high", + reasoningSummary: "auto", + reasoning: { encryptedContent: true }, textVerbosity: "low", text: { outputFormat: "plain" }, nestedValue: { camelCase: true }, }), ).toEqual({ - reasoning_effort: "high", + reasoning: { encrypted_content: true, effort: "high", summary: "auto" }, text: { output_format: "plain", verbosity: "low" }, nested_value: { camel_case: true }, }) @@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => { body: { trace: true }, settings: { resourceName: "resource" }, }) - expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({ - reasoning_effort: "high", + expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({ + reasoning: { effort: "high", summary: "auto" }, text: { verbosity: "low" }, }) }) From c55b5f5279833735b2ae154dbeb2a08197a5d703 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 24 Jun 2026 20:24:27 -0400 Subject: [PATCH 08/10] fix(tui): reconnect after server restart --- packages/cli/src/commands/handlers/serve.ts | 8 +- packages/tui/src/app.tsx | 4 + packages/tui/src/component/reconnecting.tsx | 32 ++++++ packages/tui/src/context/data.tsx | 102 +++++++++++++++----- packages/tui/test/cli/tui/data.test.tsx | 68 +++++++++++++ packages/tui/test/fixture/tui-sdk.ts | 8 ++ 6 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 packages/tui/src/component/reconnecting.tsx diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index c95dfe73a983..6b7141c18340 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -48,11 +48,15 @@ function listen(hostname: string, port: Option.Option, password: string) } function bind(hostname: string, port: number, password: string) { + const server = createServer() return Layer.build( HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), Layer.provide(Credential.defaultLayer), Layer.provide(PermissionSaved.defaultLayer), ), - ).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) + ).pipe( + Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), + Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), + ) } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 19473b7031d6..a39b6555a5c4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -32,6 +32,7 @@ import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" +import { Reconnecting } from "./component/reconnecting" import { SyncProvider, useSync } from "./context/sync" import { DataProvider, useData } from "./context/data" import { LocationProvider } from "./context/location" @@ -1091,6 +1092,9 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi + + + ) } diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx new file mode 100644 index 000000000000..2ebbda99093b --- /dev/null +++ b/packages/tui/src/component/reconnecting.tsx @@ -0,0 +1,32 @@ +import { Show } from "solid-js" +import { useTheme } from "../context/theme" +import { Spinner } from "./spinner" + +export function Reconnecting(props: { attempt: number; error?: string }) { + const theme = useTheme().theme + + return ( + + + Connection lost + Reconnecting to server... + Attempt {props.attempt} + + + {props.error} + + + + + ) +} diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b24ee9a267c1..17ff9625fa65 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -23,6 +23,8 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { createSignal, onCleanup, onMount } from "solid-js" +export type DataConnectionStatus = "connecting" | "connected" | "reconnecting" + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -34,6 +36,11 @@ type LocationData = { } type Data = { + connection: { + status: DataConnectionStatus + attempt: number + error?: string + } session: { info: Record message: Record @@ -58,6 +65,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { const [store, setStore] = createStore({ + connection: { + status: "connecting", + attempt: 0, + }, session: { info: {}, message: {}, @@ -404,16 +415,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } } - onMount(() => { - const controller = new AbortController() - onCleanup(() => controller.abort()) - void (async () => { - const events = await sdk.client.v2.event.subscribe({ signal: controller.signal }) - for await (const event of events.stream) handleEvent(event) - })().catch(() => {}) - }) - const result = { + connection: { + status() { + return store.connection.status + }, + attempt() { + return store.connection.attempt + }, + error() { + return store.connection.error + }, + }, session: { list() { return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) @@ -499,7 +512,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "agent", result.data.data) + setStore("location", key, { ...store.location[key], agent: result.data.data }) }, }, command: { @@ -509,7 +522,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "command", result.data.data) + setStore("location", key, { ...store.location[key], command: result.data.data }) }, }, integration: { @@ -522,7 +535,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ { throwOnError: true }, ) const key = locationKey(result.data.location) - setStore("location", key, "integration", result.data.data) + setStore("location", key, { ...store.location[key], integration: result.data.data }) }, }, model: { @@ -532,7 +545,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "model", result.data.data) + setStore("location", key, { ...store.location[key], model: result.data.data }) }, }, provider: { @@ -542,7 +555,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "provider", result.data.data) + setStore("location", key, { ...store.location[key], provider: result.data.data }) }, }, reference: { @@ -552,7 +565,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "reference", result.data.data) + setStore("location", key, { ...store.location[key], reference: result.data.data }) }, }, skill: { @@ -562,14 +575,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(ref?: LocationRef) { const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) const key = locationKey(result.data.location) - setStore("location", key, "skill", result.data.data) + setStore("location", key, { ...store.location[key], skill: result.data.data }) }, }, }, } - onMount(() => { - void Promise.allSettled([ + async function bootstrap() { + const settled = await Promise.allSettled([ sdk.client.v2.session .list({ limit: 50, order: "desc" }, { throwOnError: true }) .then((response) => @@ -589,12 +602,57 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.reference.refresh(), result.location.command.refresh(), result.location.skill.refresh(), - ]).then((settled) => { - for (const failure of settled.filter((item) => item.status === "rejected")) - console.error("Failed to refresh default location data", failure.reason) - }) + ]) + for (const failure of settled.filter((item) => item.status === "rejected")) + console.error("Failed to refresh default location data", failure.reason) + } + + onMount(() => { + const controller = new AbortController() + onCleanup(() => controller.abort()) + void (async () => { + while (!controller.signal.aborted) { + const error = await (async () => { + const events = await sdk.client.v2.event.subscribe({ + signal: controller.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const stream = events.stream[Symbol.asyncIterator]() + const first = await stream.next() + if (first.done) return new Error("Event stream disconnected") + setStore("connection", { status: "connected", attempt: 0, error: undefined }) + handleEvent(first.value) + await bootstrap() + while (!controller.signal.aborted) { + const event = await stream.next() + if (event.done) return new Error("Event stream disconnected") + handleEvent(event.value) + } + })().catch((error) => error) + if (controller.signal.aborted) return + setStore("connection", { + status: "reconnecting", + attempt: store.connection.status === "connected" ? 1 : store.connection.attempt + 1, + error: error instanceof Error ? error.message : String(error), + }) + await wait(250, controller.signal) + } + })() }) return result }, }) + +function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 279ba4d065c4..f6b19bac2091 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -92,6 +92,74 @@ test("refreshes resources into reactive getters", async () => { } }) +test("reconnects the event stream and bootstraps fresh data", async () => { + const events = createEventSource() + const requests = { event: 0, model: 0 } + const calls = createFetch((url) => { + if (url.pathname === "/api/event") { + requests.event++ + return events.response() + } + if (url.pathname !== "/api/model") return + requests.model++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [ + { + id: `model-${requests.model}`, + providerID: "provider", + name: `Model ${requests.model}`, + api: { type: "native" }, + capabilities: { tools: false, input: [], output: [] }, + cost: [], + limit: { context: 1, output: 1 }, + request: { headers: {}, body: {} }, + status: "active", + time: { released: 0 }, + variants: [], + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.location.model.list()?.[0]?.id === "model-1") + expect(data.connection.status()).toBe("connected") + expect(data.connection.attempt()).toBe(0) + + events.disconnect() + await wait(() => data.connection.status() === "reconnecting") + expect(data.connection.attempt()).toBe(1) + expect(data.connection.error()).toBe("Event stream disconnected") + + await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) + expect(requests.event).toBe(2) + expect(data.connection.status()).toBe("connected") + expect(data.connection.attempt()).toBe(0) + expect(data.connection.error()).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("refreshes integrations after integration updates", async () => { const events = createEventSource() const requests = { integration: 0, model: 0, provider: 0 } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d1cf3c7dfc2a..04fe2aee15b8 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -47,6 +47,9 @@ export function createEventSource() { new ReadableStream({ start(controller) { stream = controller + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`), + ) for (const chunk of pending.splice(0)) controller.enqueue(chunk) }, cancel() { @@ -56,6 +59,10 @@ export function createEventSource() { { headers: { "content-type": "text/event-stream" } }, ) }, + disconnect() { + stream?.close() + stream = undefined + }, } } @@ -88,6 +95,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Thu, 25 Jun 2026 21:16:40 -0400 Subject: [PATCH 09/10] feat(tui): improve v2 session rendering --- bun.lock | 2 + packages/cli/src/commands/commands.ts | 4 + packages/cli/src/commands/handlers/default.ts | 4 +- packages/core/package.json | 1 + packages/core/src/tool/apply-patch.ts | 56 ++- packages/core/src/tool/edit.ts | 23 +- packages/core/test/tool-apply-patch.test.ts | 23 + packages/core/test/tool-edit.test.ts | 9 + packages/tui/package.json | 1 + packages/tui/src/context/data.tsx | 55 +- packages/tui/src/routes/session/index.tsx | 470 +++++++++++++----- .../tui/src/routes/session/permission.tsx | 16 +- packages/tui/src/routes/session/rows.ts | 163 ++++++ packages/tui/src/util/layout.ts | 25 - .../inline-tool-wrap-snapshot.test.tsx.snap | 5 - packages/tui/test/cli/tui/data.test.tsx | 133 +++++ .../tui/inline-tool-wrap-snapshot.test.tsx | 185 +------ .../tui/test/cli/tui/session-rows.test.ts | 101 ++++ 18 files changed, 918 insertions(+), 358 deletions(-) create mode 100644 packages/tui/src/routes/session/rows.ts delete mode 100644 packages/tui/src/util/layout.ts create mode 100644 packages/tui/test/cli/tui/session-rows.test.ts diff --git a/bun.lock b/bun.lock index 5dd59c634bbc..99a0d5ed7430 100644 --- a/bun.lock +++ b/bun.lock @@ -289,6 +289,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", @@ -867,6 +868,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index f6ed2020b969..18db4006a83b 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -6,6 +6,10 @@ declare const OPENCODE_CLI_NAME: string | undefined export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { + directory: Argument.string("directory").pipe( + Argument.withDescription("Directory to start OpenCode in"), + Argument.optional, + ), standalone: Flag.boolean("standalone").pipe( Flag.withDescription("Run with a private server instead of the background service"), Flag.withDefault(false), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 919124c02d18..3be065f03d3e 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,11 +1,13 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Daemon } from "../../services/daemon" import { Standalone } from "../../services/standalone" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { + const directory = Option.getOrUndefined(input.directory) + if (directory !== undefined) process.chdir(directory) const daemon = yield* Daemon.Service const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) const { runTui } = yield* Effect.promise(() => import("../../tui")) diff --git a/packages/core/package.json b/packages/core/package.json index c177d940182e..5d459dc84d74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -103,6 +103,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d52..05e8e59fbbed 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,6 +1,8 @@ export * as ApplyPatchTool from "./apply-patch" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -24,7 +26,10 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export const Output = Schema.Struct({ + applied: Schema.Array(Applied), + files: Schema.Array(FileDiff.Info), +}) export type Output = typeof Output.Type export const toModelOutput = (output: Output) => @@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { readonly target: LocationMutation.Target readonly source: Uint8Array readonly content: string + readonly before: string + readonly after: string }) export const layer = Layer.effectDiscard( @@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard( for (const { hunk, target } of targets) { yield* Effect.gen(function* () { if (hunk.type === "add") { - prepared.push({ ...hunk, target }) + prepared.push({ + ...hunk, + target, + before: "", + after: + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + }) return } if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + const source = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) + const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) + prepared.push({ ...hunk, target, before, after: "" }) return } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) + const update = Patch.derive(hunk.path, hunk.chunks, original) prepared.push({ ...hunk, target, source, content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) }).pipe(Effect.mapError(() => fail(hunk.path))) } + const patchFiles = prepared.map(patchFile) yield* Effect.forEach( prepared, (change) => @@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard( }).pipe(Effect.mapError(() => fail(change.path))), { discard: true }, ) - return { applied } + return { applied, files: patchFiles } }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }, }), @@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +function patchFile(change: Prepared): typeof FileDiff.Info.Type { + const counts = diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file: change.target.resource, + patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a2234..49ef5272882b 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,6 +7,8 @@ export * as EditTool from "./edit" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -35,6 +37,7 @@ export const Output = Schema.Struct({ resource: Schema.String, existed: Schema.Boolean, replacements: Schema.Number, + files: Schema.Array(FileDiff.Info), }) export type Output = typeof Output.Type @@ -179,6 +182,13 @@ export const layer = Layer.effectDiscard( input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ @@ -187,7 +197,18 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Output + return { + ...result, + replacements, + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), + status: "modified", + ...counts, + }, + ], + } satisfies Output }) }, }), diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378edf..9b666f09170f 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => { { type: "update", resource: "update.txt" }, { type: "delete", resource: "remove.txt" }, ], + files: [ + { + file: "nested/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + { + file: "update.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + { + file: "remove.txt", + status: "deleted", + additions: 0, + deletions: 1, + patch: expect.stringContaining("-remove"), + }, + ], }) expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index d8f96a580364..be98cc70f981 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -130,6 +130,15 @@ describe("EditTool", () => { resource: "hello.txt", existed: true, replacements: 1, + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) diff --git a/packages/tui/package.json b/packages/tui/package.json index 3f4b6a2d7d0d..cc3035343790 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -54,6 +54,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 17ff9625fa65..3e946bfb378f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -22,9 +22,13 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { createSignal, onCleanup, onMount } from "solid-js" +import { createGlobalEmitter } from "@solid-primitives/event-bus" export type DataConnectionStatus = "connecting" | "connected" | "reconnecting" +export type DataEvent = V2Event +type DataEventMap = { [T in DataEvent["type"]]: Extract } + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -82,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const sdk = useSDK() + const events = createGlobalEmitter() const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), }) @@ -402,6 +407,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break + case "permission.v2.asked": + if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break + setStore("session", "permission", event.data.sessionID, [ + ...(store.session.permission[event.data.sessionID] ?? []), + event.data, + ]) + break + case "permission.v2.replied": + setStore( + "session", + "permission", + event.data.sessionID, + (store.session.permission[event.data.sessionID] ?? []).filter( + (request) => request.id !== event.data.requestID, + ), + ) + break + case "question.v2.asked": + if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break + setStore("session", "question", event.data.sessionID, [ + ...(store.session.question[event.data.sessionID] ?? []), + event.data, + ]) + break + case "question.v2.replied": + case "question.v2.rejected": + setStore( + "session", + "question", + event.data.sessionID, + (store.session.question[event.data.sessionID] ?? []).filter( + (request) => request.id !== event.data.requestID, + ), + ) + break case "reference.updated": void result.location.reference.refresh() break @@ -413,9 +453,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break } + events.emit(event.type, event) } const result = { + on: events.on, + listen: events.listen, connection: { status() { return store.connection.status @@ -454,14 +497,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } const loaded = await load() const live = new Map((store.session.message[sessionID] ?? []).map((message) => [message.id, message])) - setStore( - "session", - "message", - sessionID, - [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] - .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) - .toSorted((a, b) => b.time.created - a.time.created), - ) + const messages = [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] + .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) + .toSorted((a, b) => b.time.created - a.time.created) + setStore("session", "message", sessionID, messages) }, }, permission: { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7ecb682fbdc8..399c1d713735 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -59,7 +59,6 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { sessionEpilogue } from "../../util/presentation" -import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" @@ -69,11 +68,10 @@ import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" +import { createSessionRows, type PartRef, type SessionRow } from "./rows" addDefaultParsers(parsers.parsers) -export const alwaysSeparate = new WeakSet() - const sessionBindingCommands = [ "session.share", "session.rename", @@ -90,6 +88,7 @@ const sessionBindingCommands = [ "session.toggle.actions", "session.toggle.scrollbar", "session.toggle.generic_tool_output", + "session.toggle.exploration_grouping", "session.first", "session.last", "session.messages_last_user", @@ -125,6 +124,7 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean + groupExploration: () => boolean diffWrapMode: () => "word" | "none" models: () => ModelV2Info[] tui: ReturnType @@ -198,6 +198,7 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -214,13 +215,13 @@ export function Session() { const toast = useToast() const sdk = useSDK() const editor = useEditorContext() + const rows = createSessionRows(() => route.sessionID) createEffect(() => { const sessionID = route.sessionID void (async () => { await Promise.all([ data.session.refresh(sessionID), - data.session.message.refresh(sessionID), data.session.permission.refresh(sessionID), data.session.question.refresh(sessionID), ]) @@ -469,6 +470,15 @@ export function Session() { dialog.clear() }, }, + { + title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools", + value: "session.toggle.exploration_grouping", + category: "Session", + run: () => { + setGroupExploration((prev) => !prev) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -807,6 +817,7 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, + groupExploration, diffWrapMode, models, tui: tuiConfig, @@ -834,31 +845,8 @@ export function Session() { scrollAcceleration={scrollAcceleration()} > - - {(message, index) => { - const reverted = () => session()?.revert?.messageID - return ( - - - item.id >= message.id && item.type === "user", - ).length} - /> - - = reverted()!}> - <> - - - - - - ) - }} + + {(row) => } @@ -929,17 +917,47 @@ export function Session() { ) } -function SessionMessageView(props: { message: SessionMessage; first: boolean; last: boolean }) { +function SessionRowView(props: { row: SessionRow; messages: () => SessionMessage[] }) { + return ( + + + + {(row) => ( + message.id === row().messageID)}> + {(message) => } + + )} + + + {(row) => } + + + {(row) => } + + + {(row) => ( + message.id === row().messageID)}> + {(message) => ( + + + + )} + + )} + + + + ) +} + +function SessionMessageView(props: { message: SessionMessage }) { return ( - - - - + - + {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} @@ -956,6 +974,129 @@ function SessionMessageView(props: { message: SessionMessage; first: boolean; la ) } +function SessionPartView(props: { partRef: PartRef; messages: () => SessionMessage[] }) { + const message = createMemo(() => props.messages().find((message) => message.id === props.partRef.messageID)) + const part = createMemo(() => { + const item = message() + if (item?.type !== "assistant") return + return item.content.find((part) => part.id === props.partRef.partID) + }) + return ( + + {(item) => ( + + + + + + + + + + + + )} + + ) +} + +function SessionGroupView(props: { refs: PartRef[]; completed: boolean; messages: () => SessionMessage[] }) { + const { theme } = useTheme() + const ctx = use() + const renderer = useRenderer() + const [expanded, setExpanded] = createSignal(false) + const parts = createMemo(() => + props.refs.flatMap((ref) => { + const message = props.messages().find((message) => message.id === ref.messageID) + if (message?.type !== "assistant") return [] + const part = message.content.find((part) => part.id === ref.partID) + if (part?.type !== "tool" || part.state.status === "error") return [] + return [part] + }), + ) + const label = createMemo(() => { + const counts = parts().reduce>((result, part) => { + const name = toolDisplay(part.name) + result[name] = (result[name] ?? 0) + 1 + return result + }, {}) + const tools = Object.entries(counts).map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`) + return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` + }) + return ( + 0}> + {(part) => }} + > + { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + }} + > + {label()} + + + {(part) => } + + + + ) +} + +function AssistantFooter(props: { message: SessionMessageAssistant }) { + const ctx = use() + const local = useLocal() + const { theme } = useTheme() + const model = createMemo( + () => + ctx.models().find( + (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, + )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ) + const duration = createMemo(() => + props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, + ) + return ( + <> + + + {errorMessage(props.message.error)} + + + + + + {Locale.titlecase(props.message.agent)} + + · {model()} + + · {Locale.duration(duration())} + + + + + ) +} + function SessionSwitchMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() const text = () => { @@ -980,7 +1121,6 @@ function CompactionMessage() { const { theme } = useTheme() return ( alwaysSeparate.add(element)} onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1005,7 +1144,6 @@ function RevertMessage(props: { count: number }) { toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) dialog.clear() }} - marginTop={1} flexShrink={0} border={["left"]} customBorderChars={SplitBorder.customBorderChars} @@ -1019,10 +1157,7 @@ function RevertMessage(props: { count: number }) { ) } -function UserMessage(props: { - message: SessionMessageUser - index: number -}) { +function UserMessage(props: { message: SessionMessageUser }) { const ctx = use() const local = useLocal() const files = createMemo(() => props.message.files ?? []) @@ -1036,11 +1171,9 @@ function UserMessage(props: { alwaysSeparate.add(el)} border={["left"]} borderColor={color()} customBorderChars={SplitBorder.customBorderChars} - marginTop={props.index === 0 ? 0 : 1} > { @@ -1109,6 +1242,40 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole return props.message.time.completed - props.message.time.created }) + const exploration = createMemo(() => { + const grouped = new Map< + string, + { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean } + >() + if (!ctx.groupExploration()) return grouped + const runs = props.message.content + .map((part) => + part.type === "tool" && + ["read", "glob", "grep"].includes(toolDisplay(part.name)) && + part.state.status !== "pending" && + part.state.status !== "error" + ? part + : undefined, + ) + .reduce( + (runs, part) => { + if (part) runs[runs.length - 1].push(part) + if (!part && runs[runs.length - 1].length) runs.push([]) + return runs + }, + [[]], + ) + .filter((run) => run.length > 0) + for (const run of runs) { + const summary = { + parts: run, + active: run.some((part) => part.state.status !== "completed"), + } + run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) + } + return grouped + }) + return ( <> @@ -1128,19 +1295,24 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole /> - + + } + > + {(summary) => } + + )} alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} backgroundColor={theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} @@ -1150,8 +1322,8 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole - alwaysSeparate.add(el)} paddingLeft={3}> - + + {Locale.titlecase(props.message.agent)} @@ -1167,6 +1339,40 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole ) } +function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) { + const { theme } = useTheme() + const pathFormatter = usePathFormatter() + const label = (part: SessionMessageAssistantTool) => { + const input = typeof part.state.input === "string" ? {} : part.state.input + const tool = toolDisplay(part.name) + if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}` + if (tool === "glob") return `Glob "${stringValue(input.pattern)}"` + return `Grep "${stringValue(input.pattern)}"` + } + return ( + + + {props.active ? "Exploring" : "Explored"} + + + {(part, index) => ( + + + {index() === props.parts.length - 1 ? "└" : "├"} {label(part)} + + + )} + + + ) +} + const INLINE_TOOL_ICON_WIDTH = 2 function ReasoningPart(props: { @@ -1201,9 +1407,7 @@ function ReasoningPart(props: { return ( alwaysSeparate.add(el)} paddingLeft={3} - marginTop={1} flexDirection="column" flexShrink={0} > @@ -1283,7 +1487,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { const { theme, syntax } = useTheme() return ( - alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + { + if ( + ctx.groupExploration() && + props.part.state.status === "error" && + ["read", "glob", "grep"].includes(display()) + ) + return true if (ctx.showDetails()) return false if (props.part.state.status !== "completed") return false return true @@ -1436,7 +1646,6 @@ function InlineTool(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element part: SessionMessageAssistantTool onClick?: () => void @@ -1489,7 +1698,6 @@ function InlineTool(props: { pending={props.pending} failure={props.failure} spinner={props.spinner} - separate={props.separate} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1519,7 +1727,6 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -1531,15 +1738,6 @@ export function InlineToolRow(props: { onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp} - ref={(el: BoxRenderable) => { - if (props.separate) alwaysSeparate.add(el) - setPreLayoutSiblingMargin(el, (previous) => { - return props.separate || - (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous))) - ? 1 - : 0 - }) - }} > @@ -1599,12 +1797,10 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) return ( alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} gap={1} backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} @@ -1640,9 +1836,9 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const pathFormatter = usePathFormatter() const ctx = use() const isRunning = createMemo(() => props.part.state.status === "running") + const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 @@ -1653,47 +1849,22 @@ function Shell(props: ToolProps) { return collapsed().output }) - const workdirDisplay = createMemo(() => { - const workdir = stringValue(props.input.workdir) - if (!workdir || workdir === ".") return undefined - const formatted = pathFormatter.format(workdir) - if (formatted === ".") return undefined - return formatted - }) - - const title = createMemo(() => { - const wd = workdirDisplay() - if (!wd) return - return `# Running in ${wd}` - }) - return ( - - - setExpanded((prev) => !prev) : undefined} - > - - $ {stringValue(props.input.command)}}> - {stringValue(props.input.command)} - - - {limited()} - - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - - - {stringValue(props.input.command)} - - - + setExpanded((prev) => !prev) : undefined}> + + Writing command...}> + $ {command()}}> + {command()} + + + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + ) } @@ -1766,7 +1937,7 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {pathFormatter.format(stringValue(props.input.path))} {input(props.input, ["path"])} + Read {pathFormatter.format(stringValue(props.input.path))} {(filepath) => ( @@ -1819,7 +1990,6 @@ function Task(props: ToolProps) { return ( { + const ctx = use() + const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() + const files = createMemo(() => parseApplyPatchFiles(props.metadata.files)) + const applied = createMemo(() => { const applied = props.metadata.applied if (!Array.isArray(applied)) return [] return applied.flatMap((value) => { @@ -1920,20 +2093,60 @@ function ApplyPatch(props: ToolProps) { return type && resource ? [{ type, resource }] : [] }) }) + const view = createMemo(() => { + if (ctx.tui.diff_style === "stacked") return "unified" + return ctx.width > 120 ? "split" : "unified" + }) return ( 0}> - - {(file) => ( - - {file.resource} - - )} - + + + {(file) => ( + + + + + + )} + + + + 0}> + + + {(file) => ( + + {file.resource} + + )} + + @@ -2117,13 +2330,18 @@ export function parseApplyPatchFiles(value: unknown) { return value.flatMap((item) => { const file = recordValue(item) if (!file) return [] - const type = stringValue(file.type) - const relativePath = stringValue(file.relativePath) - const filePath = stringValue(file.filePath) + const status = stringValue(file.status) + const type = + stringValue(file.type) ?? + (status === "added" ? "add" : status === "deleted" ? "delete" : status === "modified" ? "update" : undefined) + const relativePath = stringValue(file.file) ?? stringValue(file.relativePath) + const filePath = stringValue(file.filePath) ?? relativePath const patch = stringValue(file.patch) + const additions = numberValue(file.additions) const deletions = numberValue(file.deletions) - if (!type || !relativePath || !filePath || patch === undefined || deletions === undefined) return [] - return [{ type, relativePath, filePath, patch, deletions, movePath: stringValue(file.movePath) }] + if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined) + return [] + return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] }) } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 1e20ffac60b7..0c5e9c4beef9 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -264,8 +264,6 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director if (permission === "bash") { const command = typeof data.command === "string" ? data.command : "" return { - icon: "#", - title: "Shell command", body: ( @@ -381,12 +379,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director {"△"} Permission required - - - {current.icon} - - {current.title} - + + + + {current.icon} + + {current.title} + + ) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts new file mode 100644 index 000000000000..86ab41f95d99 --- /dev/null +++ b/packages/tui/src/routes/session/rows.ts @@ -0,0 +1,163 @@ +import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import { createEffect, on, onCleanup, type Accessor } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useData } from "../../context/data" + +export type PartRef = { + messageID: string + partID: string +} + +export type SessionRow = + | { type: "message"; messageID: string } + | { type: "part"; ref: PartRef } + | { type: "group"; kind: "exploration"; refs: PartRef[]; completed: boolean } + | { type: "assistant-footer"; messageID: string } + +export function createSessionRows(sessionID: Accessor) { + const data = useData() + const [rows, setRows] = createStore([]) + + createEffect( + on(sessionID, (id) => { + setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + void data.session.message.refresh(id).then( + () => { + if (sessionID() !== id) return + setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + }, + () => undefined, + ) + }), + ) + + const appendMessage = (messageID: string) => + setRows( + produce((draft) => { + if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return + completePrevious(draft) + draft.push({ type: "message", messageID }) + }), + ) + + const appendPart = (ref: PartRef, name?: string) => + setRows( + produce((draft) => { + if (hasPart(draft, ref)) return + if (name && exploration(name)) { + const previous = draft.at(-1) + if (previous?.type === "group" && previous.kind === "exploration") { + previous.refs.push(ref) + return + } + completePrevious(draft) + draft.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + return + } + completePrevious(draft) + draft.push({ type: "part", ref }) + }), + ) + + const appendFooter = (messageID: string) => + setRows( + produce((draft) => { + if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return + completePrevious(draft) + draft.push({ type: "assistant-footer", messageID }) + }), + ) + + const message = (event: { data: { sessionID: string; messageID: string } }) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID) + } + const subscriptions = [ + data.on("session.next.prompted", message), + data.on("session.next.context.updated", message), + data.on("session.next.synthetic", message), + data.on("session.next.shell.started", message), + data.on("session.next.agent.switched", message), + data.on("session.next.model.switched", message), + data.on("session.next.compaction.ended", message), + data.on("session.next.text.delta", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + }), + data.on("session.next.text.ended", (event) => { + if (event.data.sessionID === sessionID() && event.data.text.trim()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + }), + data.on("session.next.reasoning.delta", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + }), + data.on("session.next.reasoning.ended", (event) => { + if (event.data.sessionID === sessionID() && event.data.text.trim()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + }), + data.on("session.next.tool.input.started", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) + }), + data.on("session.next.step.ended", (event) => { + if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return + appendFooter(event.data.assistantMessageID) + }), + data.on("session.next.step.failed", (event) => { + if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + }), + ] + onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) + + return rows +} + +export function reduceSessionRows(messages: SessionMessage[]) { + return messages.toReversed().reduce((rows, message) => { + if (message.type !== "assistant") { + rows.push({ type: "message", messageID: message.id }) + return rows + } + message.content.forEach((part) => { + if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return + append(rows, { messageID: message.id, partID: part.id }, part) + }) + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) + rows.push({ type: "assistant-footer", messageID: message.id }) + return rows + }, []) +} + +function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { + if (part.type === "tool") { + if (exploration(part.name)) { + const previous = rows.at(-1) + if (previous?.type === "group" && previous.kind === "exploration") { + previous.refs.push(ref) + return + } + completePrevious(rows) + rows.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + return + } + } + completePrevious(rows) + rows.push({ type: "part", ref }) +} + +function completePrevious(rows: SessionRow[]) { + const previous = rows.at(-1) + if (previous?.type === "group") previous.completed = true +} + +function exploration(name: string) { + return ["read", "glob", "grep"].includes(name.toLowerCase()) +} + +function hasPart(rows: SessionRow[], ref: PartRef) { + return rows.some((row) => { + if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID + if (row.type !== "group") return false + return row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) + }) +} diff --git a/packages/tui/src/util/layout.ts b/packages/tui/src/util/layout.ts deleted file mode 100644 index e5e4410a8aa8..000000000000 --- a/packages/tui/src/util/layout.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { BaseRenderable, BoxRenderable } from "@opentui/core" - -const previousByParent = new WeakMap< - BaseRenderable, - { frameID: number; previous: WeakMap } ->() - -export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) { - // Run before Yoga layout so scroll geometry matches the rendered frame. - el.onLifecyclePass = () => { - const parent = el.parent - if (!parent) return - const cached = previousByParent.get(parent) - const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId) - const value = margin(previous.get(el)) - if (el.marginTop !== value) el.marginTop = value - } -} - -function previousSiblings(parent: BaseRenderable, frameID: number) { - const previous = new WeakMap() - parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1])) - previousByParent.set(parent, { frameID, previous }) - return previous -} diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 4b9ec9cccef5..ab31e7cd03fa 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -3,7 +3,6 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = ` " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] @@ -14,12 +13,10 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read row exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = ` " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] No LSP server available for this file type. - ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. Path\\.data|data =" in packages/opencode/src (115 matches)" `; @@ -33,7 +30,6 @@ exports[`TUI inline tool wrapping keeps separation after a shell output block 1` ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] @@ -48,7 +44,6 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1 ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index f6b19bac2091..0bfd45f65b1a 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -309,6 +309,134 @@ test("refreshes references after updates", async () => { } }) +test("adds and dismisses permission requests from live events", async () => { + const events = createEventSource() + const calls = createFetch(undefined, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + emitEvent(events, { + id: "evt_permission_asked_1", + type: "permission.v2.asked", + properties: { + id: "per_1", + sessionID: "ses_1", + action: "bash", + resources: ["bun test"], + }, + }) + emitEvent(events, { + id: "evt_permission_asked_2", + type: "permission.v2.asked", + properties: { + id: "per_2", + sessionID: "ses_1", + action: "read", + resources: [".env"], + }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 2) + + emitEvent(events, { + id: "evt_permission_replied_1", + type: "permission.v2.replied", + properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 1) + expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2") + + emitEvent(events, { + id: "evt_permission_replied_2", + type: "permission.v2.replied", + properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 0) + } finally { + app.renderer.destroy() + } +}) + +test("adds and dismisses question requests from live events", async () => { + const events = createEventSource() + const calls = createFetch(undefined, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + emitEvent(events, { + id: "evt_question_asked_1", + type: "question.v2.asked", + properties: { + id: "que_1", + sessionID: "ses_1", + questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }], + }, + }) + emitEvent(events, { + id: "evt_question_asked_2", + type: "question.v2.asked", + properties: { + id: "que_2", + sessionID: "ses_1", + questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }], + }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 2) + + emitEvent(events, { + id: "evt_question_replied_1", + type: "question.v2.replied", + properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 1) + expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2") + + emitEvent(events, { + id: "evt_question_rejected_2", + type: "question.v2.rejected", + properties: { sessionID: "ses_1", requestID: "que_2" }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 0) + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventSource() const calls = createFetch(undefined, events) @@ -467,6 +595,8 @@ test("renders admitted prompts only after they become model-visible", async () = try { await mounted + const received: string[] = [] + const unsubscribe = sync.listen((event) => received.push(event.name)) emitEvent(events, { id: "evt_admitted_1", type: "session.next.prompt.admitted", @@ -493,10 +623,13 @@ test("renders admitted prompts only after they become model-visible", async () = }) await wait(() => sync.session.message.list("session-1")?.length === 1) + expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"]) + unsubscribe() const message = sync.session.message.list("session-1")?.[0] expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + expect(received).toHaveLength(3) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index e1ad5dc70c68..dee4c406ff64 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { createSignal, For, Show } from "solid-js" -import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" +import { For } from "solid-js" import { testRender, type JSX } from "@opentui/solid" import { formatCompletedSubagentDetail, @@ -13,7 +12,6 @@ import { parseQuestionAnswers, parseQuestions, parseTodos, - alwaysSeparate, toolDisplay, } from "../../../src/routes/session" @@ -52,40 +50,10 @@ const tools: readonly ToolFixture[] = [ }, ] as const -function ShellOutput() { - return ( - alwaysSeparate.add(el)} - marginTop={1} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - gap={1} - > - - $ ls - file.ts - - - ) -} - -function UserMessage() { - return ( - alwaysSeparate.add(el)}> - - Check whether the next tool remains separated. - - - ) -} - -function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) { +function Fixture(props: { errorExpanded?: boolean }) { return ( - {props.before === "shell" && } - {props.before === "user" && } {(item) => ( - - Grep "Task" (2 matches) - - - Explore Task — Inspect active task spacing - - - {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ) -} - -function LoadedReadBeforeTaskFixture() { - return ( - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - - - {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} - - - ) -} - -function AssistantSummaryBeforeInlineFixture() { - return ( - - alwaysSeparate.add(el)} paddingLeft={3}> - Build · Little Frank · 53.1s - - - {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} - - - ) -} - -function AssistantErrorBeforeInlineFixture() { - return ( - - alwaysSeparate.add(el)} - border={["left"]} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - > - Managed inference requires an active Member plan - - - {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} - - - ) -} - -function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) { - return ( - - - First row - - - Second row - - - alwaysSeparate.add(el)}> - Assistant text - - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ) -} - function FailedPendingToolFixture() { return ( @@ -245,10 +125,18 @@ describe("TUI inline tool wrapping", () => { parseApplyPatchFiles([ null, { type: "add" }, - { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 }, + { file: "a.ts", patch: "diff", additions: 1, deletions: 0, status: "added" }, ]), ).toEqual([ - { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined }, + { + type: "add", + relativePath: "a.ts", + filePath: "a.ts", + patch: "diff", + additions: 1, + deletions: 0, + movePath: undefined, + }, ]) expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([ { status: "pending", content: "Safe" }, @@ -299,53 +187,4 @@ describe("TUI inline tool wrapping", () => { expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() }) - test("keeps separation after a shell output block", async () => { - expect(await renderFrame(() => , { width: 72, height: 16 })).toMatchSnapshot() - }) - - test("keeps separation after a padded user message", async () => { - expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() - }) - - test("separates after a multi-line task row", async () => { - expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() - }) - - test("separates a task row from a preceding inline detail", async () => { - expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() - }) - - test("separates an inline row from the previous assistant summary", async () => { - expect(await renderFrame(() => , { width: 72, height: 5 })).toMatchSnapshot() - }) - - test("separates an inline row from the previous assistant error", async () => { - expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() - }) - - test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => { - const [separated, setSeparated] = createSignal(false) - let scroll: ScrollBoxRenderable | undefined - testSetup = await testRender( - () => (scroll = value)} />, - { - width: 72, - height: 3, - }, - ) - - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(3) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - - setSeparated(true) - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(5) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - - setSeparated(false) - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(3) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - }) }) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts new file mode 100644 index 000000000000..7f45943ea94a --- /dev/null +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test" +import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import { reduceSessionRows } from "../../../src/routes/session/rows" + +test("groups exploration parts across assistant messages until a delimiter", () => { + const messages: SessionMessage[] = [ + assistant("assistant-2", [ + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, + { type: "text", id: "text-2", text: "Done" }, + ]), + assistant("assistant-1", [ + { type: "text", id: "text-1", text: "Looking" }, + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, + { type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } }, + ]), + { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "message", messageID: "user-1" }, + { type: "part", ref: { messageID: "assistant-1", partID: "text-1" } }, + { + type: "group", + kind: "exploration", + completed: true, + refs: [ + { messageID: "assistant-1", partID: "read-1" }, + { messageID: "assistant-1", partID: "glob-1" }, + { messageID: "assistant-2", partID: "grep-1" }, + ], + }, + { type: "part", ref: { messageID: "assistant-2", partID: "text-2" } }, + ]) +}) + +test("keeps non-exploration tools as individual part rows", () => { + const messages: SessionMessage[] = [ + assistant("assistant-1", [ + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, + { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "exploration", + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } }, + { + type: "group", + kind: "exploration", + completed: false, + refs: [{ messageID: "assistant-1", partID: "grep-1" }], + }, + ]) +}) + +test("groups across empty assistant reasoning parts", () => { + const messages: SessionMessage[] = [ + assistant("assistant-2", [ + { type: "reasoning", id: "reasoning-2", text: "" }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]), + assistant("assistant-1", [ + { type: "reasoning", id: "reasoning-1", text: "Looking" }, + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } }, + { + type: "group", + kind: "exploration", + completed: false, + refs: [ + { messageID: "assistant-1", partID: "read-1" }, + { messageID: "assistant-2", partID: "grep-1" }, + ], + }, + ]) +}) + +function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + type: "assistant", + id, + agent: "build", + model: { id: "model", providerID: "provider" }, + content, + time: { created: 1 }, + } +} + +function pending() { + return { status: "pending" as const, input: "" } +} From c336a9a3ef0e5aedf1ad3a46baa11de6d252b401 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 25 Jun 2026 23:56:25 -0400 Subject: [PATCH 10/10] fix(tui): refine v2 session tool rendering --- packages/client/src/generated/types.ts | 2 + packages/core/src/session/message-updater.ts | 2 + packages/core/src/tool/edit.ts | 13 +- packages/core/test/tool-edit.test.ts | 4 - packages/schema/src/session-message.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/tui/src/context/data.tsx | 159 +++++++----- .../tui/src/routes/session/dialog-message.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 242 +++++++++++------- .../tui/src/routes/session/permission.tsx | 42 ++- packages/tui/src/routes/session/rows.ts | 46 +++- packages/tui/test/cli/tui/data.test.tsx | 25 +- .../tui/test/cli/tui/session-rows.test.ts | 22 +- 13 files changed, 362 insertions(+), 205 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 858756e2a400..e2c62df15995 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -512,6 +512,7 @@ export type SessionsContextOutput = { readonly id: string readonly text: string readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" @@ -1129,6 +1130,7 @@ export type SessionsMessageOutput = { readonly id: string readonly text: string readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a97afb58b6da..46118a89fe4b 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, + time: { created: event.data.timestamp }, }), ), ) @@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text + match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 49ef5272882b..fb5af1870697 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -32,12 +32,8 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Struct({ - operation: Schema.Literal("write"), - target: Schema.String, - resource: Schema.String, - existed: Schema.Boolean, - replacements: Schema.Number, files: Schema.Array(FileDiff.Info), + replacements: Schema.Number, }) export type Output = typeof Output.Type @@ -74,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => { export const toModelOutput = (output: Output, oldString: string, newString: string) => [ - `Edited file successfully: ${output.resource}`, + `Edited file successfully: ${output.files[0]?.file}`, `Replacements: ${output.replacements}`, "```diff", ...previewLines(oldString, "-"), @@ -198,16 +194,15 @@ export const layer = Layer.effectDiscard( }), ) return { - ...result, - replacements, files: [ { file: result.resource, patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), - status: "modified", + status: "modified" as const, ...counts, }, ], + replacements, } satisfies Output }) }, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index be98cc70f981..f95cd1c923ae 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -125,10 +125,6 @@ describe("EditTool", () => { value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", }) expect(settled.output?.structured).toEqual({ - operation: "write", - target: yield* Effect.promise(() => fs.realpath(target)), - resource: "hello.txt", - existed: true, replacements: 1, files: [ { diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index f32bb0582e69..58ff532063a0 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({ id: Schema.String, text: Schema.String, providerMetadata: ProviderMetadata.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }).pipe(optional), }).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9fde0f4e5e24..8e17a595a543 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3909,6 +3909,10 @@ export type SessionMessageAssistantReasoning = { id: string text: string providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number + } } export type SessionMessageToolStatePending = { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 3e946bfb378f..6b64b2c2cf85 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -90,31 +90,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), }) + const messageIndex = new Map>() const message = { - update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { setStore( "session", "message", produce((draft) => { - fn((draft[sessionID] ??= [])) + fn((draft[sessionID] ??= []), index(sessionID)) }), ) }, - prepend(messages: SessionMessage[], item: SessionMessage) { - if (messages.some((existing) => existing.id === item.id)) return - messages.unshift(item) + append(messages: SessionMessage[], index: Map, item: SessionMessage) { + if (index.has(item.id)) return + index.set(item.id, messages.length) + messages.push(item) }, activeAssistant(messages: SessionMessage[]) { - const item = messages.find((item) => item.type === "assistant" && !item.time.completed) + const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessage[], messageID: string) { - const item = messages.find((item) => item.type === "assistant" && item.id === messageID) + assistant(messages: SessionMessage[], index: Map, messageID: string) { + const position = index.get(messageID) + const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, activeShell(messages: SessionMessage[], callID: string) { - const item = messages.find((item) => item.type === "shell" && item.callID === callID) + const item = messages.findLast((item) => item.type === "shell" && item.callID === callID) return item?.type === "shell" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -135,6 +138,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, } + function index(sessionID: string) { + const existing = messageIndex.get(sessionID) + if (existing) return existing + const created = new Map() + messageIndex.set(sessionID, created) + return created + } + function handleEvent(event: V2Event) { switch (event.type) { case "catalog.updated": @@ -146,8 +157,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.agent.switched": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "agent", event.data.agent) - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "agent-switched", agent: event.data.agent, @@ -158,8 +169,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.model.switched": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "model", event.data.model) - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "model-switched", model: event.data.model, @@ -168,8 +179,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.prompted": { - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "user", text: event.data.prompt.text, @@ -183,8 +194,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.prompt.admitted": break case "session.next.context.updated": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "system", text: event.data.text, @@ -193,8 +204,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.synthetic": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "synthetic", sessionID: event.data.sessionID, @@ -204,8 +215,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.shell.started": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "shell", callID: event.data.callID, @@ -216,7 +227,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.shell.ended": - message.update(event.data.sessionID, (draft) => { + message.update(event.data.sessionID, (draft, index) => { const match = message.activeShell(draft, event.data.callID) if (!match) return match.output = event.data.output @@ -224,11 +235,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.started": - message.update(event.data.sessionID, (draft) => { - if (draft.some((message) => message.id === event.data.assistantMessageID)) return + message.update(event.data.sessionID, (draft, index) => { + if (index.has(event.data.assistantMessageID)) return const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.data.timestamp - message.prepend(draft, { + message.append(draft, index, { id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, @@ -240,8 +251,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.ended": - message.update(event.data.sessionID, (draft) => { - const currentAssistant = message.assistant(draft, event.data.assistantMessageID) + message.update(event.data.sessionID, (draft, index) => { + const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = event.data.finish @@ -252,8 +263,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.step.failed": - message.update(event.data.sessionID, (draft) => { - const currentAssistant = message.assistant(draft, event.data.assistantMessageID) + message.update(event.data.sessionID, (draft, index) => { + const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = "error" @@ -261,8 +272,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.text.started": - message.update(event.data.sessionID, (draft) => { - message.assistant(draft, event.data.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft, index) => { + message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "text", id: event.data.textID, text: "", @@ -270,20 +281,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.text.delta": - message.update(event.data.sessionID, (draft) => { - const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) if (match) match.text += event.data.delta }) break case "session.next.text.ended": - message.update(event.data.sessionID, (draft) => { - const match = message.latestText(message.assistant(draft, event.data.assistantMessageID), event.data.textID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) if (match) match.text = event.data.text }) break case "session.next.tool.input.started": - message.update(event.data.sessionID, (draft) => { - message.assistant(draft, event.data.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft, index) => { + message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "tool", id: event.data.callID, name: event.data.name, @@ -293,20 +304,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.tool.input.delta": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.next.tool.input.ended": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.next.tool.called": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (!match) return match.time.ran = event.data.timestamp match.provider = event.data.provider @@ -314,16 +325,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.tool.progress": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (match?.state.status !== "running") return match.state.structured = event.data.structured match.state.content = [...event.data.content] }) break case "session.next.tool.success": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (match?.state.status !== "running") return match.state = { status: "completed", @@ -341,8 +352,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.tool.failed": - message.update(event.data.sessionID, (draft) => { - const match = message.latestTool(message.assistant(draft, event.data.assistantMessageID), event.data.callID) + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", @@ -361,32 +372,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.next.reasoning.started": - message.update(event.data.sessionID, (draft) => { - message.assistant(draft, event.data.assistantMessageID)?.content.push({ + message.update(event.data.sessionID, (draft, index) => { + message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ type: "reasoning", id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, + time: { created: event.data.timestamp }, }) }) break case "session.next.reasoning.delta": - message.update(event.data.sessionID, (draft) => { + message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( - message.assistant(draft, event.data.assistantMessageID), + message.assistant(draft, index, event.data.assistantMessageID), event.data.reasoningID, ) if (match) match.text += event.data.delta }) break case "session.next.reasoning.ended": - message.update(event.data.sessionID, (draft) => { + message.update(event.data.sessionID, (draft, index) => { const match = message.latestReasoning( - message.assistant(draft, event.data.assistantMessageID), + message.assistant(draft, index, event.data.assistantMessageID), event.data.reasoningID, ) if (match) { match.text = event.data.text + match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) @@ -396,8 +409,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.compaction.delta": break case "session.next.compaction.ended": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { id: event.data.messageID, type: "compaction", reason: event.data.reason, @@ -482,25 +495,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", sessionID, result.data.data) }, message: { + ids(sessionID: string) { + return (store.session.message[sessionID] ?? []).map((message) => message.id) + }, list(sessionID: string) { - return store.session.message[sessionID] + return store.session.message[sessionID] ?? [] + }, + get(sessionID: string, messageID: string) { + const position = messageIndex.get(sessionID)?.get(messageID) + return position === undefined ? undefined : store.session.message[sessionID]?.[position] }, async refresh(sessionID: string) { setStore("session", "message", sessionID, []) - const load = async (cursor?: string, messages: SessionMessage[] = []): Promise => { - const result = await sdk.client.v2.session.messages( - cursor ? { sessionID, limit: 200, cursor } : { sessionID, limit: 200, order: "desc" }, - { throwOnError: true }, - ) - const next = [...messages, ...result.data.data] - return result.data.cursor.next ? load(result.data.cursor.next, next) : next - } - const loaded = await load() - const live = new Map((store.session.message[sessionID] ?? []).map((message) => [message.id, message])) - const messages = [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] + messageIndex.set(sessionID, new Map()) + const response = await sdk.client.v2.session.messages( + { sessionID, limit: 200, order: "desc" }, + { throwOnError: true }, + ) + const loaded = response.data.data.toReversed() + const live = store.session.message[sessionID] ?? [] + const liveByID = new Map(live.map((message) => [message.id, message])) + const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live] .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) - .toSorted((a, b) => b.time.created - a.time.created) + .toSorted((a, b) => a.time.created - b.time.created) setStore("session", "message", sessionID, messages) + messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) }, }, permission: { diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b1216b977538..2f5e4c4ee230 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -9,7 +9,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set const clipboard = useClipboard() const toast = useToast() const message = createMemo(() => - data.session.message.list(props.sessionID)?.find((message) => message.id === props.messageID), + data.session.message.get(props.sessionID, props.messageID), ) return ( diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 97f067de70aa..fb3d757b260a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -154,7 +154,11 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) - const sessionMessages = createMemo(() => (data.session.message.list(route.sessionID) ?? []).toReversed()) + const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) + const sessionMessages = () => messageIDs().flatMap((id) => { + const message = data.session.message.get(route.sessionID, id) + return message ? [message] : [] + }) const location = createMemo(() => session()?.location) createEffect(() => { @@ -838,7 +842,12 @@ export function Session() { > - {(row) => } + {(row) => ( + data.session.message.get(route.sessionID, messageID)} + /> + )} @@ -909,26 +918,33 @@ export function Session() { ) } -function SessionRowView(props: { row: SessionRow; messages: () => SessionMessage[] }) { +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) { return ( {(row) => ( - message.id === row().messageID)}> + {(message) => } )} - {(row) => } + {(row) => } - {(row) => } + {(row) => ( + + )} {(row) => ( - message.id === row().messageID)}> + {(message) => ( @@ -966,8 +982,8 @@ function SessionMessageView(props: { message: SessionMessage }) { ) } -function SessionPartView(props: { partRef: PartRef; messages: () => SessionMessage[] }) { - const message = createMemo(() => props.messages().find((message) => message.id === props.partRef.messageID)) +function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) { + const message = createMemo(() => props.message(props.partRef.messageID)) const part = createMemo(() => { const item = message() if (item?.type !== "assistant") return @@ -996,51 +1012,67 @@ function SessionPartView(props: { partRef: PartRef; messages: () => SessionMessa ) } -function SessionGroupView(props: { refs: PartRef[]; completed: boolean; messages: () => SessionMessage[] }) { +function SessionGroupView(props: { + refs: PartRef[] + pending: PartRef[] + completed: boolean + message: (messageID: string) => SessionMessage | undefined +}) { const { theme } = useTheme() const ctx = use() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) - const parts = createMemo(() => - props.refs.flatMap((ref) => { - const message = props.messages().find((message) => message.id === ref.messageID) + const [hover, setHover] = createSignal(false) + const parts = (refs: PartRef[]) => + refs.flatMap((ref) => { + const message = props.message(ref.messageID) if (message?.type !== "assistant") return [] const part = message.content.find((part) => part.id === ref.partID) - if (part?.type !== "tool" || part.state.status === "error") return [] + if (part?.type !== "tool") return [] return [part] - }), - ) + }) + const grouped = createMemo(() => parts(props.refs)) + const pending = createMemo(() => parts(props.pending)) + const failed = createMemo(() => grouped().some((part) => part.state.status === "error")) const label = createMemo(() => { - const counts = parts().reduce>((result, part) => { - const name = toolDisplay(part.name) + const counts = grouped().reduce>((result, part) => { + const tool = toolDisplay(part.name) + const name = tool === "grep" || tool === "glob" ? "search" : tool result[name] = (result[name] ?? 0) + 1 return result }, {}) - const tools = Object.entries(counts).map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`) + const tools = Object.entries(counts).map(([name, count]) => + `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, + ) return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` }) return ( - 0}> + 0 || pending().length > 0}> {(part) => }} + fallback={{(part) => }} > - { - if (renderer.getSelection()?.getSelectedText()) return - setExpanded((value) => !value) - }} - > - {label()} - - - {(part) => } + 0}> + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + }} + > + {label()} + + + 0}> + {(part) => } + {(part) => } ) @@ -1244,8 +1276,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole .map((part) => part.type === "tool" && ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "pending" && - part.state.status !== "error" + part.state.status !== "pending" ? part : undefined, ) @@ -1261,7 +1292,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole for (const run of runs) { const summary = { parts: run, - active: run.some((part) => part.state.status !== "completed"), + active: false, } run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) } @@ -1355,7 +1386,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ {(part, index) => ( - + {index() === props.parts.length - 1 ? "└" : "├"} {label(part)} @@ -1382,11 +1413,12 @@ function ReasoningPart(props: { // OpenRouter encrypts some reasoning blocks; drop the placeholder. return props.part.text.replace("[REDACTED]", "").trim() }) - const isDone = createMemo(() => props.message.time.completed !== undefined) + const isDone = createMemo(() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const duration = createMemo(() => { - const end = props.message.time.completed - return end === undefined ? 0 : Math.max(0, end - props.message.time.created) + const end = props.part.time?.completed ?? props.message.time.completed + const start = props.part.time?.created ?? props.message.time.created + return end === undefined ? 0 : Math.max(0, end - start) }) const summary = createMemo(() => reasoningSummary(content())) const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) @@ -1503,12 +1535,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { // Hide tool if showDetails is false and tool completed successfully const shouldHide = createMemo(() => { - if ( - ctx.groupExploration() && - props.part.state.status === "error" && - ["read", "glob", "grep"].includes(display()) - ) - return true if (ctx.showDetails()) return false if (props.part.state.status !== "completed") return false return true @@ -1650,9 +1676,8 @@ function InlineTool(props: { const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { - const callID = data.session.permission.list(ctx.sessionID)?.at(0)?.source?.callID - if (!callID) return false - return callID === props.part.id + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id }) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) @@ -1778,15 +1803,22 @@ export function InlineToolRow(props: { function BlockTool(props: { title?: string - children: JSX.Element + children?: JSX.Element onClick?: () => void part?: SessionMessageAssistantTool spinner?: boolean }) { const { theme } = useTheme() + const ctx = use() + const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + const permission = createMemo(() => { + if (!props.part) return false + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id + }) return ( + {title()} } > - {title().replace(/^# /, "")} + {title().replace(/^# /, "")} )} @@ -1829,6 +1861,12 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() const ctx = use() + const data = useData() + const permission = createMemo(() => { + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === props.part.id + }) + const color = createMemo(() => (permission() ? theme.warning : theme.text)) const isRunning = createMemo(() => props.part.state.status === "running") const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? "")) @@ -1844,9 +1882,9 @@ function Shell(props: ToolProps) { return ( setExpanded((prev) => !prev) : undefined}> - Writing command...}> - $ {command()}}> - {command()} + Writing command...}> + $ {command()}}> + {command()} @@ -2029,42 +2067,49 @@ function Edit(props: ToolProps) { return ctx.width > 120 ? "split" : "unified" }) - const ft = createMemo(() => filetype(stringValue(props.input.path))) - - const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "") + const file = createMemo(() => parseApplyPatchFiles(props.metadata.files)[0]) + const path = createMemo(() => file()?.relativePath ?? stringValue(props.input.path)) return ( - - - - - - - + + {(item) => ( + + + + + + + )} - - Edit {pathFormatter.format(stringValue(props.input.path))} {input({ replaceAll: props.input.replaceAll })} - + ) @@ -2075,6 +2120,11 @@ function ApplyPatch(props: ToolProps) { const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() const files = createMemo(() => parseApplyPatchFiles(props.metadata.files)) + const targets = createMemo(() => { + const patch = stringValue(props.input.patchText) + if (!patch) return [] + return [...patch.matchAll(/\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)/g)].map((match) => match[1].trim()) + }) const applied = createMemo(() => { const applied = props.metadata.applied if (!Array.isArray(applied)) return [] @@ -2141,9 +2191,17 @@ function ApplyPatch(props: ToolProps) { - - Patch - + ) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 0c5e9c4beef9..159691a6d1c3 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -18,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function EditBody(props: { request: PermissionV2Request }) { +function EditBody(props: { request: PermissionV2Request; patch?: string }) { const themeState = useTheme() const theme = themeState.theme const syntax = themeState.syntax @@ -77,9 +77,36 @@ function EditBody(props: { request: PermissionV2Request }) { - - No diff provided - + + No diff provided + + } + > + {(patch) => ( + + + + )} + ) @@ -118,9 +145,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const input = createMemo(() => { const tool = props.request.source if (!tool) return {} - const message = data.session - .message.list(props.request.sessionID) - ?.find((message) => message.type === "assistant" && message.id === tool.messageID) + const message = data.session.message.get(props.request.sessionID, tool.messageID) if (message?.type !== "assistant") return {} const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) if (part?.type === "tool" && part.state.status !== "pending") return part.state.input @@ -192,10 +217,11 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director if (permission === "edit") { const filepath = props.request.resources[0] ?? "" + const patch = typeof data.patchText === "string" ? data.patchText : undefined return { icon: "→", title: `Edit ${pathFormatter.format(filepath)}`, - body: , + body: , } } diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 86ab41f95d99..d4ac60ac3efa 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -11,20 +11,44 @@ export type PartRef = { export type SessionRow = | { type: "message"; messageID: string } | { type: "part"; ref: PartRef } - | { type: "group"; kind: "exploration"; refs: PartRef[]; completed: boolean } + | { + type: "group" + kind: "exploration" + refs: PartRef[] + pending: PartRef[] + completed: boolean + } | { type: "assistant-footer"; messageID: string } export function createSessionRows(sessionID: Accessor) { const data = useData() const [rows, setRows] = createStore([]) + createEffect(() => { + const pending = new Set( + (data.session.permission.list(sessionID()) ?? []).flatMap((request) => + request.source?.type === "tool" ? [request.source.callID] : [], + ), + ) + setRows( + produce((draft) => { + draft.forEach((row) => { + if (row.type !== "group") return + const refs = [...row.refs, ...row.pending] + row.refs = refs.filter((ref) => !pending.has(ref.partID)) + row.pending = refs.filter((ref) => pending.has(ref.partID)) + }) + }), + ) + }) + createEffect( on(sessionID, (id) => { - setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + setRows(reconcile(reduceSessionRows(data.session.message.list(id)))) void data.session.message.refresh(id).then( () => { if (sessionID() !== id) return - setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + setRows(reconcile(reduceSessionRows(data.session.message.list(id)))) }, () => undefined, ) @@ -51,7 +75,13 @@ export function createSessionRows(sessionID: Accessor) { return } completePrevious(draft) - draft.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + draft.push({ + type: "group", + kind: "exploration", + refs: [ref], + pending: [], + completed: false, + }) return } completePrevious(draft) @@ -113,7 +143,7 @@ export function createSessionRows(sessionID: Accessor) { } export function reduceSessionRows(messages: SessionMessage[]) { - return messages.toReversed().reduce((rows, message) => { + return messages.reduce((rows, message) => { if (message.type !== "assistant") { rows.push({ type: "message", messageID: message.id }) return rows @@ -137,7 +167,7 @@ function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant[ return } completePrevious(rows) - rows.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) return } } @@ -158,6 +188,8 @@ function hasPart(rows: SessionRow[], ref: PartRef) { return rows.some((row) => { if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID if (row.type !== "group") return false - return row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) + return [...row.refs, ...row.pending].some( + (item) => item.messageID === ref.messageID && item.partID === ref.partID, + ) }) } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0bfd45f65b1a..0a60ec8d055e 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -44,6 +44,14 @@ test("refreshes resources into reactive getters", async () => { location: { directory }, }, }) + if (url.pathname === "/api/session/ses_test/message") + return json({ + data: [ + { id: "msg_second", type: "user", text: "Second", time: { created: 2 } }, + { id: "msg_first", type: "user", text: "First", time: { created: 1 } }, + ], + cursor: {}, + }) if (url.pathname === "/api/agent") return json({ location, @@ -82,9 +90,12 @@ test("refreshes resources into reactive getters", async () => { expect(data.location.agent.list(location)).toBeUndefined() await data.session.refresh("ses_test") + await data.session.message.refresh("ses_test") await data.location.agent.refresh() expect(data.session.get("ses_test")?.title).toBe("Test session") + expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"]) + expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second") expect(data.location.default()).toEqual({ directory, workspaceID: undefined }) expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"]) } finally { @@ -530,7 +541,7 @@ test("settles pending tools when a live failure arrives", async () => { }) await wait(() => { - const assistant = sync.session.message.list("session-1")?.[0] + const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") return ( assistant?.type === "assistant" && assistant.content[0]?.type === "tool" && @@ -538,7 +549,7 @@ test("settles pending tools when a live failure arrives", async () => { ) }) - const assistant = sync.session.message.list("session-1")?.[0] + const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") expect(assistant?.type).toBe("assistant") if (assistant?.type !== "assistant") return expect(assistant.id).toBe("msg_explicit_assistant_9") @@ -556,10 +567,10 @@ test("settles pending tools when a live failure arrives", async () => { metadata: { fake: { call: true } }, resultMetadata: { fake: { result: true } }, }) - expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([ - "assistant", - "model-switched", + expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([ "agent-switched", + "model-switched", + "assistant", ]) } finally { app.renderer.destroy() @@ -629,6 +640,10 @@ test("renders admitted prompts only after they become model-visible", async () = expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"]) + expect(sync.session.message.ids("missing")).toEqual([]) + expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message) + expect(sync.session.message.get("session-1", "missing")).toBeUndefined() expect(received).toHaveLength(3) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 7f45943ea94a..d2aa901f30f2 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -4,16 +4,16 @@ import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { const messages: SessionMessage[] = [ - assistant("assistant-2", [ - { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, - { type: "text", id: "text-2", text: "Done" }, - ]), + { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, assistant("assistant-1", [ { type: "text", id: "text-1", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, { type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } }, ]), - { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, + assistant("assistant-2", [ + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, + { type: "text", id: "text-2", text: "Done" }, + ]), ] expect(reduceSessionRows(messages)).toEqual([ @@ -22,6 +22,7 @@ test("groups exploration parts across assistant messages until a delimiter", () { type: "group", kind: "exploration", + pending: [], completed: true, refs: [ { messageID: "assistant-1", partID: "read-1" }, @@ -46,6 +47,7 @@ test("keeps non-exploration tools as individual part rows", () => { { type: "group", kind: "exploration", + pending: [], completed: true, refs: [{ messageID: "assistant-1", partID: "read-1" }], }, @@ -53,6 +55,7 @@ test("keeps non-exploration tools as individual part rows", () => { { type: "group", kind: "exploration", + pending: [], completed: false, refs: [{ messageID: "assistant-1", partID: "grep-1" }], }, @@ -61,14 +64,14 @@ test("keeps non-exploration tools as individual part rows", () => { test("groups across empty assistant reasoning parts", () => { const messages: SessionMessage[] = [ - assistant("assistant-2", [ - { type: "reasoning", id: "reasoning-2", text: "" }, - { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, - ]), assistant("assistant-1", [ { type: "reasoning", id: "reasoning-1", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, ]), + assistant("assistant-2", [ + { type: "reasoning", id: "reasoning-2", text: "" }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]), ] expect(reduceSessionRows(messages)).toEqual([ @@ -76,6 +79,7 @@ test("groups across empty assistant reasoning parts", () => { { type: "group", kind: "exploration", + pending: [], completed: false, refs: [ { messageID: "assistant-1", partID: "read-1" },