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"
import { useI18n } from "../context/i18n"
import { RollingResults } from "./rolling-results"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import {
busy,
createThrottledValue,
hold,
updateScrollMask,
useCollapsible,
useRowWipe,
useToolFade,
} from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
{props.text}
)
}
function firstLine(text: string) {
return text
.split(/\r\n|\n|\r/g)
.map((item) => item.trim())
.find((item) => item.length > 0)
}
function shellRows(output: string) {
const rows: { id: string; text: string }[] = []
const lines = output
.split(/\r\n|\n|\r/g)
.map((item) => item.trimEnd())
.filter((item) => item.length > 0)
const start = Math.max(0, lines.length - 80)
for (let i = start; i < lines.length; i++) {
rows.push({ id: `line:${i}`, text: lines[i]! })
}
return rows
}
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
$ {props.text}
)
}
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const i18n = useI18n()
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)
}
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()
const [mounted, setMounted] = createSignal(false)
const [userToggled, setUserToggled] = createSignal(false)
const [userOpen, setUserOpen] = createSignal(false)
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record)
const pending = createMemo(() => busy(props.part.state.status))
const autoOpen = hold(pending, 2000)
const effectiveOpen = createMemo(() => {
if (pending()) return true
if (userToggled()) return userOpen()
return autoOpen()
})
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
return ""
})
const reduce = prefersReducedMotion
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 handleHeaderClick = () => {
if (pending()) return
const el = headerClipRef
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
const beforeY = el?.getBoundingClientRect().top ?? 0
setUserToggled(true)
setUserOpen((prev) => !prev)
if (viewport && el) {
requestAnimationFrame(() => {
const afterY = el.getBoundingClientRect().top
const delta = afterY - beforeY
if (delta !== 0) viewport.scrollTop += delta
})
}
}
const line = createMemo(() => firstLine(command()))
const fixed = createMemo(() => {
const value = line()
if (!value) return
return
})
const text = createThrottledValue(() => stripAnsi(output()))
const rows = createMemo(() => shellRows(text()))
return (
row.id}
render={(row) => {
const [textRef, setTextRef] = createSignal()
useRowWipe({
id: () => row.id,
text: () => row.text,
ref: textRef,
seen: wiped,
})
return (
{row.text}
)
}}
/>
)
}