Browse Source

refactor: simplify TUI layout and update package description

Dax Raad 4 tháng trước cách đây
mục cha
commit
572fab3743

+ 1 - 1
package.json

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

+ 194 - 316
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -814,208 +814,198 @@ export function Prompt(props: PromptProps) {
         agentStyleId={agentStyleId}
         promptPartTypeId={() => promptPartTypeId}
       />
-      <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
-        <box
-          border={["left"]}
-          borderColor={highlight()}
-          customBorderChars={{
-            ...EmptyBorder,
-            vertical: "┃",
-            bottomLeft: "╹",
-          }}
-        >
-          <box
-            paddingLeft={2}
-            paddingRight={2}
-            paddingTop={1}
-            flexShrink={0}
-            backgroundColor={theme.backgroundElement}
-            flexGrow={1}
-          >
-            <textarea
-              placeholder={placeholderText()}
-              textColor={keybind.leader ? theme.textMuted : theme.text}
-              focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
-              minHeight={1}
-              maxHeight={6}
-              onContentChange={() => {
-                const value = input.plainText
-                setStore("prompt", "input", value)
-                autocomplete.onInput(value)
-                syncExtmarksWithPromptParts()
-              }}
-              keyBindings={textareaKeybindings()}
-              onKeyDown={async (e) => {
-                if (props.disabled) {
+      <box
+        ref={(r) => (anchor = r)}
+        visible={props.visible !== false}
+        backgroundColor={theme.backgroundElement}
+        paddingBottom={1}
+        paddingLeft={2}
+        paddingRight={2}
+      >
+        <box paddingTop={1} flexShrink={0} flexGrow={1}>
+          <textarea
+            placeholder={placeholderText()}
+            textColor={keybind.leader ? theme.textMuted : theme.text}
+            focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
+            minHeight={1}
+            maxHeight={6}
+            onContentChange={() => {
+              const value = input.plainText
+              setStore("prompt", "input", value)
+              autocomplete.onInput(value)
+              syncExtmarksWithPromptParts()
+            }}
+            keyBindings={textareaKeybindings()}
+            onKeyDown={async (e) => {
+              if (props.disabled) {
+                e.preventDefault()
+                return
+              }
+              // 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()
-                  return
-                }
-                // 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()
-                    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: [],
+                  await pasteImage({
+                    filename: "clipboard",
+                    mime: content.mime,
+                    content: content.data,
                   })
-                  setStore("extmarkToPartIndex", new Map())
                   return
                 }
-                if (keybind.match("app_exit", e)) {
-                  if (store.prompt.input === "") {
-                    await exit()
-                    // Don't preventDefault - let textarea potentially handle the event
-                    e.preventDefault()
-                    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
+              }
+              if (keybind.match("app_exit", e)) {
+                if (store.prompt.input === "") {
+                  await exit()
+                  // Don't preventDefault - let textarea potentially handle the event
+                  e.preventDefault()
+                  return
                 }
-                if (e.name === "!" && input.visualCursor.offset === 0) {
-                  setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
-                  setStore("mode", "shell")
+              }
+              if (e.name === "!" && input.visualCursor.offset === 0) {
+                setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
+                setStore("mode", "shell")
+                e.preventDefault()
+                return
+              }
+              if (store.mode === "shell") {
+                if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
+                  setStore("mode", "normal")
                   e.preventDefault()
                   return
                 }
-                if (store.mode === "shell") {
-                  if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
-                    setStore("mode", "normal")
+              }
+              if (store.mode === "normal") autocomplete.onKeyDown(e)
+              if (!autocomplete.visible) {
+                if (
+                  (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
+                  (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()
-                    return
-                  }
-                }
-                if (store.mode === "normal") autocomplete.onKeyDown(e)
-                if (!autocomplete.visible) {
-                  if (
-                    (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
-                    (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 (direction === -1) input.cursorOffset = 0
+                    if (direction === 1) input.cursorOffset = input.plainText.length
                   }
-
-                  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
                 }
 
-                // Normalize line endings at the boundary
-                // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
-                // 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
-                }
+                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
+              }
 
-                // 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
-                      }
+              // Normalize line endings at the boundary
+              // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
+              // 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
-                      }
+                  }
+                  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 {}
-                }
+                  }
+                } 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
-                }
+              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}>
-              <text fg={highlight()}>
-                {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
-              </text>
-              <Show when={store.mode === "normal"}>
+              // 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}>
+                  <text fg={highlight()}>{Locale.titlecase(local.agent.current().name)} </text>
                   <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
                     {local.model.parsed().model}
                   </text>
@@ -1027,141 +1017,29 @@ export function Prompt(props: PromptProps) {
                     </text>
                   </Show>
                 </box>
-              </Show>
-            </box>
-          </box>
-        </box>
-        <box
-          height={1}
-          border={["left"]}
-          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}>
+                <Show when={status().type !== "retry"}>
+                  <box gap={2} flexDirection="row">
+                    <Show when={local.model.variant.list().length > 0}>
+                      <text fg={theme.text}>
+                        {keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
+                      </text>
+                    </Show>
                     <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>
-                  </Show>
-                  <text fg={theme.text}>
-                    {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
-                  </text>
-                  <text fg={theme.text}>
-                    {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
-                  </text>
-                </Match>
-                <Match when={store.mode === "shell"}>
-                  <text fg={theme.text}>
-                    esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
-                  </text>
-                </Match>
-              </Switch>
-            </box>
-          </Show>
+                    <text fg={theme.text}>
+                      {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
+                    </text>
+                  </box>
+                </Show>
+              </Match>
+              <Match when={store.mode === "shell"}>
+                <text fg={highlight()}>
+                  {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
+                </text>
+              </Match>
+            </Switch>
+          </box>
         </box>
       </box>
     </>

+ 0 - 3
packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

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

+ 14 - 22
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1046,7 +1046,7 @@ export function Session() {
       }}
     >
       <box flexDirection="row">
-        <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
+        <box flexGrow={1} gap={1}>
           <Show when={session()}>
             <Show when={showHeader() && (!sidebarVisible() || !wide())}>
               <Header />
@@ -1251,13 +1251,7 @@ function UserMessage(props: {
   return (
     <>
       <Show when={text()}>
-        <box
-          id={props.message.id}
-          border={["left"]}
-          borderColor={color()}
-          customBorderChars={SplitBorder.customBorderChars}
-          marginTop={props.index === 0 ? 0 : 1}
-        >
+        <box id={props.message.id} marginTop={props.index === 0 ? 0 : 1}>
           <box
             onMouseOver={() => {
               setHover(true)
@@ -1362,7 +1356,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
         }}
       </For>
       <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}>
             {keybind.print("session_child_first")}
             <span style={{ fg: theme.textMuted }}> view subagents</span>
@@ -1385,7 +1379,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
       </Show>
       <Switch>
         <Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
-          <box paddingLeft={3}>
+          <box paddingLeft={2}>
             <text marginTop={1}>
               <span
                 style={{
@@ -1457,7 +1451,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
   const { theme, syntax } = useTheme()
   return (
     <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>
           <Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
             <markdown
@@ -1625,7 +1619,7 @@ function GenericTool(props: ToolProps<any>) {
 function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
   const { theme } = useTheme()
   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}>
         <span style={{ bold: true }}>{props.icon}</span> {props.children}
       </Show>
@@ -1676,7 +1670,7 @@ function InlineTool(props: {
   return (
     <box
       marginTop={margin()}
-      paddingLeft={3}
+      paddingLeft={2}
       onMouseOver={() => props.onClick && setHover(true)}
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
@@ -1711,7 +1705,7 @@ function InlineTool(props: {
           <Spinner color={fg()} children={props.children} />
         </Match>
         <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}>
               <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
             </Show>
@@ -1738,15 +1732,12 @@ function BlockTool(props: {
   const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
   return (
     <box
-      border={["left"]}
       paddingTop={1}
       paddingBottom={1}
       paddingLeft={2}
       marginTop={1}
       gap={1}
-      backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
-      customBorderChars={SplitBorder.customBorderChars}
-      borderColor={theme.background}
+      backgroundColor={hover() ? theme.backgroundMenu : undefined}
       onMouseOver={() => props.onClick && setHover(true)}
       onMouseOut={() => setHover(false)}
       onMouseUp={() => {
@@ -1757,7 +1748,7 @@ function BlockTool(props: {
       <Show
         when={props.spinner}
         fallback={
-          <text paddingLeft={3} fg={theme.textMuted}>
+          <text paddingLeft={2} fg={theme.textMuted}>
             {props.title}
           </text>
         }
@@ -1905,8 +1896,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
       </InlineTool>
       <For each={loaded()}>
         {(filepath) => (
-          <box paddingLeft={3}>
-            <text paddingLeft={3} fg={theme.textMuted}>
+          <box paddingLeft={2}>
+            <text paddingLeft={2} fg={theme.textMuted}>
               ↳ Loaded {normalizePath(filepath)}
             </text>
           </box>
@@ -2056,7 +2047,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
     <Switch>
       <Match when={props.metadata.diff !== undefined}>
         <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
-          <box paddingLeft={1}>
+          <box>
             <diff
               diff={diffContent()}
               view={view()}
@@ -2066,6 +2057,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
               width="100%"
               wrapMode={ctx.diffWrapMode()}
               fg={theme.text}
+              bg={theme.background}
               addedBg={theme.diffAddedBg}
               removedBg={theme.diffRemovedBg}
               contextBg={theme.diffContextBg}