diff options
| author | Aiden Cline <[email protected]> | 2025-12-22 16:00:26 -0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-12-22 18:00:26 -0600 |
| commit | ac371d2987762e9b0b7627d7f1ee0ea2b5cab11a (patch) | |
| tree | f40096d964052985723ec5c8e030d9385f8c1100 | |
| parent | a7baa5ce188dad73a5c2896059860f1fa13dde6d (diff) | |
| download | opencode-ac371d2987762e9b0b7627d7f1ee0ea2b5cab11a.tar.gz opencode-ac371d2987762e9b0b7627d7f1ee0ea2b5cab11a.zip | |
feat: better styling for small screens (short and/or not wide) (#5968)
4 files changed, 258 insertions, 193 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 71937e179..47940d0e2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -14,7 +14,7 @@ import { Keybind } from "@/util/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useRenderer } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" @@ -120,6 +120,9 @@ export function Prompt(props: PromptProps) { const history = usePromptHistory() const command = useCommandDialog() const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const tall = createMemo(() => dimensions().height > 40) + const wide = createMemo(() => dimensions().width > 120) const { theme, syntax } = useTheme() function promptModelWarning() { @@ -881,19 +884,21 @@ export function Prompt(props: PromptProps) { 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"}> - <box flexDirection="row" gap={1}> - <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> - {local.model.parsed().model} - </text> - <text fg={theme.textMuted}>{local.model.parsed().provider}</text> - </box> - </Show> - </box> + <Show when={tall()}> + <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"}> + <box flexDirection="row" gap={1}> + <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> + {local.model.parsed().model} + </text> + <text fg={theme.textMuted}>{local.model.parsed().provider}</text> + </box> + </Show> + </box> + </Show> </box> </box> <box @@ -923,101 +928,123 @@ export function Prompt(props: PromptProps) { /> </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}> - {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} - <spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> - <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) + <Switch> + <Match when={status().type !== "idle"}> + <box + flexDirection="row" + gap={1} + flexGrow={1} + justifyContent={status().type === "retry" ? "space-between" : "flex-start"} + > + <box flexShrink={0} flexDirection="row" gap={1}> + {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} + <spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> + <box flexDirection="row" gap={1} flexShrink={0}> + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - DialogAlert.show(dialog, "Retry Error", r.message) + 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(() => { + clearTimeout(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 retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - <Show when={retry()}> - <box onMouseUp={handleMessageClick}> - <text fg={theme.error}>{retryText()}</text> - </box> - </Show> - ) - })()} + 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> - <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"}> + </Match> + <Match when={!tall()}> + <box flexDirection="row" gap={1}> + <text fg={highlight()}> + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + </text> + <Show when={store.mode === "normal"}> + <box flexDirection="row" gap={1}> + <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> + {local.model.parsed().model} + </text> + <text fg={theme.textMuted}>{local.model.parsed().provider}</text> + </box> + </Show> + </box> + </Match> + </Switch> + <box gap={2} flexDirection="row" marginLeft="auto"> + <Switch> + <Match when={store.mode === "normal"}> + <Show when={wide()}> <text fg={theme.text}> {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span> </text> + </Show> + <Show when={!wide()}> <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> + {keybind.print("sidebar_toggle")} <span style={{ fg: theme.textMuted }}>sidebar</span> </text> - </Match> - </Switch> - </box> - </Show> + </Show> + <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> </box> </box> </> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 098ee83cc..cf6abef47 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,124 +1,140 @@ import { type Accessor, createMemo, Match, Show, Switch } from "solid-js" import { useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" -import { SplitBorder, EmptyBorder } from "@tui/component/border" -import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" -import { useDirectory } from "../../context/directory" +import { EmptyBorder } from "@tui/component/border" +import type { Session } from "@opencode-ai/sdk/v2" import { useKeybind } from "../../context/keybind" +import { useTerminalDimensions } from "@opentui/solid" -const Title = (props: { session: Accessor<Session> }) => { +const Title = (props: { session: Accessor<Session>; truncate?: boolean }) => { const { theme } = useTheme() return ( - <text fg={theme.text}> + <text fg={theme.text} wrapMode={props.truncate ? "none" : undefined} flexShrink={props.truncate ? 1 : 0}> <span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span> </text> ) } -const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => { - const { theme } = useTheme() - return ( - <Show when={props.context()}> - <text fg={theme.textMuted} wrapMode="none" flexShrink={0}> - {props.context()} ({props.cost()}) - </text> - </Show> - ) -} - export function Header() { const route = useRouteData("session") const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) - const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const context = createMemo(() => { - const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage - if (!last) return - const total = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID] - let result = total.toLocaleString() - if (model?.limit.context) { - result += " " + Math.round((total / model.limit.context) * 100) + "%" - } - return result - }) + const showShare = createMemo(() => shareEnabled() && !session()?.share?.url) const { theme } = useTheme() const keybind = useKeybind() + const dimensions = useTerminalDimensions() + const tall = createMemo(() => dimensions().height > 40) return ( <box flexShrink={0}> <box - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - paddingRight={1} - {...SplitBorder} + height={1} + border={["left"]} + borderColor={theme.border} + customBorderChars={{ + ...EmptyBorder, + vertical: theme.backgroundPanel.a !== 0 ? "╻" : " ", + }} + > + <box + height={1} + border={["top"]} + borderColor={theme.backgroundPanel} + customBorderChars={ + theme.backgroundPanel.a !== 0 + ? { + ...EmptyBorder, + horizontal: "▄", + } + : { + ...EmptyBorder, + horizontal: " ", + } + } + /> + </box> + <box border={["left"]} borderColor={theme.border} - flexShrink={0} - backgroundColor={theme.backgroundPanel} + customBorderChars={{ + ...EmptyBorder, + vertical: "┃", + bottomLeft: "╹", + }} > - <Switch> - <Match when={session()?.parentID}> - <box flexDirection="row" gap={2}> - <text fg={theme.text}> - <b>Subagent session</b> - </text> - <text fg={theme.text}> - Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span> - </text> - <text fg={theme.text}> - Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> - </text> - <text fg={theme.text}> - Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> - </text> - <box flexGrow={1} flexShrink={1} /> - <ContextInfo context={context} cost={cost} /> - </box> - </Match> - <Match when={true}> - <box flexDirection="row" justifyContent="space-between" gap={1}> - <Title session={session} /> - <ContextInfo context={context} cost={cost} /> - </box> - <Show when={shareEnabled()}> + <box + paddingTop={tall() ? 1 : 0} + paddingBottom={tall() ? 1 : 0} + paddingLeft={2} + paddingRight={1} + flexShrink={0} + flexGrow={1} + backgroundColor={theme.backgroundPanel} + > + <Switch> + <Match when={session()?.parentID}> + <box flexDirection="row" gap={2}> + <text fg={theme.text}> + <b>Subagent session</b> + </text> + <text fg={theme.text}> + Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span> + </text> + <text fg={theme.text}> + Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> + </text> + <text fg={theme.text}> + Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> + </text> + <box flexGrow={1} flexShrink={1} /> + <Show when={showShare()}> + <text fg={theme.textMuted} wrapMode="none" flexShrink={0}> + /share{" "} + </text> + </Show> + </box> + </Match> + <Match when={true}> <box flexDirection="row" justifyContent="space-between" gap={1}> - <box flexGrow={1} flexShrink={1}> - <Switch> - <Match when={session().share?.url}> - <text fg={theme.textMuted} wrapMode="word"> - {session().share!.url} - </text> - </Match> - <Match when={true}> - <text fg={theme.text} wrapMode="word"> - /share <span style={{ fg: theme.textMuted }}>copy link</span> - </text> - </Match> - </Switch> - </box> + <Title session={session} truncate={!tall()} /> + <Show when={showShare()}> + <text fg={theme.textMuted} wrapMode="none" flexShrink={0}> + /share{" "} + </text> + </Show> </box> - </Show> - </Match> - </Switch> + </Match> + </Switch> + </box> + </box> + <box + height={1} + border={["left"]} + borderColor={theme.border} + customBorderChars={{ + ...EmptyBorder, + vertical: theme.backgroundPanel.a !== 0 ? "╹" : " ", + }} + > + <box + height={1} + border={["bottom"]} + borderColor={theme.backgroundPanel} + customBorderChars={ + theme.backgroundPanel.a !== 0 + ? { + ...EmptyBorder, + horizontal: "▀", + } + : { + ...EmptyBorder, + horizontal: " ", + } + } + /> </box> </box> ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index da697e632..826fa2acf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -22,6 +22,7 @@ import { ScrollBoxRenderable, addDefaultParsers, MacOSScrollAccel, + RGBA, type ScrollAcceleration, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" @@ -129,13 +130,15 @@ export function Session() { const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const wide = createMemo(() => dimensions().width > 120) + const tall = createMemo(() => dimensions().height > 40) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false if (sidebar() === "show") return true if (sidebar() === "auto" && wide()) return true return false }) - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const sidebarOverlay = createMemo(() => sidebarVisible() && !wide()) + const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? 42 : 0) - 4) const scrollAcceleration = createMemo(() => { const tui = sync.data.config.tui @@ -961,7 +964,7 @@ export function Session() { <box flexDirection="row"> <box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}> <Show when={session()}> - <Show when={!sidebarVisible()}> + <Show when={!sidebarVisible() || sidebarOverlay()}> <Header /> </Show> <scrollbox @@ -1091,15 +1094,33 @@ export function Session() { sessionID={route.sessionID} /> </box> - <Show when={!sidebarVisible()}> + <Show when={(!sidebarVisible() || sidebarOverlay()) && tall()}> <Footer /> </Show> </Show> <Toast /> </box> - <Show when={sidebarVisible()}> + <Show when={sidebarVisible() && !sidebarOverlay()}> <Sidebar sessionID={route.sessionID} /> </Show> + <Show when={sidebarOverlay()}> + <box + position="absolute" + left={0} + top={0} + width={dimensions().width} + height={dimensions().height} + backgroundColor={RGBA.fromInts(0, 0, 0, 150)} + zIndex={100} + flexDirection="row" + justifyContent="flex-end" + onMouseUp={() => setSidebar("hide")} + > + <box onMouseUp={(e) => e.stopPropagation()}> + <Sidebar sessionID={route.sessionID} /> + </box> + </box> + </Show> </box> </context.Provider> ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 6c74e04fa..c29057770 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -62,6 +62,7 @@ export function Sidebar(props: { sessionID: string }) { <box backgroundColor={theme.backgroundPanel} width={42} + height="100%" paddingTop={1} paddingBottom={1} paddingLeft={2} |
