Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 116 additions & 93 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
import * as Clipboard from "../../util/clipboard"
Expand All @@ -42,6 +42,7 @@ import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { useDirectory } from "../../context/directory"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -79,6 +80,11 @@ function randomIndex(count: number) {
return Math.floor(Math.random() * count)
}

function truncateStart(input: string, max: number) {
if (input.length <= max) return input
return "…" + input.slice(-(max - 1))
}

function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
Expand All @@ -97,6 +103,8 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const project = useProject()
const sync = useSync()
const directory = useDirectory()
const dimensions = useTerminalDimensions()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
Expand Down Expand Up @@ -174,6 +182,7 @@ export function Prompt(props: PromptProps) {
cost: cost > 0 ? money.format(cost) : undefined,
}
})
const directoryLabel = createMemo(() => truncateStart(directory(), Math.max(18, dimensions().width - 24)))

const [store, setStore] = createStore<{
prompt: PromptInfo
Expand Down Expand Up @@ -1249,111 +1258,125 @@ export function Prompt(props: PromptProps) {
}
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <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)
<box width="100%" flexDirection="row" minWidth={0}>
<box flexGrow={1} flexShrink={0}>
<Show when={status().type !== "idle"} fallback={props.hint ?? <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 handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void 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(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void 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
}
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>
)
})()}
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} wrapMode="none" flexShrink={0}>
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>
</box>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<box width={2} flexShrink={0} />
<box gap={2} flexDirection="row" flexShrink={1} minWidth={0}>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
<box flexDirection="row" gap={2} flexGrow={1} minWidth={0}>
<Show when={props.sessionID}>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
{directoryLabel()}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</box>
</Show>
<box flexDirection="row" gap={2} flexShrink={0}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="none">
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text} wrapMode="none">
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</box>
</box>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
<text fg={theme.text} wrapMode="none">
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"

const id = "internal:sidebar-footer"

Expand All @@ -13,16 +12,6 @@ function View(props: { api: TuiPluginApi }) {
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
const list = text.split("/")
return {
parent: list.slice(0, -1).join("/"),
name: list.at(-1) ?? "",
}
})

return (
<box gap={1}>
Expand Down Expand Up @@ -59,10 +48,6 @@ function View(props: { api: TuiPluginApi }) {
</box>
</box>
</Show>
<text>
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
<span style={{ fg: theme().text }}>{path().name}</span>
</text>
<text fg={theme().textMuted}>
<span style={{ fg: theme().success }}>•</span> <b>Open</b>
<span style={{ fg: theme().text }}>
Expand Down
Loading