summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2025-12-22 16:00:26 -0800
committerGitHub <[email protected]>2025-12-22 18:00:26 -0600
commitac371d2987762e9b0b7627d7f1ee0ea2b5cab11a (patch)
treef40096d964052985723ec5c8e030d9385f8c1100
parenta7baa5ce188dad73a5c2896059860f1fa13dde6d (diff)
downloadopencode-ac371d2987762e9b0b7627d7f1ee0ea2b5cab11a.tar.gz
opencode-ac371d2987762e9b0b7627d7f1ee0ea2b5cab11a.zip
feat: better styling for small screens (short and/or not wide) (#5968)
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx223
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/header.tsx198
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx29
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx1
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}