Compare commits
1 Commits
dev
...
tui-claude
| Author | SHA1 | Date |
|---|---|---|
|
|
8a323b6a8b |
|
|
@ -86,6 +86,21 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
|||
touch(handoff.session, key, { ...prev, ...patch })
|
||||
}
|
||||
|
||||
const readPromptParam = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const value = new URLSearchParams(window.location.search).get("prompt")?.trim()
|
||||
if (!value) return
|
||||
return value
|
||||
}
|
||||
|
||||
const clearPromptParam = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const url = new URL(window.location.href)
|
||||
if (!url.searchParams.has("prompt")) return
|
||||
url.searchParams.delete("prompt")
|
||||
window.history.replaceState({}, "", url)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
|
|
@ -1491,6 +1506,20 @@ export default function Page() {
|
|||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.dir, params.id, prompt.ready()] as const,
|
||||
() => {
|
||||
if (!prompt.ready()) return
|
||||
const value = readPromptParam()
|
||||
if (!value) return
|
||||
clearPromptParam()
|
||||
if (prompt.dirty()) return
|
||||
prompt.set([{ type: "text", content: value, start: 0, end: value.length }], value.length)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!terminal.ready()) return
|
||||
language.locale()
|
||||
|
|
|
|||
|
|
@ -691,7 +691,6 @@ function App() {
|
|||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseUp={async () => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
|
||||
renderer.clearSelection()
|
||||
|
|
|
|||
|
|
@ -19,3 +19,17 @@ export const SplitBorder = {
|
|||
vertical: "┃",
|
||||
},
|
||||
}
|
||||
|
||||
export const RoundedBorder = {
|
||||
topLeft: "╭",
|
||||
topRight: "╮",
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
vertical: "│",
|
||||
bottomT: "┴",
|
||||
topT: "┬",
|
||||
cross: "┼",
|
||||
leftT: "├",
|
||||
rightT: "┤",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, S
|
|||
import "opentui-spinner/solid"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder } from "@tui/component/border"
|
||||
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
|
|
@ -779,23 +779,11 @@ export function Prompt(props: PromptProps) {
|
|||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
>
|
||||
<box flexDirection="row" alignItems="flex-start" paddingLeft={1}>
|
||||
<text fg={highlight()} flexShrink={0}>
|
||||
{store.mode === "shell" ? "! " : "❯ "}
|
||||
</text>
|
||||
<box flexGrow={1}>
|
||||
<textarea
|
||||
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||
textColor={keybind.leader ? theme.textMuted : theme.text}
|
||||
|
|
@ -969,59 +957,45 @@ export function Prompt(props: PromptProps) {
|
|||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
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>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme.backgroundElement}
|
||||
customBorderChars={
|
||||
theme.backgroundElement.a !== 0
|
||||
? {
|
||||
...EmptyBorder,
|
||||
horizontal: "▀",
|
||||
}
|
||||
: {
|
||||
...EmptyBorder,
|
||||
horizontal: " ",
|
||||
}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={3}>
|
||||
<Show
|
||||
when={status().type !== "idle"}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.textMuted}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}
|
||||
<Show when={store.mode === "normal"}>
|
||||
{" · "}
|
||||
{local.model.parsed().model}
|
||||
<Show when={showVariant()}>
|
||||
{" · "}
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</text>
|
||||
<box flexGrow={1} />
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<text fg={theme.textMuted}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.textMuted}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={<text />}>
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
|
|
@ -1029,11 +1003,9 @@ export function Prompt(props: PromptProps) {
|
|||
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>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>⋯</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
|
|
@ -1093,7 +1065,7 @@ export function Prompt(props: PromptProps) {
|
|||
})()}
|
||||
</box>
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.textMuted}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
|
|
@ -1101,30 +1073,6 @@ export function Prompt(props: PromptProps) {
|
|||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
<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>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,31 @@
|
|||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Tips } from "../component/tips"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useDirectory } from "../context/directory"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import { RoundedBorder } from "../component/border"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
|
||||
const STARTER_TIPS = [
|
||||
"Type @ followed by a filename to attach files",
|
||||
"Start a message with ! to run shell commands",
|
||||
"Press Tab to cycle between Build and Plan agents",
|
||||
"Run /init to create an AGENTS.md with instructions",
|
||||
]
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
|
|
@ -25,6 +33,9 @@ export function Home() {
|
|||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
const local = useLocal()
|
||||
const { navigate } = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
|
|
@ -37,7 +48,6 @@ export function Home() {
|
|||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const showTips = createMemo(() => {
|
||||
// Don't show tips for first-time users
|
||||
if (isFirstTimeUser()) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
|
@ -55,24 +65,14 @@ export function Home() {
|
|||
},
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
const recentSessions = createMemo(() => {
|
||||
return sync.data.session
|
||||
.filter((x) => !x.parentID)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 80)
|
||||
|
||||
let prompt: PromptRef
|
||||
const args = useArgs()
|
||||
|
|
@ -89,52 +89,95 @@ export function Home() {
|
|||
})
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box height={3} />
|
||||
<Logo />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||
<box height={2} />
|
||||
{/* Bordered welcome box */}
|
||||
<box
|
||||
width="100%"
|
||||
maxWidth={90}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
customBorderChars={RoundedBorder}
|
||||
borderColor={theme.border}
|
||||
title={` OpenCode v${Installation.VERSION} `}
|
||||
titleAlignment="left"
|
||||
>
|
||||
<box
|
||||
flexDirection={wide() ? "row" : "column"}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={wide() ? 4 : 2}
|
||||
>
|
||||
{/* Left column */}
|
||||
<box flexGrow={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>Welcome back!</b>
|
||||
</text>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
{local.model.parsed().model} · {local.model.parsed().provider}
|
||||
</text>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>●</span> {connectedMcpCount()} MCP · errors
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>●</span>{" "}
|
||||
{Locale.pluralize(connectedMcpCount(), "{} MCP server", "{} MCP servers")}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
</box>
|
||||
</box>
|
||||
{/* Right column */}
|
||||
<box flexGrow={1}>
|
||||
<Show when={showTips()}>
|
||||
<text fg={theme.primary}>
|
||||
<b>Tips for getting started</b>
|
||||
</text>
|
||||
<For each={STARTER_TIPS}>{(tip) => <text fg={theme.textMuted}>{tip}</text>}</For>
|
||||
</Show>
|
||||
<box paddingTop={showTips() ? 1 : 0}>
|
||||
<text fg={theme.primary}>
|
||||
<b>Recent activity</b>
|
||||
</text>
|
||||
<Show when={recentSessions().length === 0}>
|
||||
<text fg={theme.textMuted}>No recent activity</text>
|
||||
</Show>
|
||||
<For each={recentSessions()}>
|
||||
{(session) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseUp={() => navigate({ type: "session", sessionID: session.id })}
|
||||
>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncate(session.title, wide() ? 35 : 50)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<box width="100%" maxWidth={90} zIndex={1000} paddingTop={1}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
hint={Hint}
|
||||
/>
|
||||
</box>
|
||||
<box height={3} width="100%" maxWidth={75} alignItems="center" paddingTop={2}>
|
||||
<Show when={showTips()}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{connectedMcpCount()} MCP
|
||||
</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,15 @@
|
|||
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { type Accessor, createMemo, createSignal, Show } 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 } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Installation } from "@/installation"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<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 (
|
||||
|
|
@ -67,76 +58,51 @@ export function Header() {
|
|||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
|
||||
// For non-subagent sessions, render nothing (Claude Code style)
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={session()?.parentID}>
|
||||
<box flexShrink={0} paddingTop={1} paddingBottom={1} paddingLeft={1}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.parent")}
|
||||
>
|
||||
<text fg={hover() === "parent" ? theme.text : theme.textMuted}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.previous")}
|
||||
>
|
||||
<text fg={hover() === "prev" ? theme.text : theme.textMuted}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => command.trigger("session.child.next")}
|
||||
>
|
||||
<text fg={hover() === "next" ? theme.text : theme.textMuted}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Dynamic } from "solid-js/web"
|
|||
import path from "path"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import {
|
||||
|
|
@ -68,7 +68,7 @@ import { Toast, useToast } from "../../ui/toast"
|
|||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
|
@ -156,7 +156,6 @@ export function Session() {
|
|||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const showTimestamps = createMemo(() => timestamps() === "show")
|
||||
|
|
@ -956,11 +955,9 @@ export function Session() {
|
|||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexGrow={1} paddingBottom={0} paddingTop={1} paddingLeft={2} paddingRight={2}>
|
||||
<Show when={session()}>
|
||||
<Show when={!sidebarVisible() || !wide()}>
|
||||
<Header />
|
||||
</Show>
|
||||
<Header />
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
viewportOptions={{
|
||||
|
|
@ -1006,16 +1003,9 @@ export function Session() {
|
|||
onMouseUp={handleUnrevert}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundPanel}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
|
||||
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
|
||||
|
|
@ -1075,7 +1065,25 @@ export function Session() {
|
|||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<box flexShrink={0} paddingTop={1}>
|
||||
<box
|
||||
height={1}
|
||||
border={["top"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
topLeft: "",
|
||||
topRight: "",
|
||||
bottomLeft: "",
|
||||
bottomRight: "",
|
||||
vertical: "",
|
||||
horizontal: "─",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}}
|
||||
/>
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
|
|
@ -1162,62 +1170,53 @@ function UserMessage(props: {
|
|||
<Show when={text()}>
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
flexShrink={0}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={props.onMouseUp}
|
||||
>
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return theme.accent
|
||||
if (file.mime === "application/pdf") return theme.primary
|
||||
return theme.secondary
|
||||
})
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||
</text>
|
||||
</Show>
|
||||
<box flexDirection="row" paddingLeft={1}>
|
||||
<text fg={theme.textMuted} flexShrink={0}>
|
||||
{queued() ? ") " : "❯ "}
|
||||
</text>
|
||||
<box flexGrow={1}>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingLeft={3} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return theme.accent
|
||||
if (file.mime === "application/pdf") return theme.primary
|
||||
return theme.secondary
|
||||
})
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
<span style={{ bg: theme.accent, fg: theme.background, bold: true }}> QUEUED </span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={compaction()}>
|
||||
|
|
@ -1269,17 +1268,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||
}}
|
||||
</For>
|
||||
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.error}
|
||||
>
|
||||
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={3} marginTop={1}>
|
||||
<text fg={theme.error}>⎿ {props.message.error?.data.message}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Switch>
|
||||
|
|
@ -1294,7 +1284,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||
: local.agent.color(props.message.agent),
|
||||
}}
|
||||
>
|
||||
▣{" "}
|
||||
⏺{" "}
|
||||
</span>{" "}
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||
|
|
@ -1328,15 +1318,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
|||
})
|
||||
return (
|
||||
<Show when={content() && ctx.showThinking()}>
|
||||
<box
|
||||
id={"text-" + props.part.id}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.backgroundElement}
|
||||
>
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column">
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
|
|
@ -1486,7 +1468,7 @@ type ToolProps<T extends Tool.Info> = {
|
|||
function GenericTool(props: ToolProps<any>) {
|
||||
return (
|
||||
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
|
||||
{props.tool} {input(props.input)}
|
||||
{props.tool}({input(props.input)})
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1509,6 +1491,7 @@ function InlineTool(props: {
|
|||
pending: string
|
||||
children: JSX.Element
|
||||
part: ToolPart
|
||||
result?: JSX.Element
|
||||
}) {
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const { theme } = useTheme()
|
||||
|
|
@ -1521,6 +1504,13 @@ function InlineTool(props: {
|
|||
return callID === props.part.callID
|
||||
})
|
||||
|
||||
const iconColor = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (props.part.state.status === "completed") return theme.success
|
||||
if (props.part.state.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
})
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (props.complete) return theme.textMuted
|
||||
|
|
@ -1563,13 +1553,20 @@ function InlineTool(props: {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
<text fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
||||
<span style={{ fg: props.iconColor ?? iconColor() }}>⏺</span> {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={props.result}>
|
||||
<text paddingLeft={2} fg={theme.textMuted}>
|
||||
⎿ {props.result}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={error() && !denied()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
<text paddingLeft={2} fg={theme.error}>
|
||||
⎿ {error()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -1586,17 +1583,17 @@ function BlockTool(props: {
|
|||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
||||
|
||||
const iconColor = createMemo(() => {
|
||||
if (props.part?.state.status === "completed") return theme.success
|
||||
if (props.part?.state.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingLeft={3}
|
||||
marginTop={1}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
|
|
@ -1607,16 +1604,20 @@ function BlockTool(props: {
|
|||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: iconColor() }}>⏺</span> {props.title.replace(/^# /, "").replace(/^← /, "")}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "").replace(/^← /, "")}</Spinner>
|
||||
</box>
|
||||
</Show>
|
||||
{props.children}
|
||||
<box paddingLeft={2}>{props.children}</box>
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
<text paddingLeft={2} fg={theme.error}>
|
||||
⎿ {error()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -1669,20 +1670,22 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
|||
spinner={isRunning()}
|
||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
</Show>
|
||||
<Show when={overflow()}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>⎿ $ {props.input.command}</text>
|
||||
<Show when={output()}>
|
||||
<text paddingLeft={2} fg={theme.text}>
|
||||
{limited()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={overflow()}>
|
||||
<text paddingLeft={2} fg={theme.textMuted}>
|
||||
{expanded() ? "Click to collapse" : "Click to expand"}
|
||||
</text>
|
||||
</Show>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
|
||||
{props.input.command}
|
||||
Bash({props.input.command})
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1701,23 +1704,19 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||
return props.metadata.diagnostics?.[filePath] ?? []
|
||||
})
|
||||
|
||||
const lineCount = createMemo(() => code().split("\n").length)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={theme.text}
|
||||
filetype={filetype(props.input.filePath!)}
|
||||
syntaxStyle={syntax()}
|
||||
content={code()}
|
||||
/>
|
||||
</line_number>
|
||||
<BlockTool title={"Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<text fg={theme.textMuted}>
|
||||
⎿ Wrote {lineCount()} lines to {normalizePath(props.input.filePath!)}
|
||||
</text>
|
||||
<Show when={diagnostics().length}>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
<text paddingLeft={2} fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
|
|
@ -1727,7 +1726,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||
Write {normalizePath(props.input.filePath!)}
|
||||
Write({normalizePath(props.input.filePath!)})
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1735,12 +1734,13 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
|||
}
|
||||
|
||||
function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
const result = createMemo(() => {
|
||||
if (!props.metadata.count) return undefined
|
||||
return `${props.metadata.count} ${props.metadata.count === 1 ? "match" : "matches"}`
|
||||
})
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.count}>
|
||||
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part} result={result()}>
|
||||
Glob("{props.input.pattern}"<Show when={props.input.path}>, {normalizePath(props.input.path)}</Show>)
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1754,31 +1754,40 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||
if (!value || !Array.isArray(value)) return []
|
||||
return value.filter((p): p is string => typeof p === "string")
|
||||
})
|
||||
const result = createMemo(() => {
|
||||
const l = loaded()
|
||||
if (l.length === 0) return undefined
|
||||
return `Loaded ${l.length + 1} file${l.length > 0 ? "s" : ""}`
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
<InlineTool
|
||||
icon="→"
|
||||
pending="Reading file..."
|
||||
complete={props.input.filePath}
|
||||
part={props.part}
|
||||
result={result()}
|
||||
>
|
||||
Read({normalizePath(props.input.filePath!)})
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Grep(props: ToolProps<typeof GrepTool>) {
|
||||
const result = createMemo(() => {
|
||||
if (!props.metadata.matches) return undefined
|
||||
return `${props.metadata.matches} ${props.metadata.matches === 1 ? "match" : "matches"}`
|
||||
})
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.matches}>
|
||||
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
<InlineTool
|
||||
icon="✱"
|
||||
pending="Searching content..."
|
||||
complete={props.input.pattern}
|
||||
part={props.part}
|
||||
result={result()}
|
||||
>
|
||||
Grep("{props.input.pattern}"<Show when={props.input.path}>, {normalizePath(props.input.path)}</Show>)
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1792,7 +1801,7 @@ function List(props: ToolProps<typeof ListTool>) {
|
|||
})
|
||||
return (
|
||||
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
|
||||
List {dir()}
|
||||
List({dir()})
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1800,7 +1809,7 @@ function List(props: ToolProps<typeof ListTool>) {
|
|||
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
||||
return (
|
||||
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
|
||||
WebFetch {(props.input as any).url}
|
||||
Fetch({(props.input as any).url})
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1808,9 +1817,10 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
|||
function CodeSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
const result = createMemo(() => (metadata.results ? `${metadata.results} results` : undefined))
|
||||
return (
|
||||
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
|
||||
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part} result={result()}>
|
||||
Code Search("{input.query}")
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1818,9 +1828,10 @@ function CodeSearch(props: ToolProps<any>) {
|
|||
function WebSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
const result = createMemo(() => (metadata.numResults ? `${metadata.numResults} results` : undefined))
|
||||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
|
||||
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part} result={result()}>
|
||||
Web Search("{input.query}")
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -1850,7 +1861,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||
<Switch>
|
||||
<Match when={props.input.description || props.input.subagent_type}>
|
||||
<BlockTool
|
||||
title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
|
||||
title={Locale.titlecase(props.input.subagent_type ?? "unknown") + "(" + (props.input.description ?? "") + ")"}
|
||||
onClick={
|
||||
props.metadata.sessionId
|
||||
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
|
||||
|
|
@ -1859,32 +1870,27 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||
part={props.part}
|
||||
spinner={isRunning()}
|
||||
>
|
||||
<box>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
{props.input.description} ({tools().length} toolcalls)
|
||||
</text>
|
||||
<Show when={current()}>
|
||||
{(item) => {
|
||||
const title = item().state.status === "completed" ? (item().state as any).title : ""
|
||||
return (
|
||||
<text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
|
||||
└ {Locale.titlecase(item().tool)} {title}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={props.metadata.sessionId}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("session_child_cycle")}
|
||||
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
⎿{" "}
|
||||
{isRunning()
|
||||
? `${tools().length} tool use${tools().length !== 1 ? "s" : ""}`
|
||||
: `Done (${tools().length} tool use${tools().length !== 1 ? "s" : ""})`}
|
||||
</text>
|
||||
<Show when={isRunning() ? current() : undefined}>
|
||||
{(item) => {
|
||||
const title = item().state.status === "completed" ? (item().state as any).title : ""
|
||||
return (
|
||||
<text paddingLeft={2} style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
|
||||
└ {Locale.titlecase(item().tool)} {title}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
|
||||
{props.input.subagent_type} Task {props.input.description}
|
||||
{Locale.titlecase(props.input.subagent_type ?? "unknown")}({props.input.description})
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1912,11 +1918,21 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<BlockTool
|
||||
title={"Edit(" + normalizePath(props.input.filePath!) + ")"}
|
||||
part={props.part}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<Show
|
||||
when={expanded()}
|
||||
fallback={<text fg={theme.textMuted}>⎿ {normalizePath(props.input.filePath!)} (ctrl+o to expand)</text>}
|
||||
>
|
||||
<text fg={theme.textMuted}>⎿ {normalizePath(props.input.filePath!)}</text>
|
||||
<diff
|
||||
diff={diffContent()}
|
||||
view={view()}
|
||||
|
|
@ -1936,24 +1952,21 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={diagnostics().length}>
|
||||
<box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
|
||||
{diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text paddingLeft={2} fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
Edit({normalizePath(props.input.filePath!)})
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -1999,10 +2012,10 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
|||
}
|
||||
|
||||
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||
if (file.type === "add") return "# Created " + file.relativePath
|
||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||
return "← Patched " + file.relativePath
|
||||
if (file.type === "delete") return "Deleted " + file.relativePath
|
||||
if (file.type === "add") return "Created " + file.relativePath
|
||||
if (file.type === "move") return "Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||
return "Patched " + file.relativePath
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -2038,7 +2051,7 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
|||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.todos?.length}>
|
||||
<BlockTool title="# Todos" part={props.part}>
|
||||
<BlockTool title="Todos" part={props.part}>
|
||||
<box>
|
||||
<For each={props.input.todos ?? []}>
|
||||
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
|
||||
|
|
@ -2067,7 +2080,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
|||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
<BlockTool title="# Questions" part={props.part}>
|
||||
<BlockTool title="Questions" part={props.part}>
|
||||
<box gap={1}>
|
||||
<For each={props.input.questions ?? []}>
|
||||
{(q, i) => (
|
||||
|
|
@ -2092,7 +2105,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
|||
function Skill(props: ToolProps<typeof SkillTool>) {
|
||||
return (
|
||||
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
|
||||
Skill "{props.input.name}"
|
||||
Skill({props.input.name})
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
|||
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import path from "path"
|
||||
|
|
@ -322,18 +322,13 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.error}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.error}>{"△"}</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.error}>⏺</text>
|
||||
<text fg={theme.text}>Reject permission</text>
|
||||
</box>
|
||||
<box paddingLeft={1}>
|
||||
<box paddingLeft={2}>
|
||||
<text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -341,10 +336,9 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
|
|
@ -358,10 +352,10 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||
keyBindings={textareaKeybindings()}
|
||||
/>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={theme.text}>
|
||||
<text fg={theme.textMuted}>
|
||||
enter <span style={{ fg: theme.textMuted }}>confirm</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
<text fg={theme.textMuted}>
|
||||
esc <span style={{ fg: theme.textMuted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -429,12 +423,16 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
|
||||
const content = () => (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.warning}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={1}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
? {
|
||||
top: dimensions().height * -1 + 1,
|
||||
bottom: 1,
|
||||
left: 2,
|
||||
right: 2,
|
||||
position: "absolute",
|
||||
backgroundColor: theme.background,
|
||||
}
|
||||
: {
|
||||
top: 0,
|
||||
maxHeight: 15,
|
||||
|
|
@ -445,8 +443,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
})}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>⏺</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
{props.body}
|
||||
|
|
@ -456,28 +454,26 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<box gap={0}>
|
||||
<For each={keys}>
|
||||
{(option) => (
|
||||
{(option, i) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseOver={() => setStore("selected", option)}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", option)
|
||||
props.onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
||||
{props.options[option]}
|
||||
<text fg={option === store.selected ? theme.text : theme.textMuted}>
|
||||
{option === store.selected ? "❯" : " "} {i() + 1}. {props.options[option]}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
|
@ -485,15 +481,12 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
</box>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={theme.text}>
|
||||
<text fg={theme.textMuted}>
|
||||
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>confirm</span>
|
||||
<text fg={theme.textMuted}>
|
||||
Esc to cancel <span style={{ fg: theme.textMuted }}>· Tab to amend</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useKeybind } from "../../context/keybind"
|
|||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
|
|
@ -251,12 +251,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<Show when={!single()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue