first pass

This commit is contained in:
James Long 2026-05-01 15:22:38 -04:00
parent 375444a149
commit 38129f3663
9 changed files with 556 additions and 272 deletions

View file

@ -1,6 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {},
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",

View file

@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
import { WorkspaceLabel } from "./workspace-label"
export function DialogSessionList() {
const dialog = useDialog()
@ -44,23 +42,6 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
@ -124,30 +105,18 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
let footer: JSX.Element | string = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
icon
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" icon />
)
}
} else {
@ -250,15 +219,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)

View file

@ -1,14 +1,13 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { WorkspaceLabel } from "./workspace-label"
type Adaptor = {
type: string
@ -16,54 +15,24 @@ type Adaptor = {
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
export type WorkspaceSelection =
| {
type: "none"
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
| {
type: "new"
workspaceType: string
workspaceName: string
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } | { type: "loading" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
@ -101,13 +70,11 @@ export async function restoreWorkspaceSession(input: {
input.dialog.clear()
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
function DialogWorkspaceTypeSelect(props: { onSelect: (adaptor: Adaptor) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
@ -131,6 +98,185 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})()
})
const options = createMemo(() => {
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: undefined,
description: "Fetching available workspace adaptors",
},
]
}
return list.map((item) => ({
title: item.name,
value: item,
description: item.description,
}))
})
return (
<DialogSelect
title="New Workspace"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={async (option) => {
if (!option.value) return
void props.onSelect(option.value)
}}
/>
)
}
export function DialogWorkspaceSelect(props: {
current?: WorkspaceSelection
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const project = useProject()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
})
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: { type: "loading" as const },
description: "Fetching available workspace adaptors",
category: "New workspace",
},
]
}
const workspaces = project.workspace.list()
return [
...list.map((adaptor) => ({
title: adaptor.name,
value: { type: "new" as const, workspaceType: adaptor.type, workspaceName: adaptor.name },
description: adaptor.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...workspaces.slice(0, 3).map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
category: "Choose workspace",
})),
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
})
return (
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
current={props.current}
onSelect={(option) => {
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
@ -142,21 +288,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
},
]
}
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adaptors",
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
return []
})
const create = async (type: string) => {
@ -186,14 +318,12 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
return creating() ? (
<DialogSelect title="Creating Workspace" skipFilter={true} renderFilter={false} options={options()} />
) : (
<DialogWorkspaceTypeSelect
onSelect={(adaptor) => {
void create(adaptor.type)
}}
/>
)

View file

@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
@ -40,13 +41,23 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import {
DialogWorkspaceCreate,
DialogWorkspaceSelect,
restoreWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
workspaceID?: string
workspaceSelection?: WorkspaceSelection
onWorkspaceSelectionChange?: (selection: WorkspaceSelection | undefined) => void
onWorkspaceCreatingChange?: (creating: boolean) => void
visible?: boolean
disabled?: boolean
onSubmit?: () => void
@ -149,8 +160,31 @@ export function Prompt(props: PromptProps) {
})
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const selectedWorkspace = () => props.workspaceSelection ?? workspaceSelection()
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
props.onWorkspaceSelectionChange?.(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
props.onWorkspaceCreatingChange?.(creating)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
@ -184,8 +218,9 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
if (!input || input.isDestroyed) return
if (props.disabled || workspaceCreating()) input.cursorColor = theme.backgroundElement
if (!props.disabled && !workspaceCreating()) input.cursorColor = theme.text
})
const lastUserMessage = createMemo(() => {
@ -450,6 +485,27 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogWorkspaceSelect
current={selectedWorkspace()}
onSelect={(selection) => {
selectWorkspace(selection)
dialog.clear()
}}
/>
))
},
},
]
})
@ -507,7 +563,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
if (props.visible === false || dialog.stack.length > 0 || workspaceCreating()) {
if (input.focused) input.blur()
return
}
@ -527,13 +583,13 @@ export function Prompt(props: PromptProps) {
: undefined
input.traits = {
capture,
suspend: !!props.disabled || store.mode === "shell",
suspend: !!props.disabled || workspaceCreating() || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
if (!input.isDestroyed) input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
@ -673,7 +729,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (props.disabled || workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@ -717,24 +773,6 @@ export function Prompt(props: PromptProps) {
return false
}
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({ workspace: props.workspaceID })
if (res.error) {
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
const messageID = MessageID.ascending()
let inputText = store.prompt.input
@ -759,6 +797,7 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const submittedPrompt = unwrap(store.prompt)
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
@ -794,6 +833,53 @@ export function Prompt(props: PromptProps) {
]
: []
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = selectedWorkspace()
if (workspace?.type === "new") {
setCreatingWorkspace(true)
await new Promise((resolve) => setTimeout(resolve, 10_000))
}
const workspaceID = await iife(async () => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: workspace.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) return undefined
await project.workspace.sync()
return result.data.id
})
if (workspace?.type === "new" && !workspaceID) {
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return true
}
const res = await sdk.client.session.create({ workspace: workspaceID })
if (res.error) {
setCreatingWorkspace(false)
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
if (store.mode === "shell") {
void sdk.client.session.shell({
sessionID,
@ -858,7 +944,7 @@ export function Prompt(props: PromptProps) {
lastSubmittedEditorSelectionKey = editorSelectionKey
}
history.append({
...store.prompt,
...submittedPrompt,
mode: currentMode,
})
input.extmarks.clear()
@ -877,7 +963,7 @@ export function Prompt(props: PromptProps) {
sessionID,
})
}, 50)
input.clear()
if (!input.isDestroyed) input.clear()
return true
}
const exit = useExit()
@ -998,6 +1084,28 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = selectedWorkspace()
if (!selected) return
if (selected.type === "none") return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
@ -1076,7 +1184,7 @@ export function Prompt(props: PromptProps) {
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
if (props.disabled) {
if (props.disabled || workspaceCreating()) {
e.preventDefault()
return
}
@ -1159,7 +1267,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
if (props.disabled || workspaceCreating()) {
event.preventDefault()
return
}
@ -1254,7 +1362,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
cursorColor={workspaceCreating() ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@ -1324,86 +1432,117 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<Switch>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating()) return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
)}
</Match>
<Match when={true}>
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>

View file

@ -0,0 +1,19 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}

View file

@ -9,6 +9,7 @@ import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import type { WorkspaceSelection } from "../component/dialog-workspace-create"
let once = false
const placeholder = {
@ -22,10 +23,28 @@ export function Home() {
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const args = useArgs()
const local = useLocal()
let sent = false
const currentWorkspaceSelection = (): WorkspaceSelection | undefined => {
const workspaceID = project.workspace.current()
if (!workspaceID) return { type: "none" }
const workspace = project.workspace.get(workspaceID)
return {
type: "existing",
workspaceID,
workspaceType: workspace?.type ?? "unknown",
workspaceName: workspace?.name ?? workspaceID,
}
}
createEffect(() => {
if (workspaceSelection()) return
setWorkspaceSelection(currentWorkspaceSelection())
})
const bind = (r: PromptRef | undefined) => {
setRef(r)
promptRef.set(r)
@ -73,6 +92,8 @@ export function Home() {
<Prompt
ref={bind}
workspaceID={project.workspace.current()}
workspaceSelection={workspaceSelection()}
onWorkspaceSelectionChange={setWorkspaceSelection}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder}
/>

View file

@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspace = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
</text>
</Show>
<Show when={session()!.share?.url}>

View file

@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
title: string
@ -35,6 +36,7 @@ export interface DialogSelectProps<T> {
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
value: T
description?: string
footer?: JSX.Element | string
@ -81,7 +83,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const filtered = createMemo(() => {
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
@ -250,30 +252,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc
</text>
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
</box>
<Show
when={grouped().length > 0}
@ -347,6 +351,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
@ -403,6 +408,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function Option(props: {
title: string
titleView?: JSX.Element
description?: string
active?: boolean
current?: boolean
@ -433,7 +439,9 @@ function Option(props: {
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
<Show when={props.titleView} fallback={Locale.truncate(props.title, 61)}>
{(titleView) => titleView()}
</Show>
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>

View file

@ -518,6 +518,7 @@ export const layer = Layer.effect(
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
// Update the local copy to point to the new workspace
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,