Compare commits

...

3 Commits

4 changed files with 259 additions and 373 deletions

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "opencode", "name": "opencode",
"description": "AI-powered development tool", "description": "AI-powered coding assistant that generates perfect code most of the time",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.10", "packageManager": "bun@1.3.10",

View File

@ -814,208 +814,204 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId} agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId} promptPartTypeId={() => promptPartTypeId}
/> />
<box ref={(r) => (anchor = r)} visible={props.visible !== false}> <box
<box ref={(r) => (anchor = r)}
border={["left"]} visible={props.visible !== false}
borderColor={highlight()} backgroundColor={theme.backgroundElement}
customBorderChars={{ paddingBottom={1}
...EmptyBorder, paddingLeft={1}
vertical: "┃", paddingRight={2}
bottomLeft: "╹", border={["left"]}
}} borderColor={highlight()}
> customBorderChars={{
<box ...EmptyBorder,
paddingLeft={2} vertical: "▎",
paddingRight={2} }}
paddingTop={1} >
flexShrink={0} <box paddingTop={1} flexShrink={0} flexGrow={1}>
backgroundColor={theme.backgroundElement} <textarea
flexGrow={1} placeholder={placeholderText()}
> textColor={keybind.leader ? theme.textMuted : theme.text}
<textarea focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
placeholder={placeholderText()} minHeight={1}
textColor={keybind.leader ? theme.textMuted : theme.text} maxHeight={6}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text} onContentChange={() => {
minHeight={1} const value = input.plainText
maxHeight={6} setStore("prompt", "input", value)
onContentChange={() => { autocomplete.onInput(value)
const value = input.plainText syncExtmarksWithPromptParts()
setStore("prompt", "input", value) }}
autocomplete.onInput(value) keyBindings={textareaKeybindings()}
syncExtmarksWithPromptParts() onKeyDown={async (e) => {
}} if (props.disabled) {
keyBindings={textareaKeybindings()} e.preventDefault()
onKeyDown={async (e) => { return
if (props.disabled) { }
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
// This is needed because Windows terminal doesn't properly send image data
// through bracketed paste, so we need to intercept the keypress and
// directly read from clipboard before the terminal handles it
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault() e.preventDefault()
return await pasteImage({
} filename: "clipboard",
// Handle clipboard paste (Ctrl+V) - check for images first on Windows mime: content.mime,
// This is needed because Windows terminal doesn't properly send image data content: content.data,
// through bracketed paste, so we need to intercept the keypress and
// directly read from clipboard before the terminal handles it
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
}) })
setStore("extmarkToPartIndex", new Map())
return return
} }
if (keybind.match("app_exit", e)) { // If no image, let the default paste behavior continue
if (store.prompt.input === "") { }
await exit() if (keybind.match("input_clear", e) && store.prompt.input !== "") {
// Don't preventDefault - let textarea potentially handle the event input.clear()
e.preventDefault() input.extmarks.clear()
return setStore("prompt", {
} input: "",
} parts: [],
if (e.name === "!" && input.visualCursor.offset === 0) { })
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length)) setStore("extmarkToPartIndex", new Map())
setStore("mode", "shell") return
}
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault() e.preventDefault()
return return
} }
if (store.mode === "shell") { }
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "normal") setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
e.preventDefault() setStore("mode", "shell")
return e.preventDefault()
} return
} }
if (store.mode === "normal") autocomplete.onKeyDown(e) if (store.mode === "shell") {
if (!autocomplete.visible) { if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
if ( setStore("mode", "normal")
(keybind.match("history_previous", e) && input.cursorOffset === 0) || e.preventDefault()
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
) {
const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return return
} }
}
// Normalize line endings at the boundary if (store.mode === "normal") autocomplete.onKeyDown(e)
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste if (!autocomplete.visible) {
// Replace CRLF first, then any remaining CR
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
return
}
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/")) {
event.preventDefault()
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ( if (
(lineCount >= 3 || pastedContent.length > 150) && (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
!sync.data.config.experimental?.disable_paste_summary (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
) { ) {
event.preventDefault() const direction = keybind.match("history_previous", e) ? -1 : 1
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return return
} }
// Force layout update and render for the pasted content if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
setTimeout(() => { if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
// setTimeout is a workaround and needs to be addressed properly input.cursorOffset = input.plainText.length
if (!input || input.isDestroyed) return }
input.getLayoutNode().markDirty() }}
renderer.requestRender() onSubmit={submit}
}, 0) onPaste={async (event: PasteEvent) => {
}} if (props.disabled) {
ref={(r: TextareaRenderable) => { event.preventDefault()
input = r return
if (promptPartTypeId === 0) { }
promptPartTypeId = input.extmarks.registerType("prompt-part")
} // Normalize line endings at the boundary
props.ref?.(ref) // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
setTimeout(() => { // Replace CRLF first, then any remaining CR
// setTimeout is a workaround and needs to be addressed properly const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
if (!input || input.isDestroyed) return const pastedContent = normalizedText.trim()
input.cursorColor = theme.text if (!pastedContent) {
}, 0) command.trigger("prompt.paste")
}} return
onMouseDown={(r: MouseEvent) => r.target?.focus()} }
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text} // trim ' from the beginning and end of the pasted content. just
syntaxStyle={syntax()} // ' and nothing else
/> const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}> const isUrl = /^(https?):\/\//.test(filepath)
<text fg={highlight()}> if (!isUrl) {
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} try {
</text> const mime = Filesystem.mimeType(filepath)
<Show when={store.mode === "normal"}> const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/")) {
event.preventDefault()
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary
) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}}
ref={(r: TextareaRenderable) => {
input = r
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<Switch>
<Match when={store.mode === "normal"}>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={highlight()}>{Locale.titlecase(local.agent.current().name)} </text>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model} {local.model.parsed().model}
</text> </text>
@ -1027,141 +1023,29 @@ export function Prompt(props: PromptProps) {
</text> </text>
</Show> </Show>
</box> </box>
</Show> <Show when={status().type !== "retry"}>
</box> <box gap={2} flexDirection="row">
</box> <Show when={local.model.variant.list().length > 0}>
</box> <text fg={theme.text}>
<box {keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
height={1} </text>
border={["left"]} </Show>
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={<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()) {
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>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}> <text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span> {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text> </text>
</Show> <text fg={theme.text}>
<text fg={theme.text}> {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span> </text>
</text> </box>
<text fg={theme.text}> </Show>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> </Match>
</text> <Match when={store.mode === "shell"}>
</Match> <text fg={highlight()}>
<Match when={store.mode === "shell"}> {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
<text fg={theme.text}> </text>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span> </Match>
</text> </Switch>
</Match> </box>
</Switch>
</box>
</Show>
</box> </box>
</box> </box>
</> </>

View File

@ -93,9 +93,6 @@ export function Header() {
paddingBottom={1} paddingBottom={1}
paddingLeft={2} paddingLeft={2}
paddingRight={1} paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
flexShrink={0} flexShrink={0}
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
> >

View File

@ -16,7 +16,7 @@ import { Dynamic } from "solid-js/web"
import path from "path" import path from "path"
import { useRoute, useRouteData } from "@tui/context/route" import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border" import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
import { import {
@ -1046,7 +1046,12 @@ export function Session() {
}} }}
> >
<box flexDirection="row"> <box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}> <box
flexGrow={1}
gap={1}
paddingX={sidebarVisible() && wide() ? 2 : 0}
paddingY={sidebarVisible() && wide() ? 1 : 0}
>
<Show when={session()}> <Show when={session()}>
<Show when={showHeader() && (!sidebarVisible() || !wide())}> <Show when={showHeader() && (!sidebarVisible() || !wide())}>
<Header /> <Header />
@ -1057,7 +1062,7 @@ export function Session() {
paddingRight: showScrollbar() ? 1 : 0, paddingRight: showScrollbar() ? 1 : 0,
}} }}
verticalScrollbarOptions={{ verticalScrollbarOptions={{
paddingLeft: 1, paddingLeft: 0,
visible: showScrollbar(), visible: showScrollbar(),
trackOptions: { trackOptions: {
backgroundColor: theme.backgroundElement, backgroundColor: theme.backgroundElement,
@ -1251,13 +1256,7 @@ function UserMessage(props: {
return ( return (
<> <>
<Show when={text()}> <Show when={text()}>
<box <box id={props.message.id} marginTop={props.index === 0 ? 0 : 1}>
id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
<box <box
onMouseOver={() => { onMouseOver={() => {
setHover(true) setHover(true)
@ -1268,9 +1267,15 @@ function UserMessage(props: {
onMouseUp={props.onMouseUp} onMouseUp={props.onMouseUp}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2} backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundMenu}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} paddingLeft={1}
flexShrink={0} flexShrink={0}
border={["left"]}
borderColor={color()}
customBorderChars={{
...EmptyBorder,
vertical: "▎",
}}
> >
<text fg={theme.text}>{text()?.text}</text> <text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}> <Show when={files().length}>
@ -1331,6 +1336,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const final = createMemo(() => { const final = createMemo(() => {
if (props.message.error) return true
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
}) })
@ -1342,6 +1348,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created return props.message.time.completed - user.time.created
}) })
const interrupted = createMemo(() => props.message.error?.name === "MessageAbortedError")
const keybind = useKeybind() const keybind = useKeybind()
return ( return (
@ -1362,14 +1370,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
}} }}
</For> </For>
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}> <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}> <box paddingTop={1} paddingLeft={2}>
<text fg={theme.text}> <text fg={theme.text}>
{keybind.print("session_child_first")} {keybind.print("session_child_first")}
<span style={{ fg: theme.textMuted }}> view subagents</span> <span style={{ fg: theme.textMuted }}> view subagents</span>
</text> </text>
</box> </box>
</Show> </Show>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}> <Show when={props.message.error && !interrupted()}>
<box <box
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
@ -1384,28 +1392,26 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box> </box>
</Show> </Show>
<Switch> <Switch>
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}> <Match when={props.last || final()}>
<box paddingLeft={3}> <box paddingLeft={2} marginTop={1}>
<text marginTop={1}> <Show when={!duration()}>
<span <Spinner color={local.agent.color(props.message.agent)}>
style={{ <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
fg: <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
props.message.error?.name === "MessageAbortedError" </Spinner>
? theme.textMuted </Show>
: local.agent.color(props.message.agent), <Show when={duration()}>
}} <text fg={interrupted() ? theme.textMuted : theme.text}>
> <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
{" "} <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
</span>{" "} <Show when={!interrupted()}>
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span> <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span> </Show>
<Show when={duration()}> <Show when={interrupted()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span> <span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show> </Show>
<Show when={props.message.error?.name === "MessageAbortedError"}> </text>
<span style={{ fg: theme.textMuted }}> · interrupted</span> </Show>
</Show>
</text>
</box> </box>
</Match> </Match>
</Switch> </Switch>
@ -1457,7 +1463,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
return ( return (
<Show when={props.part.text.trim()}> <Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}> <box id={"text-" + props.part.id} paddingLeft={2} marginTop={1} flexShrink={0}>
<Switch> <Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}> <Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown <markdown
@ -1625,7 +1631,7 @@ function GenericTool(props: ToolProps<any>) {
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
const { theme } = useTheme() const { theme } = useTheme()
return ( return (
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}> <text paddingLeft={2} fg={props.when ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}> <Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children} <span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show> </Show>
@ -1676,7 +1682,7 @@ function InlineTool(props: {
return ( return (
<box <box
marginTop={margin()} marginTop={margin()}
paddingLeft={3} paddingLeft={2}
onMouseOver={() => props.onClick && setHover(true)} onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onMouseUp={() => { onMouseUp={() => {
@ -1711,7 +1717,7 @@ function InlineTool(props: {
<Spinner color={fg()} children={props.children} /> <Spinner color={fg()} children={props.children} />
</Match> </Match>
<Match when={true}> <Match when={true}>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}> <text paddingLeft={2} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}> <Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children} <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show> </Show>
@ -1738,15 +1744,13 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return ( return (
<box <box
border={["left"]}
paddingTop={1} paddingTop={1}
marginTop={1}
paddingBottom={1} paddingBottom={1}
paddingLeft={2} paddingLeft={2}
marginTop={1} paddingRight={2}
gap={1} gap={1}
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
onMouseOver={() => props.onClick && setHover(true)} onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)} onMouseOut={() => setHover(false)}
onMouseUp={() => { onMouseUp={() => {
@ -1757,7 +1761,7 @@ function BlockTool(props: {
<Show <Show
when={props.spinner} when={props.spinner}
fallback={ fallback={
<text paddingLeft={3} fg={theme.textMuted}> <text paddingLeft={2} fg={theme.textMuted}>
{props.title} {props.title}
</text> </text>
} }
@ -1905,8 +1909,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
</InlineTool> </InlineTool>
<For each={loaded()}> <For each={loaded()}>
{(filepath) => ( {(filepath) => (
<box paddingLeft={3}> <box paddingLeft={2}>
<text paddingLeft={3} fg={theme.textMuted}> <text paddingLeft={2} fg={theme.textMuted}>
Loaded {normalizePath(filepath)} Loaded {normalizePath(filepath)}
</text> </text>
</box> </box>
@ -2056,7 +2060,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
<Switch> <Switch>
<Match when={props.metadata.diff !== undefined}> <Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}> <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}> <box>
<diff <diff
diff={diffContent()} diff={diffContent()}
view={view()} view={view()}
@ -2066,6 +2070,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
width="100%" width="100%"
wrapMode={ctx.diffWrapMode()} wrapMode={ctx.diffWrapMode()}
fg={theme.text} fg={theme.text}
bg={theme.background}
addedBg={theme.diffAddedBg} addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg} removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg} contextBg={theme.diffContextBg}