diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx
index 8b8a3f68c0..4a563c5dc3 100644
--- a/packages/ui/src/components/context-tool-results.tsx
+++ b/packages/ui/src/components/context-tool-results.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js"
+import { createEffect, createMemo, createSignal, For, onMount } from "solid-js"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path"
import { useI18n } from "../context/i18n"
@@ -9,15 +9,13 @@ import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results"
import {
animate,
- type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
- COLLAPSIBLE_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { useSpring } from "./motion-spring"
-import { busy } from "./tool-utils"
+import { busy, updateScrollMask, useCollapsible } from "./tool-utils"
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state
@@ -124,74 +122,15 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
- let heightAnim: AnimationPlaybackControls | undefined
- let fadeAnim: AnimationPlaybackControls | undefined
-
- const FADE = 12
-
const updateMask = () => {
- if (!scrollRef) return
- const { scrollTop, scrollHeight, clientHeight } = scrollRef
- const overflow = scrollHeight - clientHeight
- if (overflow <= 1) {
- scrollRef.style.maskImage = ""
- scrollRef.style.webkitMaskImage = ""
- return
- }
- const top = scrollTop > 1
- const bottom = scrollTop < overflow - 1
- const mask =
- top && bottom
- ? `linear-gradient(to bottom, transparent 0, black ${FADE}px, black calc(100% - ${FADE}px), transparent 100%)`
- : top
- ? `linear-gradient(to bottom, transparent 0, black ${FADE}px)`
- : bottom
- ? `linear-gradient(to bottom, black calc(100% - ${FADE}px), transparent 100%)`
- : ""
- scrollRef.style.maskImage = mask
- scrollRef.style.webkitMaskImage = mask
+ if (scrollRef) updateScrollMask(scrollRef)
}
- createEffect(
- on(
- () => props.expanded,
- (isOpen) => {
- if (!contentRef || !bodyRef) return
- heightAnim?.stop()
- fadeAnim?.stop()
- if (isOpen) {
- contentRef.style.display = ""
- bodyRef.style.opacity = "0"
- bodyRef.style.filter = "blur(2px)"
- const h = bodyRef.getBoundingClientRect().height
- heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
- fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
- heightAnim.finished
- .catch(() => {})
- .then(() => {
- if (!contentRef || !props.expanded) return
- contentRef.style.height = "auto"
- updateMask()
- })
- } else {
- const h = contentRef.getBoundingClientRect().height
- heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
- fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
- heightAnim.finished
- .catch(() => {})
- .then(() => {
- if (!contentRef || props.expanded) return
- contentRef.style.display = "none"
- })
- }
- },
- { defer: true },
- ),
- )
-
- onCleanup(() => {
- heightAnim?.stop()
- fadeAnim?.stop()
+ useCollapsible({
+ content: () => contentRef,
+ body: () => bodyRef,
+ open: () => props.expanded,
+ onOpen: updateMask,
})
return (
diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx
index 19f98c6282..a7ae89f63e 100644
--- a/packages/ui/src/components/shell-rolling-results.tsx
+++ b/packages/ui/src/components/shell-rolling-results.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
@@ -8,9 +8,15 @@ import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
-import { animate, clearFadeStyles, clearMaskStyles, FAST_SPRING, GROW_SPRING, WIPE_MASK } from "./motion"
+import {
+ animate,
+ clearFadeStyles,
+ clearMaskStyles,
+ GROW_SPRING,
+ WIPE_MASK,
+} from "./motion"
import { useSpring } from "./motion-spring"
-import { busy, createThrottledValue, useToolFade } from "./tool-utils"
+import { busy, createThrottledValue, hold, updateScrollMask, useCollapsible, useToolFade } from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
@@ -57,6 +63,118 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) {
)
}
+function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
+ const i18n = useI18n()
+ const fade = 12
+ const rows = 10
+ const rowHeight = 22
+ const max = rows * rowHeight
+
+ let contentRef: HTMLDivElement | undefined
+ let bodyRef: HTMLDivElement | undefined
+ let scrollRef: HTMLDivElement | undefined
+ let topRef: HTMLDivElement | undefined
+ const [copied, setCopied] = createSignal(false)
+ const [cap, setCap] = createSignal(max)
+
+ const updateMask = () => {
+ if (scrollRef) updateScrollMask(scrollRef, fade)
+ }
+
+ const resize = () => {
+ const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
+ setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
+ }
+
+ const measure = () => {
+ resize()
+ return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
+ }
+
+ onMount(() => {
+ resize()
+ if (!topRef) return
+ const obs = new ResizeObserver(resize)
+ obs.observe(topRef)
+ onCleanup(() => obs.disconnect())
+ })
+
+ createEffect(() => {
+ props.cmd
+ props.out
+ queueMicrotask(() => {
+ resize()
+ updateMask()
+ })
+ })
+
+ useCollapsible({
+ content: () => contentRef,
+ body: () => bodyRef,
+ open: () => props.open,
+ measure,
+ onOpen: updateMask,
+ })
+
+ const handleCopy = async (e: MouseEvent) => {
+ e.stopPropagation()
+ const cmd = props.cmd ? `$ ${props.cmd}` : ""
+ const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
+ if (!text) return
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+
+
+
+ $
+ {props.cmd}
+
+
+
+ e.preventDefault()}
+ onClick={handleCopy}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ />
+
+
+
+
+ <>
+
+
+ >
+
+
+
+
+ )
+}
+
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
const i18n = useI18n()
const wiped = new Set()
@@ -66,36 +184,24 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record)
const pending = createMemo(() => busy(props.part.state.status))
- // autoOpen starts true if pending, false if already complete (e.g. scrolling back in history).
- // When pending transitions false, autoOpen is still true — no intermediate state — then
- // a 2s timer sets it false to trigger the collapse. This avoids the flash caused by
- // holdOpen being set in a late-running effect.
- const [autoOpen, setAutoOpen] = createSignal(pending())
- createEffect(
- on(pending, (isPending, wasPending) => {
- if (isPending) {
- setAutoOpen(true)
- } else if (wasPending && !userToggled()) {
- const timer = setTimeout(() => setAutoOpen(false), 2000)
- onCleanup(() => clearTimeout(timer))
- }
- }),
- )
+ const autoOpen = hold(pending, 2000)
const effectiveOpen = createMemo(() => {
if (pending()) return true
if (userToggled()) return userOpen()
return autoOpen()
})
- const subtitle = createMemo(() => {
- const value = state().input?.description ?? state().metadata?.description
- if (typeof value === "string") return value
- return ""
- })
+ const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
+ const previewOpen = createMemo(() => effectiveOpen() && !expanded())
const command = createMemo(() => {
const value = state().input?.command ?? state().metadata?.command
if (typeof value === "string") return value
return ""
})
+ const subtitle = createMemo(() => {
+ const value = state().input?.description ?? state().metadata?.description
+ if (typeof value === "string" && value.trim().length > 0) return value
+ return firstLine(command()) ?? ""
+ })
const output = createMemo(() => {
const value = state().output ?? state().metadata?.output
if (typeof value === "string") return value
@@ -105,9 +211,10 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
const skip = () => reduce() || props.animate === false
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
+ const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
+ const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
let headerClipRef: HTMLDivElement | undefined
- const [copied, setCopied] = createSignal(false)
const handleHeaderClick = () => {
if (pending()) return
const el = headerClipRef
@@ -123,16 +230,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
})
}
}
- const handleCopy = async (e: MouseEvent) => {
- e.stopPropagation()
- const cmd = command()
- const out = stripAnsi(output())
- const content = `$ ${cmd}${out ? "\n" + out : ""}`
- if (!content) return
- await navigator.clipboard.writeText(content)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
const line = createMemo(() => firstLine(command()))
const fixed = createMemo(() => {
const value = line()
@@ -162,21 +259,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
{(text) => }
-
- e.preventDefault()}
- onClick={handleCopy}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
- />
-
@@ -184,59 +266,74 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
- row.id}
- render={(row) => {
- const [textRef, setTextRef] = createSignal()
- createEffect(() => {
- const el = textRef()
- if (!el || !row.text) return
- if (wiped.has(row.id)) return
- wiped.add(row.id)
- if (reduce()) return
- el.style.maskImage = WIPE_MASK
- el.style.webkitMaskImage = WIPE_MASK
- el.style.maskSize = "240% 100%"
- el.style.webkitMaskSize = "240% 100%"
- el.style.maskRepeat = "no-repeat"
- el.style.webkitMaskRepeat = "no-repeat"
- el.style.maskPosition = "100% 0%"
- el.style.webkitMaskPosition = "100% 0%"
- animate(
- el,
- {
- opacity: [0, 1],
- filter: ["blur(2px)", "blur(0px)"],
- transform: ["translateX(-0.06em)", "translateX(0)"],
- maskPosition: "0% 0%",
- },
- GROW_SPRING,
- ).finished.then(() => {
- if (!el) return
- clearFadeStyles(el)
- clearMaskStyles(el)
- })
- })
- return (
-
-
- {row.text}
-
-
- )
+
+ >
+ row.id}
+ render={(row) => {
+ const [textRef, setTextRef] = createSignal()
+ createEffect(() => {
+ const el = textRef()
+ if (!el || !row.text) return
+ if (wiped.has(row.id)) return
+ wiped.add(row.id)
+ if (reduce()) return
+ el.style.maskImage = WIPE_MASK
+ el.style.webkitMaskImage = WIPE_MASK
+ el.style.maskSize = "240% 100%"
+ el.style.webkitMaskSize = "240% 100%"
+ el.style.maskRepeat = "no-repeat"
+ el.style.webkitMaskRepeat = "no-repeat"
+ el.style.maskPosition = "100% 0%"
+ el.style.webkitMaskPosition = "100% 0%"
+ let done = false
+ const clear = () => {
+ if (done) return
+ done = true
+ clearFadeStyles(el)
+ clearMaskStyles(el)
+ }
+ const anim = animate(
+ el,
+ {
+ opacity: [0, 1],
+ filter: ["blur(2px)", "blur(0px)"],
+ transform: ["translateX(-0.06em)", "translateX(0)"],
+ maskPosition: "0% 0%",
+ },
+ GROW_SPRING,
+ )
+ anim.finished.catch(() => {}).finally(clear)
+ onCleanup(() => {
+ anim.stop()
+ clear()
+ })
+ })
+ return (
+
+
+ {row.text}
+
+
+ )
+ }}
+ />
+
+
)
}
diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts
index 060fffd516..973af49c30 100644
--- a/packages/ui/src/components/tool-utils.ts
+++ b/packages/ui/src/components/tool-utils.ts
@@ -1,9 +1,10 @@
-import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
+ COLLAPSIBLE_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
@@ -76,6 +77,90 @@ export function hold(state: () => boolean, wait = 2000) {
return live
}
+export function updateScrollMask(el: HTMLElement, fade = 12) {
+ const { scrollTop, scrollHeight, clientHeight } = el
+ const overflow = scrollHeight - clientHeight
+ if (overflow <= 1) {
+ el.style.maskImage = ""
+ el.style.webkitMaskImage = ""
+ return
+ }
+ const top = scrollTop > 1
+ const bottom = scrollTop < overflow - 1
+ const mask =
+ top && bottom
+ ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
+ : top
+ ? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
+ : bottom
+ ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
+ : ""
+ el.style.maskImage = mask
+ el.style.webkitMaskImage = mask
+}
+
+export function useCollapsible(options: {
+ content: () => HTMLElement | undefined
+ body: () => HTMLElement | undefined
+ open: () => boolean
+ measure?: () => number
+ onOpen?: () => void
+}) {
+ let heightAnim: AnimationPlaybackControls | undefined
+ let fadeAnim: AnimationPlaybackControls | undefined
+
+ createEffect(
+ on(options.open, (isOpen) => {
+ const content = options.content()
+ const body = options.body()
+ if (!content || !body) return
+ heightAnim?.stop()
+ fadeAnim?.stop()
+ if (isOpen) {
+ content.style.display = ""
+ content.style.height = "0px"
+ body.style.opacity = "0"
+ body.style.filter = "blur(2px)"
+ fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
+ queueMicrotask(() => {
+ if (!options.open()) return
+ const c = options.content()
+ if (!c) return
+ const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
+ heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
+ heightAnim.finished
+ .catch(() => {})
+ .then(() => {
+ if (!options.open()) return
+ const el = options.content()
+ if (!el) return
+ el.style.height = "auto"
+ options.onOpen?.()
+ })
+ })
+ return
+ }
+
+ const h = content.getBoundingClientRect().height
+ heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
+ fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
+ heightAnim.finished
+ .catch(() => {})
+ .then(() => {
+ if (options.open()) return
+ const el = options.content()
+ if (!el) return
+ el.style.display = "none"
+ })
+ }, { defer: true }),
+ )
+
+ onCleanup(() => {
+ heightAnim?.stop()
+ fadeAnim?.stop()
+ })
+}
+
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
const [settled, setSettled] = createSignal(false)