feat(tui): add v2 terminal interface

This commit is contained in:
Dax Raad 2026-06-26 14:20:47 -04:00
commit e6f660fecf
73 changed files with 3028 additions and 2035 deletions

View file

@ -10,9 +10,9 @@ export function DialogAgent() {
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
value: item.id,
title: item.id,
description: item.description,
}
}),
)
@ -20,7 +20,7 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
current={local.agent.current()?.name}
current={local.agent.current()?.id}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)

View file

@ -0,0 +1,463 @@
import { TextAttributes } from "@opentui/core"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { Link } from "../ui/link"
import { useToast } from "../ui/toast"
const INTEGRATION_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted(
(a, b) =>
(INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
}
export function connectMethods(integration: IntegrationInfo): ConnectMethod[] {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export function credentialConnections(integration: IntegrationInfo) {
return integration.connections.filter(
(connection): connection is Extract<ConnectionInfo, { type: "credential" }> => connection.type === "credential",
)
}
export function connectionSummary(integration: IntegrationInfo) {
return integration.connections
.map((connection) => (connection.type === "credential" ? connection.label : `$${connection.name}`))
.join(", ")
}
export function DialogIntegration() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
return {
title: integration.name,
value: integration.id,
description: methods.length ? undefined : "Environment only",
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog)
: selectMethod(integration, methods, dialog),
}
}),
)
return (
<DialogSelect
title="Connect a service"
options={options()}
emptyView={<text fg={theme.textMuted}>No integrations available</text>}
/>
)
}
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const toast = useToast()
return (
<DialogSelect
title={integration.name}
options={[
...(methods.length
? [
{
title: "Add connection",
value: "add",
onSelect: () => selectMethod(integration, methods, dialog),
},
]
: []),
...credentialConnections(integration).map((connection) => ({
title: `Disconnect ${connection.label}`,
value: connection.id,
onSelect: () => {
void sdk.client.v2.credential
.remove(
{ credentialID: connection.id, location: location(data) },
{ throwOnError: true },
)
.then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error)
},
})),
]}
/>
)
})
}
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, dialog),
}))}
/>
))
}
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />)
return
}
void beginOAuth(integration, method, dialog)
}
function KeyMethod(props: { integration: IntegrationInfo; method: Extract<ConnectMethod, { type: "key" }> }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
title={props.method.label ?? `Connect ${props.integration.name}`}
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.client.v2.integration.connect
.key(
{
integrationID: props.integration.id,
location: location(data),
key,
},
{ throwOnError: true },
)
.then(() => connected(props.integration.name, data, dialog, toast))
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
)}
/>
)
}
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => <OAuthStarting integration={integration} method={method} inputs={inputs} />)
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
inputs: Record<string, string>
}) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
onMount(() => {
void sdk.client.v2.integration.connect
.oauth(
{
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
inputs: props.inputs,
},
{ throwOnError: true },
)
.then((result) => {
if (result.data.data.mode === "code") {
dialog.replace(() => (
<OAuthCode integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
return
}
dialog.replace(() => (
<OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data.data} />
))
})
.catch((cause) => {
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
})
return <OAuthView title={props.method.label} message="Starting authorization..." />
}
function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const clipboard = useClipboard()
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
useBindings(() => ({
bindings: [
{
key: "c",
desc: "Copy authorization details",
group: "Dialog",
cmd: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
const poll = () => {
void sdk.client.v2.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true })
.then((result) => {
const status = result.data.data
if (status.status === "pending") {
timer = setTimeout(poll, 500)
return
}
settled = true
if (status.status === "complete") {
void connected(props.integration.name, data, dialog, toast)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
dialog.clear()
})
.catch((cause) => {
settled = true
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
}
onMount(poll)
onCleanup(() => {
if (timer) clearTimeout(timer)
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<OAuthView
title={props.title}
url={props.attempt.url}
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
/>
)
}
function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
let settled = false
onCleanup(() => {
if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
})
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.client.v2.integration.attempt
.complete(
{ attemptID: props.attempt.attemptID, location: location(data), code },
{ throwOnError: true },
)
.then(() => {
settled = true
return connected(props.integration.name, data, dialog, toast)
})
.catch((cause) => setError(message(cause)))
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.primary} />
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
</box>
)}
/>
)
}
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
}) {
const dialog = useDialog()
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={theme.primary} />
<Show when={props.instructions}>{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}</Show>
</box>
)}
</Show>
<text fg={theme.textMuted}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>
</box>
)
}
async function promptInputs(
dialog: ReturnType<typeof useDialog>,
prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
) {
const inputs: Record<string, string> = {}
for (const prompt of prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((option) => ({
title: option.label,
value: option.value,
description: option.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
dialog.replace(
() => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}
async function connected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Connected ${name}` })
dialog.clear()
}
async function disconnected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
toast.show({ variant: "success", message: `Disconnected ${name}` })
dialog.clear()
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
}
function message(cause: unknown) {
if (cause instanceof Error) return cause.message
return "Authentication failed"
}

View file

@ -1,22 +1,23 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogIntegration } from "./dialog-integration"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useSync } from "../context/sync"
import { useData } from "../context/data"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const data = useData()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createDialogProviderOptions()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
@ -29,21 +30,20 @@ export function DialogModel(props: { providerID?: string }) {
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
const model = models().find((model) => model.providerID === item.providerID && model.id === item.modelID)
if (!model) return []
const provider = providers().get(model.providerID)
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
value: { providerID: model.providerID, modelID: model.id },
title: model.name,
releaseDate: model.time.released,
description: provider?.name ?? model.providerID,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: free(model) ? "Free" : undefined,
onSelect: () => {
onSelect(provider.id, model.id)
onSelect(model.providerID, model.id)
},
},
]
@ -58,80 +58,48 @@ export function DialogModel(props: { providerID?: string }) {
"Recent",
)
const providerOptions = pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
const modelOptions = sortModelOptions(
models()
.filter((model) => model.status !== "deprecated")
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => ({
value: { providerID: model.providerID, modelID: model.id },
title: model.name,
releaseDate: model.time.released,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
onSelect(model.providerID, model.id)
},
}))
.filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
return false
if (
recents.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
return true
}),
(options) => sortModelOptions(options, props.providerID !== undefined),
),
),
)
return false
if (
recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
)
return false
return true
}),
props.providerID !== undefined,
)
const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
if (needle) {
return [
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
return [...favoriteOptions, ...recentOptions, ...modelOptions]
})
const provider = createMemo(() =>
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
)
const provider = createMemo(() => (props.providerID ? providers().get(props.providerID) : undefined))
const title = createMemo(() => {
const value = provider()
@ -142,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
const cur = local.model.variant.current()
if (cur && list.includes(cur)) {
dialog.clear()
return
}
@ -160,9 +128,9 @@ export function DialogModel(props: { providerID?: string }) {
actions={[
{
command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers",
title: connected() ? "Connect integration" : "View all integrations",
onTrigger() {
dialog.replace(() => <DialogProvider />)
dialog.replace(() => <DialogIntegration />)
},
},
{
@ -195,3 +163,7 @@ export function sortModelOptions<T extends { footer?: string; releaseDate: strin
(option) => option.title,
)
}
function free(model: { cost: Array<{ input: number }> }) {
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
}

View file

@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) {
<DialogPrompt
title={props.title}
placeholder="API key"
description={
{
description={() =>
({
opencode: (
<box gap={1}>
<text fg={theme.textMuted}>
@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) {
</text>
</box>
),
}[props.providerID] ?? undefined
})[props.providerID] ?? undefined
}
onConfirm={async (value) => {
if (!value) return

View file

@ -1,203 +1,53 @@
import { createMemo, createResource, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
import path from "path"
import { useData } from "../context/data"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local"
import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap"
import { useEvent } from "../context/event"
type SessionListFilter = { scope?: "project"; path?: string }
export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
const search = input.search?.trim()
return {
roots: true,
limit: search ? 30 : 100,
...(search ? { search } : {}),
...input.filter,
}
}
export function loadDialogSessionList<T>(input: {
search?: string
filter: SessionListFilter
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
}) {
return input.list(createDialogSessionListQuery(input)).then(
(result) => result.data,
() => undefined,
)
}
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const data = useData()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const event = useEvent()
const local = useLocal()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [deleted, setDeleted] = createSignal(new Set<string>())
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [browseResults, { refetch: refetchBrowse }] = createResource(
() => sync.session.query(),
(filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
)
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
(input) => {
if (!input.query) return undefined
return loadDialogSessionList({
search: input.query,
filter: input.filter,
list: (query) => sdk.client.session.list(query),
})
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
const response = await sdk.client.v2.session.list(
{ search: query, limit: 50, order: "desc" },
{ throwOnError: true },
)
return { query, sessions: response.data.data }
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const result = searchResults() ?? browseResults() ?? sync.data.session
const synced = new Map(sync.data.session.map((session) => [session.id, session]))
const ids = new Set(result.map((session) => session.id))
const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
if (!id || ids.has(id)) return []
const session = synced.get(id)
if (session) ids.add(id)
return session ? [session] : []
})
const query = search().trim().toLowerCase()
return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
.filter((session) => !deleted().has(session.id))
.filter((session) => !query || session.title.toLowerCase().includes(query))
const query = search()
if (!query) return data.session.list()
const result = searchResults()
return result?.query === query ? result.sessions : []
})
onCleanup(
event.on("session.deleted", (event) => {
setDeleted((current) => new Set(current).add(event.properties.info.id))
}),
)
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data
if (!workspace) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
await refetchBrowse()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
if (!first || !last) return
return quickSwitchRange(first, last)
})
const quickSwitchFooterHints = createMemo(() => {
@ -207,149 +57,65 @@ export function DialogSessionList() {
const options = createMemo(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const order = searchResult ? orderByRecency(sessions()) : browseOrder()
const current = currentSessionID()
const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
if (!x) return undefined
const directory = x.path
? x.directory.endsWith(x.path)
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
: undefined
: x.directory
const footer =
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter = isWorking
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: undefined
const option = (session: SessionV2Info, category: string) => {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
title: session.title,
value: session.id,
category,
footer,
gutter,
gutter: slot === undefined ? undefined : () => <text fg={theme.accent}>{slot}</text>,
}
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
const remaining = sessions()
.filter((session) => !session.parentID && !pinnedSet.has(session.id))
.map((session) => {
const date = new Date(session.time.updated).toDateString()
return option(session, date === today ? "Today" : date)
})
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
})
onMount(() => {
dialog.setSize("large")
})
onMount(() => dialog.setSize("large"))
const unavailable = (feature: string) =>
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
return (
<DialogSelect
title="Sessions"
options={options()}
skipFilter={true}
preserveSelection={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
route.navigate({ type: "session", sessionID: option.value })
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
},
{
command: "session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
await refetchBrowse()
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
onTrigger: () => unavailable("Deleting"),
},
{
command: "session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
onTrigger: () => unavailable("Renaming"),
},
]}
footerHints={quickSwitchFooterHints()}

View file

@ -7,32 +7,22 @@ export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() => {
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
const options = createMemo(() =>
local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
})),
)
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.selected()}
current={local.model.variant.current()}
flat={true}
/>
)

View file

@ -400,15 +400,15 @@ export function Autocomplete(props: {
})
const agents = createMemo(() => {
return sync.data.agent
return (data.location.agent.list() ?? [])
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
display: "@" + agent.id,
onSelect: () => {
insertPart(agent.name, {
insertPart(agent.id, {
type: "agent",
name: agent.name,
name: agent.id,
source: {
start: 0,
end: 0,

View file

@ -43,7 +43,8 @@ import { errorMessage } from "../../util/error"
import { formatDuration } from "../../util/format"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
@ -56,6 +57,7 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
export type PromptProps = {
sessionID?: string
@ -205,8 +207,10 @@ export function Prompt(props: PromptProps) {
const [auto, setAuto] = createSignal<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const data = useData()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
@ -215,8 +219,8 @@ export function Prompt(props: PromptProps) {
message: "Connect a provider to send prompts",
duration: 3000,
})
if (sync.data.provider.length === 0) {
dialog.replace(() => <DialogProviderConnect />)
if (!connected()) {
dialog.replace(() => <DialogIntegration />)
}
}
@ -303,28 +307,20 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize agent/model/variant from last user message when session changes
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
const msg = lastUserMessage()
if (sessionID !== syncedSessionID) {
if (!sessionID || !msg) return
syncedSessionID = sessionID
// Only set agent if it's a primary agent (not a subagent)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
// Keep command line --agent if specified.
if (!args.agent) local.agent.set(msg.agent)
if (msg.model) {
local.model.set(msg.model)
local.model.variant.set(msg.model.variant)
}
}
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
const promptCommands = createMemo(() =>
@ -982,6 +978,7 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
if (sessionID == null) {
const selectedWorkspace = workspace.selection()
@ -991,10 +988,9 @@ export function Prompt(props: PromptProps) {
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const res = await sdk.client.session.create({
directory,
workspace: workspaceID,
agent: agent.name,
const res = await sdk.client.v2.session.create({
location: directory ? { directory, workspaceID } : undefined,
agent: agent.id,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
@ -1004,8 +1000,6 @@ export function Prompt(props: PromptProps) {
if (res.error) {
if (finishMoveProgress) move.finishSubmit()
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
@ -1014,7 +1008,8 @@ export function Prompt(props: PromptProps) {
return true
}
sessionID = res.data.id
sessionID = res.data.data.id
session = res.data.data
}
const inputText = expandTrackedPastedText(
@ -1054,7 +1049,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit()
void sdk.client.session.shell({
sessionID,
agent: agent.name,
agent: agent.id,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
@ -1078,39 +1073,72 @@ export function Prompt(props: PromptProps) {
sessionID,
command: command.slice(1),
arguments: args,
agent: agent.name,
agent: agent.id,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
variant,
parts: nonTextParts.filter((x) => x.type === "file"),
})
} else {
move.startSubmit()
sdk.client.session
.prompt(
if (!session) {
await data.session.refresh(sessionID)
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true })
}
if (
session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID ||
session.model.variant !== variant
) {
await sdk.client.v2.session.switchModel(
{
sessionID,
...selectedModel,
agent: agent.name,
model: selectedModel,
variant,
parts: [
...editorParts,
{
type: "text",
text: inputText,
},
...nonTextParts,
],
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
},
{ throwOnError: true },
)
.catch((error) => {
toast.show({
title: "Failed to send prompt",
message: errorMessage(error),
variant: "error",
})
})
}
const result = await sdk.client.v2.session.prompt({
sessionID,
prompt: {
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
files: nonTextParts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: part.source
? {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
: undefined,
},
]
: [],
),
agents: nonTextParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
},
})
if (result.error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" })
return false
}
if (editorParts.length > 0) editor.markSelectionSent()
}
history.append({
@ -1284,7 +1312,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") return theme.primary
const agent = local.agent.current()
if (!agent) return theme.border
return local.agent.color(agent.name)
return local.agent.color(agent.id)
})
const showVariant = createMemo(() => {
@ -1316,9 +1344,9 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => {
const agent =
status().type !== "idle"
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
const color = agent ? local.agent.color(agent.id) : theme.border
return {
frames: createFrames({
color,
@ -1439,7 +1467,7 @@ export function Prompt(props: PromptProps) {
{(agent) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>

View file

@ -0,0 +1,32 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { attempt: number; error?: string }) {
const theme = useTheme().theme
return (
<box
position="absolute"
zIndex={10_000}
top={0}
right={0}
bottom={0}
left={0}
backgroundColor={theme.background}
alignItems="center"
justifyContent="center"
>
<box width={54} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<text fg={theme.text}>Connection lost</text>
<Spinner color={theme.textMuted}>Reconnecting to server...</Spinner>
<text fg={theme.textMuted}>Attempt {props.attempt}</text>
<Show when={props.error}>
<text fg={theme.error} wrapMode="word">
{props.error}
</text>
</Show>
</box>
</box>
)
}

View file

@ -1,12 +1,13 @@
import { createMemo } from "solid-js"
import { useData } from "../context/data"
import { useSync } from "../context/sync"
export function useConnected() {
const data = useData()
const sync = useSync()
return createMemo(() =>
sync.data.provider.some(
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0),
),
return createMemo(
() =>
(data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) ||
sync.data.console_state.consoleManagedProviders.length > 0,
)
}