Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton fb5cd98dc7 fix(tui): restore transcript page keys 2026-08-06 16:42:58 -04:00
Kit Langton f471e51b95 fix(tui): navigate unread sessions with page keys 2026-08-06 16:38:17 -04:00
Kit Langton e5436ab5c1 refactor(tui): simplify session model drafts 2026-08-06 16:38:13 -04:00
Kit Langton d4216c5d9a fix(tui): keep model selection session scoped 2026-08-06 16:20:37 -04:00
Kit Langton c71b5d73d8 fix(tui): scope model selection to active location 2026-08-06 15:27:51 -04:00
46 changed files with 5107 additions and 2808 deletions
+131 -109
View File
@@ -1693,43 +1693,44 @@ export type AgentInfo = {
export type ConfigEntry =
| {
type: "document"
path?: string
path?: string | null
info: {
$schema?: string
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
autoupdate?: boolean | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
permissions?: PermissionRuleset
$schema?: string | null
shell?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
default_agent?: string | null
autoupdate?: boolean | "notify" | null
share?: "manual" | "auto" | "disabled" | null
enterprise?: { url?: string | null } | null
username?: string | null
permissions?: PermissionRuleset | null
agents?: {
[x: string]: {
model?: string | { providerID: string; model: string; variant?: string }
request?: { headers?: { [x: string]: string }; body?: { [x: string]: JsonValue } }
system?: string
description?: string
mode?: "subagent" | "primary" | "all"
hidden?: boolean
color?: string
steps?: number
disabled?: boolean
permissions?: PermissionRuleset
model?: string | { providerID: string; model: string; variant?: string | null } | null
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
system?: string | null
description?: string | null
mode?: "subagent" | "primary" | "all" | null
hidden?: boolean | null
color?: string | null
steps?: number | null
disabled?: boolean | null
permissions?: PermissionRuleset | null
}
}
snapshots?: boolean
watcher?: { ignore?: Array<string> }
} | null
snapshots?: boolean | null
watcher?: { ignore?: Array<string> | null } | null
formatter?:
| boolean
| {
[x: string]: {
disabled?: boolean
command?: Array<string>
environment?: { [x: string]: string }
extensions?: Array<string>
disabled?: boolean | null
command?: Array<string> | null
environment?: { [x: string]: string } | null
extensions?: Array<string> | null
}
}
| null
lsp?:
| boolean
| {
@@ -1737,117 +1738,125 @@ export type ConfigEntry =
| { disabled: true }
| {
command: Array<string>
extensions?: Array<string>
disabled?: boolean
env?: { [x: string]: string }
initialization?: { [x: string]: JsonValue }
extensions?: Array<string> | null
disabled?: boolean | null
env?: { [x: string]: string } | null
initialization?: { [x: string]: JsonValue } | null
}
}
| null
media?: {
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
}
tool_output?: { max_lines?: number; max_bytes?: number }
image?: {
auto_resize?: boolean | null
max_width?: number | null
max_height?: number | null
max_base64_bytes?: number | null
} | null
} | null
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
mcp?: {
timeout?: { startup?: number; catalog?: number; execution?: number }
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
servers?: {
[x: string]:
| {
type: "local"
command: Array<string>
cwd?: string
environment?: { [x: string]: string }
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
cwd?: string | null
environment?: { [x: string]: string } | null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
}
| {
type: "remote"
url: string
headers?: { [x: string]: string }
headers?: { [x: string]: string } | null
oauth?:
| {
client_id?: string
client_secret?: string
scope?: string
callback_port?: number
redirect_uri?: string
client_id?: string | null
client_secret?: string | null
scope?: string | null
callback_port?: number | null
redirect_uri?: string | null
}
| false
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
| null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
}
}
}
compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
skills?: Array<string>
} | null
} | null
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
skills?: Array<string> | null
commands?: {
[x: string]: {
template: string
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subtask?: boolean
description?: string | null
agent?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
subtask?: boolean | null
}
}
instructions?: Array<string>
} | null
instructions?: Array<string> | null
references?: {
[x: string]:
| string
| { repository: string; branch?: string; description?: string; hidden?: boolean }
| { path: string; description?: string; hidden?: boolean }
}
websearch?: { provider: string }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
| { path: string; description?: string | null; hidden?: boolean | null }
} | null
websearch?: { provider: string } | null
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
providers?: {
[x: string]: {
name?: string
env?: Array<string>
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
name?: string | null
env?: Array<string> | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
models?: {
[x: string]: {
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
modelID?: string | null
family?: string | null
name?: string | null
compatibility?: ModelCompatibility | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
capabilities?: ModelCapabilities | null
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
}> | null
cost?:
| {
tier?: { type: "context"; size: number }
tier?: { type: "context"; size: number } | null
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
}
| Array<{
tier?: { type: "context"; size: number }
tier?: { type: "context"; size: number } | null
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
}>
disabled?: boolean
limit?: { context?: number; input?: number; output?: number }
| null
disabled?: boolean | null
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
}
}
} | null
}
}
} | null
experimental?: {
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
subagent_depth?: number | null
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
} | null
}
}
| { type: "directory"; path: string }
@@ -3233,28 +3242,41 @@ export type McpAddInput = {
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string
readonly environment?: { readonly [x: string]: string }
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
readonly cwd?: string | undefined
readonly environment?: { readonly [x: string]: string } | undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string }
readonly headers?: { readonly [x: string]: string } | undefined
readonly oauth?:
| {
readonly client_id?: string
readonly client_secret?: string
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
readonly client_id?: string | undefined
readonly client_secret?: string | undefined
readonly scope?: string | undefined
readonly callback_port?: number | undefined
readonly redirect_uri?: string | undefined
}
| false
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
| undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
}
}["config"]
}
+47 -64
View File
@@ -1,8 +1,5 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -13,12 +10,6 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
const keys = new Set([
"logLevel",
"server",
@@ -57,46 +48,42 @@ export function isV1(input: unknown) {
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
)
providers: providers(info.provider),
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
@@ -167,22 +154,18 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
}
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
+1 -1
View File
@@ -516,12 +516,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath, optional } from "./schema.js"
import { AbsolutePath } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(optional).annotate({
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(optional).annotate({
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(optional).annotate({
default_agent: Schema.String.pipe(Schema.optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(optional),
url: Schema.String.pipe(Schema.optional),
})
.pipe(optional)
.pipe(Schema.optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(optional).annotate({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(optional).annotate({
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(optional).annotate({
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(optional).annotate({
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(optional).annotate({
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(optional).annotate({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(optional).annotate({
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(optional).annotate({
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(optional).annotate({
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, optional).annotate({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(optional).annotate({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(optional),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt, optional } from "../schema.js"
import { NonNegativeInt } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+4 -5
View File
@@ -1,13 +1,12 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+4 -5
View File
@@ -1,7 +1,6 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -9,10 +8,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+2 -3
View File
@@ -2,7 +2,6 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(optional),
image: Image.pipe(Schema.optional),
}) {}
+1 -2
View File
@@ -3,7 +3,6 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(optional),
variant: VariantID.pipe(Schema.optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+1 -2
View File
@@ -1,11 +1,10 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+24 -25
View File
@@ -3,14 +3,13 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -19,47 +18,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(optional),
}).pipe(Schema.optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(optional),
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: Capabilities.pipe(optional),
capabilities: Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}
+5 -6
View File
@@ -1,19 +1,18 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { optional, PositiveInt } from "../schema.js"
import { PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}
+3 -4
View File
@@ -1,16 +1,15 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(optional).annotate({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(optional).annotate({
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(optional).annotate({
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+1 -2
View File
@@ -1,8 +1,7 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, optional),
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
+18 -18
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(optional).annotate({
startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(optional).annotate({
catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(optional).annotate({
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(optional).annotate({
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(optional),
timeout: TimeoutConfig.pipe(Schema.optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
+1 -47
View File
@@ -1,10 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -33,14 +29,7 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -50,39 +39,4 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
+4 -20
View File
@@ -16,9 +16,7 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -27,7 +25,6 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -58,21 +56,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -6,7 +6,6 @@ import type {
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -446,19 +445,6 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{
bind: "c",
title: "Copy authorization details",
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
open
/>
)
}
@@ -574,14 +559,7 @@ function OAuthCode(props: {
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useTheme("elevated")
return (
@@ -605,18 +583,11 @@ function OAuthView(props: {
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
)
}
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
+46 -88
View File
@@ -157,7 +157,6 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const data = useData()
const keymapCommands = Keymap.useCommands()
const queueShortcut = Keymap.useShortcut("prompt.queue")
const currentLocation = useLocation()
const config = useConfig().data
const dialog = useDialog()
@@ -328,10 +327,6 @@ export function Prompt(props: PromptProps) {
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
})
@@ -362,20 +357,6 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{
title: "Remove editor context",
name: "prompt.editor_context.clear",
@@ -534,11 +515,6 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
@@ -924,7 +900,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit(delivery: "steer" | "queue" = "steer") {
async function submit() {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -934,13 +910,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false
submitting = true
try {
return await submitInner(delivery)
return await submitInner()
} finally {
submitting = false
}
}
async function submitInner(delivery: "steer" | "queue") {
async function submitInner() {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -953,24 +929,35 @@ export function Prompt(props: PromptProps) {
if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim()
if (delivery === "queue" && (store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt()
await slash.command.run(slash.input)
return true
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -978,6 +965,15 @@ export function Prompt(props: PromptProps) {
void promptModelWarning()
return false
}
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selectedModel)) {
toast.show({
title: "Model unavailable",
message: `${selectedModel.providerID}/${selectedModel.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current()
let sessionID = props.sessionID
@@ -1018,17 +1014,6 @@ export function Prompt(props: PromptProps) {
session = created
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
@@ -1041,48 +1026,30 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
} else if (slashHead && isCommand) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
void client.api.session
.command({
sessionID,
command: command.slice(1),
arguments: args,
command: slashHead.name,
arguments: slashHead.arguments,
agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
model,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
} else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
if (delivery === "queue") {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
} else if (isSkill) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
skill: slashHead!.name,
})
} else {
move.startSubmit()
@@ -1098,9 +1065,11 @@ export function Prompt(props: PromptProps) {
session.model.id !== selectedModel.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
await client.api.session.switchModel({
sessionID,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
const model = { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }
const cancelCommit = local.model.expectCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) {
@@ -1136,7 +1105,6 @@ export function Prompt(props: PromptProps) {
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.then(
() => undefined,
@@ -1354,10 +1322,7 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})()
if (!value) return undefined
const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
})
const locationLabel = createMemo(() => {
@@ -1609,13 +1574,6 @@ export function Prompt(props: PromptProps) {
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
<Show when={queueShortcut()}>
{(shortcut) => (
<text fg={theme.text.default} wrapMode="none" flexShrink={0}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>queue</span>
</text>
)}
</Show>
</box>
</Match>
<Match when={move.progress()}>
+1 -3
View File
@@ -161,7 +161,6 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -171,7 +170,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
@@ -360,7 +359,6 @@ export const CommandMap = {
messages_redo: "session.redo",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
+132 -37
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js"
import { batch, createMemo, onCleanup } from "solid-js"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const event = useEvent()
const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) {
return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
}
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
if (model && isModelValid(model)) return model
}
}
function createAgent() {
const agents = createMemo(() =>
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
@@ -128,20 +132,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent()
function createModel() {
type Selection = ModelPreferenceModel & { variant?: string }
const [modelStore, setModelStore] = createStore<
ModelPreference & {
ready: boolean
model: Record<string, ModelPreferenceModel>
defaults: Record<string, ModelPreferenceModel | undefined>
drafts: Record<string, Selection | undefined>
}
>({
ready: false,
model: {},
defaults: {},
drafts: {},
recent: [],
favorite: [],
variant: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingCommits = new Map<string, string>()
const commitKey = (value: ModelPreferenceModel & { variant?: string }) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const state = {
pending: false,
}
@@ -191,7 +201,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const model = data.location.model.list()?.[0]
const model = models()?.[0]
if (!model) return undefined
return {
providerID: model.providerID,
@@ -200,21 +210,109 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const currentModel = createMemo(() => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
if (sessionID) return modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const a = agent.current()
return (
getFirstValidModel(
() => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
) ?? undefined
const fallback = getFirstValidModel(
() => a && modelStore.defaults[agentModelKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
)
return fallback
})
function agentModelKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `agent:${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): Selection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function setDraft(sessionID: string, selection: Selection) {
const durable = durableSelection(sessionID)
setModelStore(
"drafts",
sessionID,
durable && commitKey(durable) === commitKey(selection) ? undefined : selection,
)
}
function select(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = modelStore.drafts[sessionID] ?? durableSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: modelStore.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setModelStore("defaults", agentModelKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingCommits.get(evt.data.sessionID)
if (!expected) return
pendingCommits.delete(evt.data.sessionID)
const committed = commitKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
const draft = modelStore.drafts[evt.data.sessionID]
if (draft && commitKey(draft) === committed) setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingCommits.delete(evt.data.sessionID)
setModelStore("drafts", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
expectCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = commitKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingCommits.set(sessionID, committed)
return () => {
if (pendingCommits.get(sessionID) === committed) pendingCommits.delete(sessionID)
}
},
get ready() {
return modelStore.ready
},
get catalogReady() {
return models() !== undefined
},
recent() {
return modelStore.recent
},
@@ -230,30 +328,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
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)
const provider = providers()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
model: info?.name ?? `${value.modelID} (unavailable)`,
reasoning: (info?.variants?.length ?? 0) !== 0,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = modelStore.recent
const recent = recentModels(current, modelStore.recent).filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
select({ ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -279,18 +372,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...next })
if (!select({ ...next })) return
setModelStore("recent", recentModels(next, modelStore.recent))
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (!select(model)) return
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
@@ -317,6 +406,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
selected() {
const m = currentModel()
if (!m) return undefined
if (route.data.type === "session") {
const selection = modelStore.drafts[route.data.sessionID] ?? durableSelection(route.data.sessionID)
if (selection?.providerID === m.providerID && selection.modelID === m.modelID) return selection.variant
}
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
},
current() {
@@ -327,14 +420,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
list() {
const m = currentModel()
if (!m) return []
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
if (route.data.type === "session") {
setDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
save()
},
+4 -22
View File
@@ -19,7 +19,6 @@ import {
displayCharAt,
displaySlice,
isExitCommand,
isCompactCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
@@ -981,18 +980,8 @@ export function createPromptState(input: PromptInput): PromptState {
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{
id: "prompt.editor",
title: "Open editor",
@@ -1127,7 +1116,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
}
const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => {
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
draft = promptCopy(next)
}
@@ -1147,13 +1136,6 @@ export function createPromptState(input: PromptInput): PromptState {
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text))
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -1175,10 +1157,10 @@ export function createPromptState(input: PromptInput): PromptState {
}
const submit = command
? { ...next, command, delivery }
? { ...next, command }
: parsed?.type === "command"
? { ...next, command: parsed.command, delivery }
: { ...next, delivery }
? { ...next, command: parsed.command }
: next
const shellMode = next.mode === "shell"
resetDraft()
-4
View File
@@ -185,7 +185,6 @@ export function RunFooterView(props: RunFooterViewProps) {
const command = () => shortcut("command.palette.show")
const subagentShortcut = () => shortcut("session.child.first")
const queuedShortcut = () => shortcut("session.queued_prompts")
const queueShortcut = () => shortcut("prompt.queue")
const backgroundShortcut = () => shortcut("session.background")
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
const interrupt = () => shortcut("session.interrupt")
@@ -458,9 +457,6 @@ export function RunFooterView(props: RunFooterViewProps) {
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
}
if (busy() && queueShortcut()) {
items.push({ key: queueShortcut(), label: "queue" })
}
return items
})
+4 -5
View File
@@ -25,7 +25,7 @@ export type QueueInput = {
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
}
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(sent, sent.delivery ?? "steer")
input.onSend?.(sent, "steer")
if (state.closed) {
break
@@ -276,11 +276,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission
admissionVersion += 1
const delivery = prompt.delivery ?? "queue"
input.onSend?.(sent, delivery)
input.onSend?.(sent, "queue")
admissions = admissions
.then(() => admission)
.then(() => input.admit(sent, delivery, admissionController.signal))
.then(() => input.admit(sent, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return
}
+11 -14
View File
@@ -892,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log,
onSend: (prompt, delivery) => {
state.shown = true
state.history.push({ ...prompt, delivery: undefined })
state.history.push(prompt)
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({
kind: "user",
@@ -903,21 +903,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
}
},
admit: async (prompt, delivery, signal) => {
admit: async (prompt, signal) => {
await state.switching?.catch(() => {})
const next = await ensureStream()
await next.handle.admitPromptTurn(
{
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
},
delivery,
)
await next.handle.queuePromptTurn({
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
})
},
onAdmissionError: renderPromptError,
onCompact: async () => {
+5 -5
View File
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
queuePromptTurn(input: SessionTurnInput): Promise<void>
waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void
@@ -1643,14 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
return {
async admitPromptTurn(next, delivery) {
async queuePromptTurn(next) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
mergePending(await admitPrompt(next, client, delivery))
mergePending(await admitPrompt(next, client, "queue"))
settlementClient = client
},
async waitForIdle() {
@@ -1688,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (command) {
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
return
}
@@ -1700,7 +1700,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
},
async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it;
-1
View File
@@ -75,7 +75,6 @@ export type RunPrompt = {
messageID?: string
text: string
parts: RunPromptPart[]
delivery?: "steer" | "queue"
mode?: "shell"
command?: {
name: string
+5 -49
View File
@@ -175,11 +175,6 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global)
})
const queuedPrompts = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) =>
item.type === "user" && item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [],
),
)
const [composer, setComposer] = createStore({
open: false,
tab: undefined as string | undefined,
@@ -366,7 +361,7 @@ export function Session() {
createEffect(() => {
const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true
@@ -1011,9 +1006,6 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
@@ -1857,10 +1849,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const delivery = createMemo(() => {
const pending = data.session.pending.list(ctx.sessionID).find((item) => item.id === props.message.id)
return pending?.type === "user" ? pending.delivery : undefined
})
const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
@@ -1869,7 +1860,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
<Show when={props.message.text.trim() || files().length}>
<box
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1896,9 +1887,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
flexShrink={0}
>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={delivery()}>
{(value) => <text fg={theme.text.subdued}>{value() === "queue" ? "queued" : "steering"}</text>}
</Show>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -1931,38 +1919,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
}
function QueuedPromptDock(props: { prompts: { id: string; text: string }[] }) {
const theme = useTheme("elevated")
const shortcut = Keymap.useShortcut("command.palette.show")
const next = createMemo(() => props.prompts[0]?.text)
return (
<box
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
flexDirection="row"
justifyContent="space-between"
gap={2}
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
<Show when={next()}>{(text) => <> · Next · {text()}</>}</Show>
</text>
<Show when={shortcut()}>
{(key) => (
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
<span style={{ fg: theme.text.default }}>{key()}</span> view all
</text>
)}
</Show>
</box>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
return (
+7 -18
View File
@@ -46,7 +46,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const pending = data.session.pending.list(sessionID())
const boundary = revertBoundary()
const rows = reduceSessionRows(
boundary ? messages.filter((message) => message.id < boundary) : messages,
@@ -54,17 +53,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
turnTokens(),
)
partitionPending(rows, pendingPermissions())
removeQueuedPrompts(
rows,
pending
.filter((item) => item.type === "user" && item.delivery === "queue")
.map((item) => item.id),
)
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
rows.splice(
position === -1 ? rows.length : position,
0,
...pending
...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
)
@@ -118,7 +112,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(
() =>
data.session.pending.list(sessionID()).map((item) => `${item.id}:${"delivery" in item ? item.delivery : item.type}`),
data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => item.id),
() => setRows(reconcile(reduce())),
{ defer: true },
),
@@ -199,9 +196,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex(
(row) =>
row.type === "compaction-queued" ||
(row.type === "message" && isPending(row.messageID)),
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
)
return index === -1 ? rows.length : index
}
@@ -286,12 +281,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
return rows
}
export function removeQueuedPrompts(rows: SessionRow[], messageIDs: string[]) {
const queued = new Set(messageIDs)
const visible = rows.filter((row) => row.type !== "message" || !queued.has(row.messageID))
rows.splice(0, rows.length, ...visible)
}
export function reduceSessionRows(
messages: SessionMessageInfo[],
inputs = new Set<string>(),
+1 -1
View File
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
open(props.href).catch(() => {})
}}
>
<a href={props.href}>{displayText}</a>
{displayText}
</text>
)
}
+1 -25
View File
@@ -1,30 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import {
cacheReuseDrop,
messageBoundaryIDs,
removeQueuedPrompts,
reduceSessionRows,
} from "../../../src/routes/session/rows"
import type { SessionRow } from "../../../src/routes/session/rows"
test("removes queued prompts from transcript rows", () => {
const rows: SessionRow[] = [
{ type: "message" as const, messageID: "active" },
{ type: "message" as const, messageID: "queue-1" },
{ type: "message" as const, messageID: "steer" },
{ type: "message" as const, messageID: "queue-2" },
{ type: "message" as const, messageID: "queue-3" },
{ type: "message" as const, messageID: "queue-4" },
]
removeQueuedPrompts(rows, ["queue-1", "queue-2", "queue-3", "queue-4"])
expect(rows).toEqual([
{ type: "message", messageID: "active" },
{ type: "message", messageID: "steer" },
])
})
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
+2 -2
View File
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
return true
},
+7 -14
View File
@@ -1068,11 +1068,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce()
expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
{ text: "/new ", parts: [] },
{ text: "/new ", parts: [] },
])
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally {
@@ -1100,9 +1100,7 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
])
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally {
app.cleanup()
@@ -1160,12 +1158,7 @@ test("direct footer tags skill slash submissions with their catalog source", asy
await app.renderOnce()
expect(submits).toEqual([
{
text: "/formatter src",
parts: [],
command: { name: "formatter", arguments: "src", source: "skill" },
delivery: "steer",
},
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
])
} finally {
app.cleanup()
+1 -2
View File
@@ -82,8 +82,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
})
test("preserves disabled leader from resolved tui config", async () => {
+1 -28
View File
@@ -265,33 +265,6 @@ describe("run runtime queue", () => {
await task
})
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (_input, _signal, onAdmitted) => {
onAdmitted()
await gate.promise
},
admit: async (input, delivery) => {
admitted.push(`${input.text}:${delivery}`)
},
settle: async () => ui.api.close(),
})
ui.submit("one")
ui.submit("two", undefined, "steer")
ui.submit("three", undefined, "queue")
while (admitted.length < 2) await Bun.sleep(0)
expect(admitted).toEqual(["two:steer", "three:queue"])
gate.resolve()
await task
})
test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
@@ -335,7 +308,7 @@ describe("run runtime queue", () => {
admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
},
admit: async (_prompt, _delivery, signal) => {
admit: async (_prompt, signal) => {
admissionStarted.resolve()
await new Promise<void>((resolve) => {
if (signal.aborted) {
+3 -3
View File
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
turnStarted.resolve()
api.close()
},
admitPromptTurn: async () => {},
queuePromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
streamStarted.resolve()
return {
runPromptTurn: async () => {},
admitPromptTurn: async () => {},
queuePromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0)
return {
runPromptTurn: async () => {},
admitPromptTurn: async () => {},
queuePromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -701,14 +701,14 @@ describe("V2 mini transport", () => {
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
await transport.admitPromptTurn({
await transport.queuePromptTurn({
agent: "review",
model: undefined,
variant: undefined,
prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [],
includeFiles: false,
}, "queue")
})
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({
@@ -813,14 +813,14 @@ describe("V2 mini transport", () => {
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" },
})
await transport.admitPromptTurn({
await transport.queuePromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [],
includeFiles: false,
}, "queue")
})
events.push({
id: "evt_queued_promoted",
created: 3,
+1517 -690
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff