Compare commits

...

9 Commits

Author SHA1 Message Date
Kit Langton 32e2e3f2f7 feat(tui): surface plugin failures 2026-08-12 02:43:52 +00:00
Kit Langton caae28e0d4 fix(tui): render instruction updates as compact notices (#41900) 2026-08-11 22:21:24 -04:00
Kit Langton c83933d1d4 fix(core): gate tool snapshot on initial MCP registration (#41884) 2026-08-11 22:21:21 -04:00
Kit Langton c86f1c41ff fix(tui): show completed write output (#41883) 2026-08-11 22:20:55 -04:00
Kit Langton 5c0cc8e617 fix(tui): align running shell output (#41880) 2026-08-11 22:20:52 -04:00
Kit Langton 1b45061afb feat(tui): experiments via devtools bar, drafts stay put (#41917) 2026-08-12 02:08:06 +00:00
opencode-agent[bot] 07bcd290c2 chore: generate 2026-08-12 02:06:08 +00:00
Luke Parker 04ad06e2e3 fix(desktop): align local development identity (#41889) 2026-08-12 12:04:57 +10:00
Dax 93965df860 feat(session): record location switches (#41899) 2026-08-11 19:03:57 -07:00
40 changed files with 743 additions and 203 deletions
+1 -1
View File
@@ -658,7 +658,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
return (
<>
{["beta", "dev"].includes(channel) && (
{["local", "beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
@@ -65,6 +65,7 @@ export type SessionMessageSystem = {
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill = {
@@ -408,6 +409,17 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type SessionMessageLocationSwitched = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionCreated = {
id: string
created: number
@@ -1943,6 +1955,7 @@ export type SessionInputAdmitted = {
export type SessionMessageInfo =
| SessionMessageAgentSelected
| SessionMessageModelSelected
| SessionMessageLocationSwitched
| SessionMessageUser
| SessionMessageSynthetic
| SessionMessageSystem
@@ -2546,6 +2559,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -2585,6 +2612,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
@@ -2798,6 +2826,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -2837,6 +2879,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
@@ -3050,6 +3093,20 @@ export type SessionImportInput = {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "location-switched"
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
readonly previous?: {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly projectID?: string
readonly subpath?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -3089,6 +3146,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
readonly description?: string
}
| {
readonly id: string
+2
View File
@@ -136,6 +136,8 @@ const serialize = (message: SessionMessage.Info) => {
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
if (message.type === "assistant") {
return message.content
.flatMap((part) => {
+4
View File
@@ -10,6 +10,7 @@ import { Instructions } from "../instructions/index.js"
import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
@@ -64,6 +65,7 @@ const layer = Layer.effect(
const entries = yield* InstructionEntry.Service
const location = yield* Location.Service
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
@@ -78,6 +80,7 @@ const layer = Layer.effect(
return yield* Effect.interrupt
yield* plugins.flush
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
@@ -136,6 +139,7 @@ export const node = makeLocationNode({
InstructionEntry.node,
Location.node,
McpInstructions.node,
McpTool.node,
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
+18 -1
View File
@@ -6,6 +6,7 @@ import { SessionMessage } from "./message.js"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
@@ -89,7 +90,22 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.moved": () => Effect.void,
"session.moved": (event) => {
return Effect.gen(function* () {
yield* adapter.appendMessage(
SessionMessage.LocationSwitched.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "location-switched",
metadata: event.metadata,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: yield* adapter.getLocation(),
time: { created: event.created },
}),
)
})
},
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
@@ -109,6 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
}),
+29
View File
@@ -16,6 +16,7 @@ import { InstructionState } from "./instruction-state.js"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { Money } from "@opencode-ai/schema/money"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
type DatabaseService = Database.Interface["db"]
@@ -253,6 +254,33 @@ function run(db: DatabaseService, event: MessageEvent) {
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(Model.Ref)(row.model) : undefined)),
)
},
getLocation() {
return db
.select({
directory: SessionTable.directory,
workspaceID: SessionTable.workspace_id,
projectID: SessionTable.project_id,
subpath: SessionTable.path,
})
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) =>
row
? {
location: {
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
},
projectID: row.projectID,
subpath: row.subpath === null ? undefined : RelativePath.make(row.subpath),
}
: undefined,
),
)
},
getCurrentAssistant() {
return Effect.gen(function* () {
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
@@ -391,6 +419,7 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.Moved, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({
@@ -201,6 +201,15 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "agent-switched":
case "model-switched":
return []
case "location-switched":
return [
Message.make({
id: message.id,
role: "user",
content: `The working directory has been changed to ${message.location.directory}.`,
metadata: message.metadata,
}),
]
case "user":
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
+13 -4
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -16,7 +16,15 @@ import { Tool } from "../tool.js"
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
export interface Interface {
/** Wait for the initial MCP tool registration to settle. */
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tool.Service
@@ -113,16 +121,17 @@ export const layer = Layer.effectDiscard(
}),
)
yield* reconcile.pipe(Effect.forkScoped)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
}),
)
export const node = makeLocationNode({
name: "mcp-tools",
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
})
+17
View File
@@ -50,6 +50,23 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
subpath: "",
}),
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
@@ -8,6 +8,8 @@ import { Skill } from "@opencode-ai/schema/skill"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { Agent } from "@opencode-ai/core/agent"
import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@@ -67,6 +69,15 @@ describe("toLLMMessages", () => {
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
time: { created },
}),
SessionMessage.LocationSwitched.make({
id: id("location"),
type: "location-switched",
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
previous: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
},
time: { created },
}),
SessionMessage.System.make({
id: id("system"),
type: "system",
@@ -110,9 +121,16 @@ describe("toLLMMessages", () => {
model,
)
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[1]).toEqual(
expect(messages.map((message) => message.role)).toEqual(["user", "system", "user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: id("location"),
role: "user",
content: "The working directory has been changed to /destination.",
}),
)
expect(messages[1]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[2]).toEqual(
Message.make({
id: id("user"),
role: "user",
@@ -123,7 +141,7 @@ describe("toLLMMessages", () => {
metadata: { agents: [{ name: "build" }] },
}),
)
expect(messages.slice(2).map((message) => message.content)).toEqual([
expect(messages.slice(3).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[
{
+1
View File
@@ -134,6 +134,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.LocationSwitched, SessionMessage.LocationSwitched],
[coreSessionMessage.User, SessionMessage.User],
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],
+5 -1
View File
@@ -4,7 +4,7 @@ import appPlugin from "@opencode-ai/app/vite"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
@@ -72,6 +72,10 @@ const require = __cjs_mod__.createRequire(import.meta.url);
},
},
renderer: {
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
plugins: [appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
+4
View File
@@ -7,7 +7,11 @@ type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
async function main() {
process.env.OPENCODE_CHANNEL = "local"
process.env.OPENCODE_VERSION = `2.0.0-local-${Date.now()}`
process.env.OPENCODE_DISABLE_CHANNEL_DB = "0"
const options = selectOptions()
if (options.server.type === "build") process.env.OPENCODE_DESKTOP_SERVER_CHANNEL = "local"
await prepareDesktop()
await prepareServer(options.server)
await startDesktop(options.electron)
+1 -1
View File
@@ -93,7 +93,7 @@ export async function buildCliToResources(dest = windowsify("resources/opencode-
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
@@ -25,6 +25,10 @@ export async function startBackgroundCli(logger: Logger) {
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
+3 -2
View File
@@ -1,7 +1,8 @@
import { app } from "electron"
type Channel = "dev" | "beta" | "prod"
type Channel = "local" | "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+1
View File
@@ -1,5 +1,6 @@
interface ImportMetaEnv {
readonly OPENCODE_CHANNEL: string
readonly OPENCODE_VERSION?: string
}
interface ImportMeta {
+3 -3
View File
@@ -12,7 +12,7 @@ import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL } from "./constants"
import { CHANNEL, VERSION } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
@@ -135,7 +135,7 @@ const main = Effect.gen(function* () {
initCrashReporter()
const wslServers = createWslServersController(
app.getVersion(),
VERSION,
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
@@ -165,7 +165,7 @@ const main = Effect.gen(function* () {
}
logger.log("app starting", {
version: app.getVersion(),
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingTestRoot),
})
+2 -1
View File
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
import { VERSION } from "./constants"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
@@ -133,7 +134,7 @@ function cleanup() {
function manifest() {
return {
generated: new Date().toISOString(),
version: app.getVersion(),
version: VERSION,
name: app.getName(),
packaged: app.isPackaged,
platform: process.platform,
+3 -2
View File
@@ -33,6 +33,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme/context"
const root = document.getElementById("root")
const version = import.meta.env.OPENCODE_VERSION ?? pkg.version
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("desktop.error.dev.rootNotFound"))
}
@@ -41,7 +42,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${version}`,
initialScope: {
tags: {
platform: "desktop",
@@ -168,7 +169,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return {
platform: "desktop",
os,
version: pkg.version,
version,
windowID: windowState.id,
async openDirectoryPickerDialog(opts) {
-2
View File
@@ -372,8 +372,6 @@ export interface KeymapCommand {
readonly aliases?: string[]
/** Keeps the slash command in the prompt and passes its raw input to run. */
readonly arguments?: true
/** Hides the command from slash completion until its exact name is typed. */
readonly secret?: true
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},
+31 -1
View File
@@ -3,7 +3,9 @@ export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Location } from "./location.js"
import { Model } from "./model.js"
import { Project } from "./project.js"
import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
import { ascending } from "./identifier.js"
@@ -53,6 +55,20 @@ export const ModelSelected = Schema.Struct({
previous: Model.Ref.pipe(optional),
}).annotate({ identifier: "Session.Message.ModelSelected" })
export interface LocationSwitched extends Schema.Schema.Type<typeof LocationSwitched> {}
export const LocationSwitched = Schema.Struct({
...Base,
type: Schema.tag("location-switched"),
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
previous: Schema.Struct({
location: Location.Ref,
projectID: Project.ID.pipe(optional),
subpath: RelativePath.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.LocationSwitched" })
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
@@ -75,7 +91,10 @@ export interface System extends Schema.Schema.Type<typeof System> {}
export const System = Schema.Struct({
...Base,
type: Schema.tag("system"),
/** The model-facing update text, frozen at emit time. */
text: Schema.String,
/** A short human-readable summary for transcript display. */
description: Schema.String.pipe(optional),
}).annotate({ identifier: "Session.Message.System" })
export interface Skill extends Schema.Schema.Type<typeof Skill> {}
@@ -243,6 +262,7 @@ export type Compaction = CompactionRunning | CompactionCompleted | CompactionFai
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
LocationSwitched,
User,
Synthetic,
System,
@@ -251,5 +271,15 @@ export const Info = Schema.Union([
Assistant,
Compaction,
]).annotate({ identifier: "Session.Message.Info" })
export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction
export type Info =
| AgentSelected
| ModelSelected
| LocationSwitched
| User
| Synthetic
| System
| Skill
| Shell
| Assistant
| Compaction
export type Type = Info["type"]
+2 -30
View File
@@ -30,7 +30,7 @@ import {
batch,
Show,
} from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -62,7 +62,6 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogExperiments } from "./component/dialog-experiments"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -497,7 +496,7 @@ function App(props: { pair?: DialogPairCredentials }) {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Run /mcps to view details.",
})
}
})
@@ -658,22 +657,8 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
// With per-tab drafts, a new session is an explicit "this belongs
// elsewhere" gesture: move the in-progress draft instead of leaving
// a copy behind on the tab it came from.
const carried = (() => {
if (config.data.experimental?.tab_drafts !== true) return undefined
const current = promptRef.current
if (!current?.current.text) return undefined
// Copy before reset: reset() merges an empty prompt into the same
// underlying store object that unwrap exposes.
const prompt = { ...unwrap(current.current) }
current.reset()
return prompt
})()
route.navigate({
type: "home",
prompt: carried,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -885,19 +870,6 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
{
// Deliberately absent from the command palette; reachable only by the
// secret /baldbeard incantation.
name: "opencode.experiments",
title: "Experiments",
description: "look is my devrel meme face",
palette: undefined,
slash: { name: "baldbeard", secret: true as const },
run: () => {
dialog.replace(() => <DialogExperiments />)
},
category: "System",
},
{
name: "opencode.status",
title: "View status",
@@ -13,6 +13,8 @@ import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { DevTools } from "../devtools"
import { useDialog } from "../ui/dialog"
import { DialogExperiments } from "./dialog-experiments"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
@@ -27,6 +29,7 @@ export type RuntimeStatus = "normal" | "medium" | "high"
export function DevToolsBar() {
const client = useClient()
const config = useConfig()
const dialog = useDialog()
const data = useData()
const location = useLocation()
const route = useRoute()
@@ -405,6 +408,15 @@ export function DevToolsBar() {
</PanelBox>
</Show>
</BarItem>
<BarItem
active={false}
onClick={() => {
close()
dialog.replace(() => <DialogExperiments />)
}}
>
<text fg={theme.text.subdued}>Experiments</text>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
</box>
@@ -0,0 +1,85 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -16,13 +16,14 @@ export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
},
]
export function DialogExperiments() {
const config = useConfig()
const toast = useToast()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
@@ -30,14 +31,15 @@ export function DialogExperiments() {
const options = createMemo(() =>
experiments.map((experiment, index) => ({
title: experiment.title,
description: experiment.description,
category: "Experiments",
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: index,
})),
)
async function toggle(index: number) {
// All experiments are booleans, so either direction toggles.
async function change(index = selected()) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
@@ -56,8 +58,23 @@ export function DialogExperiments() {
<DialogSelect
title="Experiments"
options={options()}
onSelect={(option) => void toggle(option.value)}
footerHints={[{ title: "enter", label: "toggle" }]}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]}
/>
)
}
+6 -84
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { DialogErrorDetails } from "./dialog-error-details"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -143,8 +140,9 @@ export function DialogMcp() {
}
>
{(server) => (
<DialogMcpError
server={server()}
<DialogErrorDetails
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -155,79 +153,3 @@ export function DialogMcp() {
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -512,9 +512,6 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
const slash = command.slash
if (!slash) return []
// Secret commands are incantations: absent from the "/" listing and from
// fuzzy matching until the exact name is typed.
if (slash.secret && search().toLowerCase() !== slash.name) return []
return {
display: `/${slash.name}`,
description: command.description ?? command.title,
@@ -8,10 +8,6 @@ import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { useData } from "../../context/data"
function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const client = useClient()
@@ -103,9 +99,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, directory })
await client.api.session
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
.catch(() => undefined)
dialog.clear()
} catch (error) {
toast.error(error)
+26 -11
View File
@@ -431,14 +431,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
const previous = {
location: { ...current.location },
projectID: current.projectID,
subpath: current.subpath,
}
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID)
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
})
}
break
}
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
@@ -505,19 +523,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
typeof instructions === "object" &&
instructions !== null &&
"initial" in instructions &&
instructions.initial === true
)
break
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
// and produce no transcript message.
const updateText = event.data.text
if (updateText === undefined) break
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "system",
text: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
-1
View File
@@ -24,7 +24,6 @@ declare module "@opentui/keymap" {
name: string
aliases?: string[]
arguments?: true
secret?: true
}
}
}
@@ -1,10 +1,11 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
function Mcp(props: { context: Plugin.Context }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
@@ -14,6 +15,7 @@ function Mcp(props: { context: Plugin.Context }) {
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} MCP failed
</Match>
<Match when={true}>
<span
@@ -24,11 +26,28 @@ function Mcp(props: { context: Plugin.Context }) {
>
{" "}
</span>
{count()} MCP
</Match>
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</box>
</Show>
)
@@ -50,6 +69,7 @@ function View(props: { context: Plugin.Context }) {
gap={2}
>
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
@@ -1,22 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
category: "Built-in",
footer: (
<span
style={{
@@ -29,8 +33,43 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
</span>
),
}),
),
)
)
const external = props.plugins.list().map(
(plugin): DialogSelectOption<string> => ({
title: "id" in plugin ? plugin.id : plugin.target,
value: "id" in plugin ? plugin.id : plugin.target,
category: "External",
searchText: plugin.target,
footer: (
<span
style={{
fg:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}}
>
{plugin.status}
</span>
),
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return ("id" in plugin ? plugin.id : plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
@@ -51,15 +90,53 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<DialogSelect
title="Plugins"
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="Plugins"
options={options()}
current={focused()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => Boolean(failure(option?.value)),
onTrigger: toggle,
},
]}
onSelect={select}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
</Show>
}
/>
}
>
{(item) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
}
@@ -72,6 +149,7 @@ function Commands(props: { context: Plugin.Context }) {
id: "plugins.list",
title: "Plugins",
group: "System",
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
+5 -1
View File
@@ -390,7 +390,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
host.toast.show({
variant: "error",
title: `Plugin failed: ${state.target}`,
message: "Run /plugins to view details.",
})
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
+28 -14
View File
@@ -1379,7 +1379,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
<Match when={props.message.type === "shell"}>
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
</Match>
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
<Match
when={
props.message.type === "agent-switched" ||
props.message.type === "model-switched" ||
props.message.type === "location-switched"
}
>
<SessionSwitchMessageV2 message={props.message} />
</Match>
<Match
@@ -1670,6 +1676,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
}
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
if (props.message.type === "location-switched") return `Switched location to ${props.message.location.directory}`
return ""
}
return (
@@ -1688,7 +1695,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const state = () => stringValue(metadata()?.state)
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
const text = () => {
if (props.message.type === "system") return props.message.text
if (props.message.type === "system") return props.message.description ?? "Instructions updated"
if (props.message.type === "synthetic") return props.message.description ?? ""
return ""
}
@@ -2819,10 +2826,15 @@ function Shell(props: ToolProps) {
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
const input = createMemo(() => {
if (!command()) return ""
const prompt = workdir() && workdir() !== "." ? `${workdir()}$ ` : isRunning() ? "" : "$ "
return `${prompt}${command()}`
const cmd = command()
if (!cmd) return ""
// While running, the workdir prompt shares the spinner's text column; when
// settled, the prompt renders as its own column so wrapped command lines
// keep a stable hanging indent instead of jumping to the card inset.
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
return cmd
})
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
@@ -2830,6 +2842,8 @@ function Shell(props: ToolProps) {
if (expanded() || !collapsed().overflow) return content()
return collapsed().output
})
const limitedInput = createMemo(() => limited().slice(0, input().length))
const limitedOutput = createMemo(() => limited().slice(Math.min(limited().length, input().length + 2)))
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
@@ -2853,16 +2867,16 @@ function Shell(props: ToolProps) {
<Show
when={isRunning()}
fallback={
<text>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</text>
<box flexDirection="row" gap={1}>
<text fg={theme.text.default}>{prompt()}</text>
<text fg={theme.text.default}>{limitedInput()}</text>
</box>
}
>
<Spinner color={color()}>
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
</Spinner>
<Spinner color={color()}>{limitedInput()}</Spinner>
</Show>
<Show when={limitedOutput()}>
<text fg={theme.text.subdued}>{limitedOutput()}</text>
</Show>
</Show>
<Show when={background()}>
@@ -2883,7 +2897,7 @@ function Write(props: ToolProps) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<Match when={props.part.state.status === "completed"}>
<BlockTool
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
+28 -4
View File
@@ -655,6 +655,18 @@ test("updates session location when moved", async () => {
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.projectID).toBe("project-moved")
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
expect(data.session.message.list("ses_test")).toContainEqual({
id: "msg_moved_1",
type: "location-switched",
location: { directory: destination },
projectID: "project-moved",
subpath: "packages/cli",
previous: {
location: { directory },
projectID: "proj_test",
},
time: { created: 1 },
})
} finally {
app.renderer.destroy()
}
@@ -2889,14 +2901,26 @@ test("skips initial instruction state and projects later updates with their mess
delta: { "core/date": "1".repeat(64) },
},
})
emitEvent(events, {
id: "evt_instructions_3",
created: 2,
type: "session.instructions.updated",
durable: durable("session-1", 2, 2),
data: {
sessionID: "session-1",
delta: { "core/date": "2".repeat(64) },
text: "The current date has changed.",
},
})
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 1))
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 2))
expect(sync.session.message.list("session-1")).toHaveLength(1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_2")),
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_3")),
type: "system",
text: "Instructions updated: core/date",
time: { created: 1 },
text: "The current date has changed.",
description: "Instructions updated: core/date",
time: { created: 2 },
})
} finally {
app.renderer.destroy()
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},
+60
View File
@@ -12795,6 +12795,63 @@
"required": ["id", "time", "type", "model"],
"additionalProperties": false
},
"Session.Message.LocationSwitched": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^msg_"
}
]
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["location-switched"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
},
"previous": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"projectID": {
"type": "string"
},
"subpath": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
},
"required": ["id", "time", "type", "location"],
"additionalProperties": false
},
"Prompt.Base64": {
"type": "string",
"allOf": [
@@ -13719,6 +13776,9 @@
{
"$ref": "#/components/schemas/Session.Message.ModelSelected"
},
{
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
},
{
"$ref": "#/components/schemas/Session.Message.User"
},