feat(desktop): show write tool output

This commit is contained in:
Adam 2025-12-16 10:49:22 -06:00
parent ebefb26e8f
commit ff690350b1
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
6 changed files with 311 additions and 277 deletions

View file

@ -5,7 +5,9 @@ import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
@ -39,41 +41,43 @@ export function App() {
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
<CodeComponentProvider component={Code}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>

View file

@ -1,4 +1,5 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
import { Dynamic } from "solid-js/web"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@ -11,7 +12,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { SessionReview } from "@opencode-ai/ui/session-review"
@ -48,6 +49,7 @@ export default function Page() {
const sync = useSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const params = useParams()
const navigate = useNavigate()
@ -764,7 +766,8 @@ export default function Page() {
<Switch>
<Match when={file()}>
{(f) => (
<Code
<Dynamic
component={codeComponent}
file={{
name: f().path,
contents: f().content?.content ?? "",

View file

@ -3,6 +3,7 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@ -27,6 +28,7 @@ import { Meta } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
@ -196,239 +198,254 @@ export default function () {
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<DiffComponentProvider component={ClientOnlyDiff}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
<CodeComponentProvider component={ClientOnlyCode}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<ProviderIcon id={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
/>
)}
</For>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{ "hidden w-full flex-1 min-h-0": true, "md:flex": wide(), "lg:flex": !wide() }}
>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container:
"w-full pb-20 " +
(wide()
? "max-w-146 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
}}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container:
"w-full pb-20 " +
(wide()
? "max-w-146 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}>
{turns()}
</div>
</Match>
</Switch>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
</div>
</div>
)
})}
</DataProvider>
)
})}
</DataProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</>
)

View file

@ -11,6 +11,7 @@ import {
} from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Card } from "./card"
@ -19,6 +20,7 @@ import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
export interface MessageProps {
message: MessageType
@ -488,9 +490,10 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
console.log(props)
const codeComponent = useCodeComponent()
return (
<BasicTool
defaultOpen
icon="code-lines"
trigger={
<div data-component="write-trigger">
@ -507,19 +510,19 @@ ToolRegistry.register({
</div>
}
>
{/* <Show when={props.input.content}> */}
{/* <div data-component="write-content"> */}
{/* <Code */}
{/* file={{ */}
{/* name: props.input.filePath, */}
{/* contents: props.input.content, */}
{/* cacheKey: checksum(props.input.content), */}
{/* }} */}
{/* overflow="scroll" */}
{/* class="pb-40" */}
{/* /> */}
{/* </div> */}
{/* </Show> */}
<Show when={props.input.content}>
<div data-component="write-content">
<Dynamic
component={codeComponent}
file={{
name: props.input.filePath,
contents: props.input.content,
cacheKey: checksum(props.input.content),
}}
overflow="scroll"
/>
</div>
</Show>
</BasicTool>
)
},

View file

@ -0,0 +1,10 @@
import type { ValidComponent } from "solid-js"
import { createSimpleContext } from "./helper"
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
name: "CodeComponent",
init: (props) => props.component,
})
export const CodeComponentProvider = ctx.provider
export const useCodeComponent = ctx.use

View file

@ -1,13 +1,10 @@
import { createContext, useContext, type ParentProps, type ValidComponent } from "solid-js"
import type { ValidComponent } from "solid-js"
import { createSimpleContext } from "./helper"
const DiffComponentContext = createContext<ValidComponent>()
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
name: "DiffComponent",
init: (props) => props.component,
})
export function DiffComponentProvider(props: ParentProps<{ component: ValidComponent }>) {
return <DiffComponentContext.Provider value={props.component}>{props.children}</DiffComponentContext.Provider>
}
export function useDiffComponent() {
const component = useContext(DiffComponentContext)
if (!component) throw new Error("DiffComponentProvider must be used to provide a diff component")
return component
}
export const DiffComponentProvider = ctx.provider
export const useDiffComponent = ctx.use