chore: merge v2 into service channel config
This commit is contained in:
commit
e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions
|
|
@ -4,7 +4,6 @@ import { Deferred, Effect } from "effect"
|
|||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flag } from "@opencode-ai/util/flag"
|
||||
import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
||||
import { LogProvider, useLog, type LogSink } from "./context/log"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
|
|
@ -211,7 +210,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
|
||||
useMouse: config.mouse,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
|
|
@ -420,7 +419,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
|
||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
|
|
@ -466,7 +465,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
const offSelectionKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (config.data.terminal?.copy_on_select ?? process.platform !== "win32") return
|
||||
Selection.handleSelectionKey(renderer, toast, event, clipboard)
|
||||
},
|
||||
{ priority: 1 },
|
||||
|
|
@ -487,15 +486,16 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
renderer.clearSelection()
|
||||
}
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.data.mouse
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
if (!terminalTitleEnabled()) return
|
||||
|
||||
if (route.data.type === "home") {
|
||||
renderer.setTerminalTitle("OpenCode")
|
||||
|
|
@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
name: "theme.switch_mode",
|
||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||
palette: undefined,
|
||||
enabled: () => supports(mode() === "dark" ? "light" : "dark"),
|
||||
run: () => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
|
|
@ -1087,9 +1088,9 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast, clipboard)) return
|
||||
|
|
@ -1097,12 +1098,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||
copyOnSelectEnabled()
|
||||
? () => Selection.copy(renderer, toast, clipboard)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<Show when={config.data.debug?.timing}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useTheme } from "../context/theme"
|
|||
import { DevTools } from "../devtools"
|
||||
|
||||
export function DevToolsSidebar() {
|
||||
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
|
||||
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
|
||||
const [modeHovered, setModeHovered] = createSignal(false)
|
||||
const nextMode = () => (mode() === "dark" ? "light" : "dark")
|
||||
const canSwitchMode = () => supports(nextMode())
|
||||
|
||||
return (
|
||||
<box
|
||||
|
|
@ -15,26 +17,26 @@ export function DevToolsSidebar() {
|
|||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
>
|
||||
<box flexShrink={0} marginBottom={1}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
|
||||
Theme
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued()}>Mode</text>
|
||||
<text fg={themeV2.text.subdued}>Mode</text>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
|
||||
onMouseOver={() => setModeHovered(true)}
|
||||
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setModeHovered(canSwitchMode())}
|
||||
onMouseOut={() => setModeHovered(false)}
|
||||
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
|
||||
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
|
||||
>
|
||||
<text fg={themeV2.text()}>{mode()}</text>
|
||||
<text fg={canSwitchMode() ? themeV2.text.default : themeV2.text.subdued}>{mode()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -42,16 +44,16 @@ export function DevToolsSidebar() {
|
|||
{(group) => (
|
||||
<box flexShrink={0} marginBottom={1}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
|
||||
{group.title}
|
||||
</text>
|
||||
</box>
|
||||
<For each={group.entries}>
|
||||
{(entry) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued()}>{entry.key}</text>
|
||||
<text fg={themeV2.text.subdued}>{entry.key}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={themeV2.text()}>{String(entry.value)}</text>
|
||||
<text fg={themeV2.text.default}>{String(entry.value)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -206,6 +206,14 @@ const settings: Setting[] = [
|
|||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Copy on select",
|
||||
category: "Terminal",
|
||||
path: ["terminal", "copy_on_select"],
|
||||
default: process.platform !== "win32",
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "DevTools",
|
||||
category: "Debug",
|
||||
|
|
@ -214,6 +222,14 @@ const settings: Setting[] = [
|
|||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Timing",
|
||||
category: "Debug",
|
||||
path: ["debug", "timing"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogConfig() {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ export function DialogDebug() {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Debug
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -67,10 +67,10 @@ export function DialogDebug() {
|
|||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={themeV2.text.subdued()}>
|
||||
<text flexShrink={0} fg={themeV2.text.subdued}>
|
||||
{entry.label.padEnd(10)}
|
||||
</text>
|
||||
<text fg={themeV2.text()} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
{entry.value}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -78,12 +78,12 @@ export function DialogDebug() {
|
|||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued()}>Share this when reporting an issue.</text>
|
||||
<text fg={themeV2.text.subdued}>Share this when reporting an issue.</text>
|
||||
<text onMouseUp={copy}>
|
||||
<span style={{ fg: copied() ? themeV2.text.feedback.success() : themeV2.text() }}>
|
||||
<span style={{ fg: copied() ? themeV2.text.feedback.success.default : themeV2.text.default }}>
|
||||
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>enter</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>enter</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
|||
footer: connectionSummary(integration) || undefined,
|
||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
||||
disabled: methods.length === 0,
|
||||
gutter: connected ? () => <text fg={themeV2.text.feedback.success()}>✓</text> : undefined,
|
||||
gutter: connected ? () => <text fg={themeV2.text.feedback.success.default}>✓</text> : undefined,
|
||||
onSelect: () =>
|
||||
credentialConnections(integration).length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
|
|
@ -89,12 +89,12 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
|||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No integrations available</text>
|
||||
<text fg={themeV2.text.subdued}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No integrations found</text>
|
||||
<text fg={themeV2.text.subdued}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
|
|
@ -295,24 +295,24 @@ function CommandView(props: { title: string; output: string; message: string })
|
|||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc close
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<text fg={overlayTheme.text()}>{props.output.trim()}</text>
|
||||
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -346,7 +346,7 @@ function KeyMethod(props: {
|
|||
.catch((cause) => setError(message(cause)))
|
||||
}}
|
||||
description={() => (
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
|
@ -536,9 +536,9 @@ function OAuthCode(props: {
|
|||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link()} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
|
||||
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -551,27 +551,27 @@ function OAuthView(props: { title: string; url?: string; instructions?: string;
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.url}>
|
||||
{(url) => (
|
||||
<box gap={1}>
|
||||
<Link href={url()} fg={themeV2.markdown.link()} />
|
||||
<Link href={url()} fg={themeV2.markdown.link} />
|
||||
<Show when={props.instructions}>
|
||||
{(instructions) => <text fg={themeV2.text.subdued()}>{instructions()}</text>}
|
||||
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={themeV2.text()}>
|
||||
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ function statusError(status: McpServer["status"]) {
|
|||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued() }}>⋯ Loading</span>
|
||||
if (props.loading) return <span style={{ fg: themeV2.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: themeV2.text.feedback.success(), attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
return <span style={{ fg: themeV2.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: themeV2.text.subdued() }}>○ Disabled</span>
|
||||
return <span style={{ fg: themeV2.text.subdued }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
|
|
@ -110,7 +110,7 @@ export function DialogMcp() {
|
|||
]}
|
||||
footer={
|
||||
<Show when={focusedError()}>
|
||||
<text fg={themeV2.text.subdued()}>enter to view error</text>
|
||||
<text fg={themeV2.text.subdued}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
|
|
@ -171,16 +171,16 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={props.onBack}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.feedback.error()}>✗ Failed</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
|
|
@ -192,14 +192,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<text fg={overlayTheme.text()} wrapMode="word">
|
||||
<text fg={overlayTheme.text.default} wrapMode="word">
|
||||
{error()}
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued()}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={copy}>
|
||||
<text fg={themeV2.text.subdued}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -172,18 +172,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
return {
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: themeV2.text.feedback.error() }}>Deleting {item.location}</span>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: themeV2.text.action.destructive() }}>
|
||||
<span style={{ fg: themeV2.text.action.destructive.default }}>
|
||||
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||
</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{visible.slice(split)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive() : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive.default : undefined,
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
|
|
@ -316,7 +316,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
|
|
@ -329,25 +329,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued()}>Close and reopen Move session to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>Loading project directories…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No project directories available</text>
|
||||
<text fg={themeV2.text.subdued}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No project directories found</text>
|
||||
<text fg={themeV2.text.subdued}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
|
|
|
|||
|
|
@ -47,17 +47,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued()}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={themeV2.text()}>{url}</text>}</For>
|
||||
<text fg={themeV2.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={themeV2.text.default}>{url}</text>}</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued()}>Username</text>
|
||||
<text fg={themeV2.text()}>{value.username}</text>
|
||||
<text fg={themeV2.text.subdued}>Username</text>
|
||||
<text fg={themeV2.text.default}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text.subdued()}>Password</text>
|
||||
<text fg={themeV2.text.subdued}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? themeV2.text() : themeV2.text.subdued()}
|
||||
fg={passwordHover() ? themeV2.text.default : themeV2.text.subdued}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
|
|
@ -67,7 +67,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
</Show>
|
||||
|
|
@ -78,7 +78,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={themeV2.text()}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
<text fg={themeV2.text.default}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -87,17 +87,17 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Pair
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={loadError()}
|
||||
fallback={
|
||||
<Show when={info()} fallback={<text fg={themeV2.text.subdued()}>Loading server information…</text>}>
|
||||
<Show when={info()} fallback={<text fg={themeV2.text.subdued}>Loading server information…</text>}>
|
||||
<Show
|
||||
when={dimensions().height >= 36}
|
||||
fallback={
|
||||
|
|
@ -116,11 +116,11 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
|||
>
|
||||
{(error) => (
|
||||
<box>
|
||||
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load server information
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(error())}</text>
|
||||
<text fg={themeV2.text.subdued()}>Close and reopen Pair to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(error())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Pair to try again.</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -61,18 +61,17 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
|||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
textColor={themeV2.text.formfield()}
|
||||
focusedTextColor={themeV2.text.formfield()}
|
||||
cursorColor={themeV2.text.formfield()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={themeV2.text.formfield.default}
|
||||
focusedTextColor={themeV2.text.formfield.default}
|
||||
cursorColor={themeV2.text.formfield.default}
|
||||
/>
|
||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||
<text fg={themeV2.text()}>
|
||||
enter <span style={{ fg: themeV2.text.subdued() }}>submit</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
</text>
|
||||
<text fg={themeV2.text()}>
|
||||
{shortcuts.get("dialog.project_copy.generate")}{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>generate one</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: themeV2.text.subdued }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background()) : undefined)
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
|
|
@ -85,26 +85,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()} bg={textBg()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={themeV2.text.subdued()} bg={textBg()}>
|
||||
<text fg={themeV2.text.subdued} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link()} bg={textBg()} wrapMode="none" />
|
||||
<Link href={props.link} fg={themeV2.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link()} wrapMode="none" />
|
||||
<Link href={props.link} fg={themeV2.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
|
|
@ -115,13 +115,13 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)
|
||||
selected() === "dismiss" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? themeV2.text.action("focused") : themeV2.text.subdued()}
|
||||
fg={selected() === "dismiss" ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
|
@ -131,12 +131,14 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
|||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "action" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={
|
||||
selected() === "action" ? themeV2.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? themeV2.text.action("focused") : themeV2.text()}
|
||||
fg={selected() === "action" ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
|
|
@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
|||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action("focused") : undefined}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
|
|
@ -94,12 +94,12 @@ export function DialogSessionDeleteFailed(props: {
|
|||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text()}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}
|
||||
fg={item.id === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
|
|
|
|||
|
|
@ -63,29 +63,29 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||
<Switch
|
||||
fallback={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No skills available</text>
|
||||
<text fg={themeV2.text.subdued}>No skills available</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Match when={showError()}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Could not load skills
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued()}>Close and reopen Skills to try again.</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued}>Close and reopen Skills to try again.</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={skills.loading}>
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>Loading skills…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading skills…</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No skills found</text>
|
||||
<text fg={themeV2.text.subdued}>No skills found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,25 +13,25 @@ export function DialogStatus() {
|
|||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
const color = (status: string) => {
|
||||
if (status === "connected") return themeV2.text.feedback.success()
|
||||
if (status === "failed") return themeV2.text.feedback.error()
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning()
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error()
|
||||
return themeV2.text.subdued()
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
}
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
Status
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text()}>No MCP servers</text>}>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={themeV2.text.default}>No MCP servers</text>}>
|
||||
<box>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||
</text>
|
||||
<For each={mcp()}>
|
||||
|
|
@ -40,9 +40,9 @@ export function DialogStatus() {
|
|||
<text flexShrink={0} style={{ fg: color(item.status.status) }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={themeV2.text()} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
<b>{item.name}</b>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
|
||||
|
|
|
|||
|
|
@ -72,21 +72,21 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={themeV2.text.subdued} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height={height()}
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
|
|
@ -95,18 +95,16 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" minWidth={0} flexShrink={1}>
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={overlayTheme.text.subdued()}>{statusLabel(item.status)}</text>
|
||||
<text fg={overlayTheme.text.subdued}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued()} />
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
{" "}
|
||||
{item.additions ? (
|
||||
<span style={{ fg: overlayTheme.diff.text.added() }}>+{item.additions}</span>
|
||||
) : null}
|
||||
{item.additions ? <span style={{ fg: overlayTheme.diff.text.added }}>+{item.additions}</span> : null}
|
||||
{item.deletions ? (
|
||||
<span style={{ fg: overlayTheme.diff.text.removed() }}> -{item.deletions}</span>
|
||||
<span style={{ fg: overlayTheme.diff.text.removed }}> -{item.deletions}</span>
|
||||
) : null}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -120,14 +118,16 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? themeV2.background.action("focused") : undefined}
|
||||
backgroundColor={item === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>{item}</text>
|
||||
<text fg={item === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{item}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export function Logo() {
|
|||
const { themeV2 } = useTheme()
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(themeV2.background(), fg, 0.25)
|
||||
const shadow = tint(themeV2.background.default, fg, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
return Array.from(line).map((char) => {
|
||||
if (char === "_") {
|
||||
|
|
@ -52,8 +52,8 @@ export function Logo() {
|
|||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued(), false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text(), true)}</box>
|
||||
<box flexDirection="row">{renderLine(line, themeV2.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,16 @@ export function PluginRouteMissing(props: { id: string; name: string; onHome: ()
|
|||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={themeV2.text.feedback.warning()}>
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
Unknown plugin route: {props.id}/{props.name}
|
||||
</text>
|
||||
<box
|
||||
onMouseUp={props.onHome}
|
||||
backgroundColor={themeV2.background.action("hovered")}
|
||||
backgroundColor={themeV2.background.action.primary.hovered}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={themeV2.text.action("hovered")}>go home</text>
|
||||
<text fg={themeV2.text.action.primary.hovered}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,38 +22,7 @@ import { Keymap } from "../../context/keymap"
|
|||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
if (hashIndex === -1) {
|
||||
return { baseQuery: input }
|
||||
}
|
||||
|
||||
const baseName = input.substring(0, hashIndex)
|
||||
const linePart = input.substring(hashIndex + 1)
|
||||
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
|
||||
|
||||
if (!lineMatch) {
|
||||
return { baseQuery: baseName }
|
||||
}
|
||||
|
||||
const startLine = Number(lineMatch[1])
|
||||
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
|
||||
|
||||
return {
|
||||
lineRange: {
|
||||
baseName,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
baseQuery: baseName,
|
||||
}
|
||||
}
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
|
|
@ -275,9 +244,9 @@ export function Autocomplete(props: {
|
|||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
const { baseQuery } = extractLineRange(search())
|
||||
const slash = baseQuery.indexOf("/")
|
||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||
const base = parseFileLineRange(search()).base
|
||||
const slash = base.indexOf("/")
|
||||
const alias = slash === -1 ? base : base.slice(0, slash)
|
||||
return references().find((item) => !item.hidden && item.name === alias)
|
||||
})
|
||||
|
||||
|
|
@ -312,11 +281,11 @@ export function Autocomplete(props: {
|
|||
async (input) => {
|
||||
if (!input.visible || input.visible === "/") return { options: [], failed: false }
|
||||
if (referenceMatch()) return { options: [], failed: false }
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
const { lineRange, base } = parseFileLineRange(input.query ?? "")
|
||||
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: baseQuery,
|
||||
query: base,
|
||||
limit: 20,
|
||||
location: {
|
||||
directory: input.location?.directory,
|
||||
|
|
@ -500,9 +469,9 @@ export function Autocomplete(props: {
|
|||
}
|
||||
|
||||
const fuzziedNonFiles = fuzzysort
|
||||
.go(removeLineRange(searchValue), nonFileOptions, {
|
||||
.go(stripFileLineRange(searchValue), nonFileOptions, {
|
||||
keys: [
|
||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
(obj) => stripFileLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
// Match description for slash commands only; for "@" it surfaced unrelated items.
|
||||
...(store.visible === "/" ? ["description" as const] : []),
|
||||
(obj) => obj.aliases?.join(" ") ?? "",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { registerOpencodeSpinner } from "../register-spinner"
|
|||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Flag } from "@opencode-ai/util/flag"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
|
|
@ -26,6 +25,8 @@ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../.
|
|||
import { normalizePromptContent, openEditor } from "../../editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { promptOffsetWidth } from "../../prompt/display"
|
||||
import { expandPromptInputPastedText, realignPromptInputMentions } from "../../prompt/mention"
|
||||
import { parseSlashHead } from "../../prompt/parse"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
|
||||
|
|
@ -51,7 +52,7 @@ import { readLocalAttachment } from "./local-attachment"
|
|||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { contextUsage } from "../../util/session"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
|
@ -137,15 +138,15 @@ function formatEditorContext(selection: EditorSelection) {
|
|||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
|
||||
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
if (!input.startsWith("/")) return
|
||||
const separator = input.search(/\s/)
|
||||
const name = input.slice(1, separator === -1 ? undefined : separator)
|
||||
const head = parseSlashHead(input, /\s/)
|
||||
if (!head) return
|
||||
const command = commands.find(
|
||||
(command) =>
|
||||
command.slash?.arguments && (command.slash.name === name || command.slash.aliases?.includes(name) === true),
|
||||
command.slash?.arguments &&
|
||||
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
|
||||
)
|
||||
if (!command) return
|
||||
return { command, input: separator === -1 ? "" : input.slice(separator + 1) }
|
||||
return { command, input: head.arguments }
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
|
|
@ -295,8 +296,8 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset()
|
||||
if (!props.disabled) input.cursorColor = themeV2.text()
|
||||
if (props.disabled) input.cursorColor = themeV2.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = themeV2.text.default
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
|
|
@ -311,11 +312,7 @@ export function Prompt(props: PromptProps) {
|
|||
session.revert?.messageID,
|
||||
)
|
||||
return {
|
||||
context: context
|
||||
? context.percent === undefined
|
||||
? Locale.number(context.tokens)
|
||||
: `${Locale.number(context.tokens)} (${context.percent}%)`
|
||||
: undefined,
|
||||
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
|
@ -493,13 +490,8 @@ export function Prompt(props: PromptProps) {
|
|||
run: async () => {
|
||||
dialog.clear()
|
||||
|
||||
// replace summarized text parts with the actual text
|
||||
const text = store.prompt.pasted.reduce(
|
||||
(result, part) => result.replace(part.source.text, part.text),
|
||||
store.prompt.text,
|
||||
)
|
||||
|
||||
const value = text
|
||||
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
|
||||
const value = editorPrompt.text
|
||||
const content = await openEditor({
|
||||
renderer,
|
||||
value,
|
||||
|
|
@ -513,20 +505,8 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
input.setText(normalized)
|
||||
|
||||
// Update attachment positions and drop virtual text deleted in the editor.
|
||||
// this handles a case where the user edits the text in the editor
|
||||
// such that the virtual text moves around or is deleted
|
||||
const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(part: Part) => {
|
||||
if (!part.mention?.text) return part
|
||||
const start = normalized.indexOf(part.mention.text)
|
||||
if (start === -1) return
|
||||
return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } }
|
||||
}
|
||||
|
||||
setStore("prompt", {
|
||||
text: normalized,
|
||||
files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined),
|
||||
agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined),
|
||||
...realignPromptInputMentions(normalized, editorPrompt),
|
||||
pasted: [],
|
||||
})
|
||||
restoreExtmarksFromPrompt(store.prompt)
|
||||
|
|
@ -1122,7 +1102,7 @@ export function Prompt(props: PromptProps) {
|
|||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session.model.variant !== variant
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
|
|
@ -1319,10 +1299,10 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return themeV2.border()
|
||||
if (store.mode === "shell") return themeV2.background.action()
|
||||
if (leader()) return themeV2.border.default
|
||||
if (store.mode === "shell") return themeV2.background.action.primary.default
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return themeV2.border()
|
||||
if (!agent) return themeV2.border.default
|
||||
return local.agent.color(agent.id)
|
||||
})
|
||||
|
||||
|
|
@ -1339,7 +1319,7 @@ export function Prompt(props: PromptProps) {
|
|||
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(themeV2.border(), highlight(), agentMetaAlpha()))
|
||||
const borderHighlight = createMemo(() => tint(themeV2.border.default, highlight(), agentMetaAlpha()))
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
|
|
@ -1359,7 +1339,7 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : themeV2.border()
|
||||
const color = agent ? local.agent.color(agent.id) : themeV2.border.default
|
||||
return {
|
||||
frames: createFrames({
|
||||
color,
|
||||
|
|
@ -1379,7 +1359,7 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset()))
|
||||
const promptBg = createMemo(() => themeV2.raise(themeV2.background.surface.offset))
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -1405,9 +1385,9 @@ export function Prompt(props: PromptProps) {
|
|||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
textColor={leader() ? themeV2.text.subdued() : themeV2.text()}
|
||||
focusedTextColor={leader() ? themeV2.text.subdued() : themeV2.text()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
focusedTextColor={leader() ? themeV2.text.subdued : themeV2.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
|
|
@ -1467,7 +1447,7 @@ export function Prompt(props: PromptProps) {
|
|||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = themeV2.text()
|
||||
input.cursorColor = themeV2.text.default
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
|
|
@ -1475,7 +1455,7 @@ export function Prompt(props: PromptProps) {
|
|||
r.target?.focus()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? themeV2.background.surface.offset() : themeV2.text()}
|
||||
cursorColor={props.disabled ? themeV2.background.surface.offset : themeV2.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
|
@ -1487,24 +1467,24 @@ export function Prompt(props: PromptProps) {
|
|||
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
|
||||
</text>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<text fg={fadeColor(themeV2.text.subdued(), agentMetaAlpha())}>auto</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(leader() ? themeV2.text.subdued() : themeV2.text(), modelMetaAlpha())}
|
||||
fg={fadeColor(leader() ? themeV2.text.subdued : themeV2.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={fadeColor(themeV2.text.subdued(), variantMetaAlpha())}>·</text>
|
||||
<text fg={fadeColor(themeV2.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
style={{
|
||||
fg: fadeColor(themeV2.text.feedback.warning(), variantMetaAlpha()),
|
||||
fg: fadeColor(themeV2.text.feedback.warning.default, variantMetaAlpha()),
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
|
|
@ -1558,12 +1538,12 @@ export function Prompt(props: PromptProps) {
|
|||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued()}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? themeV2.background.action() : themeV2.text()}
|
||||
fg={store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
|
|
@ -1571,7 +1551,7 @@ export function Prompt(props: PromptProps) {
|
|||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? themeV2.background.action() : themeV2.text.subdued(),
|
||||
fg: store.interrupt > 0 ? themeV2.background.action.primary.default : themeV2.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
|
|
@ -1582,16 +1562,16 @@ export function Prompt(props: PromptProps) {
|
|||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={themeV2.hue.accent(500)}>
|
||||
<Spinner color={themeV2.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{".".repeat(move.creatingDots())}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={themeV2.hue.accent(500)} wrapMode="none" truncate>
|
||||
<text fg={themeV2.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -1599,7 +1579,7 @@ export function Prompt(props: PromptProps) {
|
|||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
|
@ -1613,7 +1593,7 @@ export function Prompt(props: PromptProps) {
|
|||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent(500) : themeV2.text.subdued()}
|
||||
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent[500] : themeV2.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
|
|
@ -1623,40 +1603,40 @@ export function Prompt(props: PromptProps) {
|
|||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexShrink={1}>
|
||||
<text fg={themeV2.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
|
||||
{(shortcut) => <span style={{ fg: themeV2.text() }}>{shortcut()} </span>}
|
||||
{(shortcut) => <span style={{ fg: themeV2.text.default }}>{shortcut()} </span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · </span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={shellStatusLabel()}>
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
|
||||
{(label) => <span style={{ fg: themeV2.text.subdued }}>{label()}</span>}
|
||||
</Show>
|
||||
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · </span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · </span>
|
||||
</Show>
|
||||
<Show when={statusItems().length > 0}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{statusItems().join(" · ")}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{statusItems().join(" · ")}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={themeV2.text()} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued() }}>agents</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{agentShortcut()} <span style={{ fg: themeV2.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={themeV2.text()} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued() }}>commands</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={themeV2.text()} flexShrink={0}>
|
||||
esc <span style={{ fg: themeV2.text.subdued() }}>exit shell mode</span>
|
||||
<text fg={themeV2.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ export function Reconnecting() {
|
|||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
||||
<Spinner color={themeV2.text.subdued()}>Waiting for background service...</Spinner>
|
||||
<Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ registerOpencodeSpinner()
|
|||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? themeV2.text.subdued()
|
||||
const color = () => props.color ?? themeV2.text.subdued
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ export function StartupLoading(props: { ready: () => boolean }) {
|
|||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={themeV2.background()} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={themeV2.text.subdued()}>{text()}</Spinner>
|
||||
<box backgroundColor={themeV2.background.default} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={themeV2.text.subdued}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export const Info = Schema.Struct({
|
|||
terminal: Schema.optional(
|
||||
Schema.Struct({
|
||||
title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }),
|
||||
copy_on_select: Schema.optional(Schema.Boolean).annotate({ description: "Copy selected terminal text" }),
|
||||
}),
|
||||
).annotate({ description: "Terminal integration settings" }),
|
||||
prompt: Schema.optional(
|
||||
|
|
@ -129,6 +130,7 @@ export const Info = Schema.Struct({
|
|||
debug: Schema.optional(
|
||||
Schema.Struct({
|
||||
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools sidebar" }),
|
||||
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const Definitions = {
|
|||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import { useArgs } from "./args"
|
|||
import { useClient } from "./client"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import {
|
||||
createModelPreferenceRepository,
|
||||
cycleModelVariant,
|
||||
modelPreferenceKey,
|
||||
normalizeModelVariant,
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
|
|
@ -33,14 +41,11 @@ export function parseModel(model: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function recentModels(
|
||||
model: { providerID: string; modelID: string },
|
||||
recent: { providerID: string; modelID: string }[],
|
||||
) {
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
const key = `${item.providerID}/${item.modelID}`
|
||||
const key = modelPreferenceKey(item)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
|
|
@ -62,13 +67,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const event = useEvent()
|
||||
const permission = usePermission()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
function isModelValid(model: ModelPreferenceModel) {
|
||||
return !!data.location.model
|
||||
.list()
|
||||
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
|
|
@ -87,14 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
[
|
||||
themeV2.hue.blue(step),
|
||||
themeV2.hue.purple(step),
|
||||
themeV2.hue.green(step),
|
||||
themeV2.hue.orange(step),
|
||||
themeV2.hue.red(step),
|
||||
themeV2.hue.cyan(step),
|
||||
],
|
||||
themeV2.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
)
|
||||
})
|
||||
|
|
@ -144,25 +142,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const agent = createAgent()
|
||||
|
||||
function createModel() {
|
||||
const [modelStore, setModelStore] = createStore<{
|
||||
ready: boolean
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
favorite: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
variant: Record<string, string | undefined>
|
||||
}>({
|
||||
const [modelStore, setModelStore] = createStore<
|
||||
ModelPreference & {
|
||||
ready: boolean
|
||||
model: Record<string, ModelPreferenceModel>
|
||||
}
|
||||
>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
|
|
@ -170,7 +155,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
variant: {},
|
||||
})
|
||||
|
||||
const filePath = path.join(paths.state, "model.json")
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
|
@ -181,21 +166,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return
|
||||
}
|
||||
state.pending = false
|
||||
void writeJsonAtomic(filePath, {
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
void repository
|
||||
.patch({
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((x) => {
|
||||
if (!x || typeof x !== "object") return
|
||||
const value = x as Record<string, unknown>
|
||||
if (Array.isArray(value.recent)) setModelStore("recent", value.recent)
|
||||
if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite)
|
||||
if (typeof value.variant === "object" && value.variant !== null)
|
||||
setModelStore("variant", value.variant as Record<string, string | undefined>)
|
||||
repository
|
||||
.load()
|
||||
.then((value) => {
|
||||
setModelStore("recent", value.recent)
|
||||
setModelStore("favorite", value.favorite)
|
||||
setModelStore("variant", value.variant)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
|
|
@ -360,14 +345,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
selected() {
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key] ?? "default"
|
||||
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (v !== "default" && this.list().includes(v)) return v
|
||||
return "default"
|
||||
if (v && this.list().includes(v)) return v
|
||||
return undefined
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
|
|
@ -380,24 +363,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
if (!m) return
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
setModelStore("variant", key, value ?? "default")
|
||||
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
save()
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const current = this.current()
|
||||
if (!current) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
this.set(cycleModelVariant(this.current(), variants))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import path from "path"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { formatPath } from "../util/path-format"
|
||||
import { useLocation } from "./location"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
|
|
@ -8,17 +7,6 @@ export function usePathFormatter() {
|
|||
const location = useLocation()
|
||||
return {
|
||||
path: () => location.current?.directory || paths.cwd,
|
||||
format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home),
|
||||
format: (input?: string) => formatPath(input, { base: location.current?.directory || paths.cwd, home: paths.home }),
|
||||
}
|
||||
}
|
||||
|
||||
function formatPath(input: string | undefined, base: string, home: string) {
|
||||
if (typeof input !== "string" || !input) return ""
|
||||
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(base, input)
|
||||
const relative = path.relative(base, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||
return abbreviateHome(absolute, home)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { discoverThemes, themeDirectories } from "../theme/discovery"
|
|||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -78,10 +79,12 @@ type ThemeService = {
|
|||
has: typeof hasTheme
|
||||
syntax: Accessor<SyntaxStyle>
|
||||
mode: Accessor<"dark" | "light">
|
||||
modes: Accessor<readonly ("dark" | "light")[]>
|
||||
supports(mode: "dark" | "light"): boolean
|
||||
locked: Accessor<boolean>
|
||||
lock(): void
|
||||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
|
@ -271,17 +274,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
|
||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||
const values = createMemo(() => resolveTheme(source(), store.mode))
|
||||
const valuesV2 = createMemo(() => {
|
||||
const file = createMemo(() => {
|
||||
const started = performance.now()
|
||||
const file = migrateV1(source())
|
||||
const result = migrateV1(source())
|
||||
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
|
||||
return result
|
||||
})
|
||||
const modes = createMemo(() => themeModes(file()))
|
||||
const mode = () => {
|
||||
const supported = modes()
|
||||
if (supported.includes(store.mode)) return store.mode
|
||||
return supported[0] ?? store.mode
|
||||
}
|
||||
const values = createMemo(() => resolveTheme(source(), mode()))
|
||||
const valuesV2 = createMemo(() => {
|
||||
const resolveStarted = performance.now()
|
||||
const result = resolveThemeFile(file, store.mode, sourceName())
|
||||
const result = resolveThemeFile(file(), mode(), sourceName())
|
||||
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
|
||||
return result
|
||||
})
|
||||
const mode = () => store.mode
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => {
|
||||
|
|
@ -319,11 +330,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
mode: () => store.mode,
|
||||
mode,
|
||||
modes,
|
||||
supports: (requested) => modes().includes(requested),
|
||||
locked: () => store.lock !== undefined,
|
||||
lock: () => pin(store.mode),
|
||||
lock: () => pin(mode()),
|
||||
unlock: free,
|
||||
setMode: pin,
|
||||
setMode(requested = mode(), persist = true) {
|
||||
if (!modes().includes(requested)) return false
|
||||
pin(requested, persist)
|
||||
return true
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
|||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued()} />}
|
||||
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,20 +31,18 @@ function Mcp(props: { context: Plugin.Context }) {
|
|||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
<span style={{ fg: themeV2.text.feedback.error() }}>⊙ </span>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success() : themeV2.text.subdued() }}>
|
||||
⊙{" "}
|
||||
</span>
|
||||
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>/status</text>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
|
@ -78,7 +76,7 @@ function View(props: { context: Plugin.Context }) {
|
|||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
|
||||
<text fg={themeV2.text.subdued}>{InstallationVersion}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,20 +20,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||
|
||||
return (
|
||||
<box>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={themeV2.text.subdued()}>Not measured</text>}>
|
||||
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={themeV2.text.subdued()}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={themeV2.text.subdued()}>{value().percent}% used</text>
|
||||
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>{money.format(cost())} spent</text>
|
||||
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function View(props: { context: Plugin.Context }) {
|
|||
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
|
||||
)
|
||||
return (
|
||||
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued()} />}</Show>
|
||||
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ function View() {
|
|||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>LSP status unavailable</text>
|
||||
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
if (status === "connected") return themeV2.text.feedback.success()
|
||||
if (status === "failed") return themeV2.text.feedback.error()
|
||||
if (status === "disabled") return themeV2.text.subdued()
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning()
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error()
|
||||
return themeV2.text.subdued()
|
||||
if (status === "connected") return themeV2.text.feedback.success.default
|
||||
if (status === "failed") return themeV2.text.feedback.error.default
|
||||
if (status === "disabled") return themeV2.text.subdued
|
||||
if (status === "needs_auth") return themeV2.text.feedback.warning.default
|
||||
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
|
||||
return themeV2.text.subdued
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -32,12 +32,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||
<box>
|
||||
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
|
||||
<Show when={list().length > 2}>
|
||||
<text fg={themeV2.text()}>{open() ? "▼" : "▶"}</text>
|
||||
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>MCP</b>
|
||||
<Show when={!open()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
{" "}
|
||||
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
|
|
@ -56,9 +56,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={themeV2.text()} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>
|
||||
<span style={{ fg: themeV2.text.subdued }}>
|
||||
<Switch fallback={item.status.status}>
|
||||
<Match when={item.status.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status.status === "failed"}>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { tint } from "../../theme/color"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
import { useTheme } from "../../context/theme"
|
||||
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeTheme = {
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: ColorInput
|
||||
readonly backgroundElement: ColorInput
|
||||
readonly primary: ColorInput
|
||||
readonly secondary: ColorInput
|
||||
readonly selectedListItemText: ColorInput
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly error: ColorInput
|
||||
}
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
readonly error: unknown
|
||||
readonly theme: DiffViewerFileTreeTheme
|
||||
readonly focused?: boolean
|
||||
readonly highlightedNode?: number
|
||||
readonly selectedFileIndex?: number
|
||||
|
|
@ -35,6 +23,7 @@ export type DiffViewerFileTreeProps = {
|
|||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const { themeV2 } = useTheme()
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
|
@ -49,7 +38,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
|
||||
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
|
||||
|
||||
return (
|
||||
<Panel border="both" width={props.width}>
|
||||
|
|
@ -63,7 +52,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||
<text />
|
||||
</Match>
|
||||
<Match when={props.files.length === 0}>
|
||||
<text fg={props.theme.text}>No files</text>
|
||||
<text fg={themeV2.text.default}>No files</text>
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
|
|
@ -82,22 +71,26 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? props.theme.primary : undefined}
|
||||
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
|
||||
<text
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{prefix()}
|
||||
</text>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? props.theme.background
|
||||
? themeV2.text.action.primary.focused
|
||||
: selected()
|
||||
? props.theme.primary
|
||||
? themeV2.text.formfield.selected
|
||||
: reviewed() || row.kind === "directory"
|
||||
? props.theme.textMuted
|
||||
: props.theme.text
|
||||
? themeV2.text.subdued
|
||||
: themeV2.text.default
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
|
@ -105,7 +98,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? props.theme.background : props.theme.textMuted}
|
||||
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { bo
|
|||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: themeV2.border(),
|
||||
borderColor: themeV2.border.default,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -60,7 +60,7 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
|
|||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
const { themeV2 } = useTheme()
|
||||
const color = () => props.color ?? themeV2.border()
|
||||
const color = () => props.color ?? themeV2.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (axis() === "y") {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const themeState = useTheme()
|
||||
const theme = () => themeState.theme
|
||||
const themeV2 = themeState.themeV2
|
||||
const params = () => {
|
||||
const route = props.context.ui.router.current()
|
||||
return (route.type === "plugin" ? route.data : undefined) as
|
||||
|
|
@ -741,11 +741,11 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={theme().text}>Diff </text>
|
||||
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
|
||||
<text fg={themeV2.text.default}>Diff </text>
|
||||
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
|
||||
<box flexGrow={1} />
|
||||
<Show when={!diff.loading && !diff.error}>
|
||||
<text fg={theme().textMuted}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Show>
|
||||
|
|
@ -756,19 +756,21 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
<Match when={diff.loading}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme().textMuted}>Loading diff…</text>
|
||||
<text fg={themeV2.text.subdued}>Loading diff…</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading && diff.error}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme().error}>Could not load diff. Reopen the diff viewer to try again.</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
Could not load diff. Reopen the diff viewer to try again.
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading && files().length === 0}>
|
||||
<Separator axis="x" />
|
||||
<box flexGrow={1} paddingLeft={1}>
|
||||
<text fg={theme().textMuted}>No changes to show</text>
|
||||
<text fg={themeV2.text.subdued}>No changes to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
|
|
@ -778,7 +780,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
theme={theme()}
|
||||
focused={focus() === "files"}
|
||||
width={FILE_TREE_WIDTH}
|
||||
highlightedNode={highlightedFileNode()}
|
||||
|
|
@ -812,24 +813,26 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={theme().border}
|
||||
borderColor={themeV2.border.default}
|
||||
>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
|
||||
{entry.file.file}
|
||||
</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
||||
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
</box>
|
||||
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={theme().border}>
|
||||
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
|
||||
<diff
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
diff={patch()}
|
||||
|
|
@ -839,17 +842,27 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? theme().textMuted : theme().text}
|
||||
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
||||
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
||||
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
||||
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
|
||||
addedBg={
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
|
||||
}
|
||||
removedBg={
|
||||
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
|
||||
}
|
||||
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
|
||||
removedSignColor={
|
||||
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
|
||||
}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
addedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
||||
reviewed()
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.added
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
||||
reviewed()
|
||||
? themeV2.background.surface.overlay
|
||||
: themeV2.diff.lineNumber.background.removed
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
|
|
@ -860,7 +873,11 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
}}
|
||||
</For>
|
||||
<Show when={patchFillerHeight() > 0}>
|
||||
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
|
||||
<box
|
||||
height={patchFillerHeight()}
|
||||
border={patchLeftBorder()}
|
||||
borderColor={themeV2.border.default}
|
||||
/>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
|
||||
|
|
@ -873,57 +890,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next hunk</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousHunkShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous hunk</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchSourceShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={markReviewedShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={helpShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -934,7 +951,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
|||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
||||
const { theme } = useTheme()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
|
||||
const rows = [
|
||||
{
|
||||
|
|
@ -1002,30 +1019,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Diff shortcuts
|
||||
</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
<text fg={themeV2.text.subdued}>esc</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted} width={5} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
|
||||
Key
|
||||
</text>
|
||||
<text fg={theme.textMuted} width={22} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
|
||||
Action
|
||||
</text>
|
||||
<text fg={theme.textMuted}>Description</text>
|
||||
<text fg={themeV2.text.subdued}>Description</text>
|
||||
</box>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text} width={5} wrapMode="none">
|
||||
<text fg={themeV2.text.default} width={5} wrapMode="none">
|
||||
{row.shortcut() || "-"}
|
||||
</text>
|
||||
<text fg={theme.text} width={22} wrapMode="none">
|
||||
<text fg={themeV2.text.default} width={22} wrapMode="none">
|
||||
{row.action}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{row.description}</text>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -43,19 +43,19 @@ function Scrap(props: { context: Plugin.Context }) {
|
|||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background()}>
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={elevatedTheme.background()}
|
||||
backgroundColor={elevatedTheme.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued()}>~/code/anomalyco/opencode</text>
|
||||
<text fg={elevatedTheme.text.subdued}>~/code/anomalyco/opencode</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued()}>esc home</text>
|
||||
<text fg={elevatedTheme.text.subdued}>esc home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
|
|||
provider.models[model.id] = {
|
||||
name: model.name,
|
||||
cost: cost === undefined ? undefined : { input: cost },
|
||||
limit: { context: model.limit.context },
|
||||
status: model.status,
|
||||
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,11 +263,14 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
|
|||
{ footer: state.footer },
|
||||
{
|
||||
commits,
|
||||
footer: view
|
||||
? {
|
||||
view,
|
||||
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
||||
}
|
||||
updates: view
|
||||
? [
|
||||
{
|
||||
type: "stream.patch" as const,
|
||||
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
||||
},
|
||||
{ type: "stream.view" as const, view },
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
|
|
@ -276,7 +279,13 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
|
|||
function clearBlocker(state: State): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
|
||||
{
|
||||
commits: [],
|
||||
updates: [
|
||||
{ type: "stream.patch", patch: { status: "" } },
|
||||
{ type: "stream.view", view: { type: "prompt" } },
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ type QueuedEntry = PanelEntry & {
|
|||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
||||
|
||||
const PANEL_PAD = 2
|
||||
const PANEL_LIST_ROWS = 10
|
||||
const PANEL_FRAME_ROWS = 6
|
||||
|
|
@ -124,72 +122,6 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
|||
return "running"
|
||||
}
|
||||
|
||||
function handleKey(input: {
|
||||
event: KeyEvent
|
||||
menu: MenuState
|
||||
field: () => InputRenderable | undefined
|
||||
setQuery: (value: string) => void
|
||||
select: () => void
|
||||
close: () => void
|
||||
}) {
|
||||
const name = input.event.name.toLowerCase()
|
||||
const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super
|
||||
|
||||
if (name === "escape" || (ctrl && name === "c")) {
|
||||
input.event.preventDefault()
|
||||
input.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
input.event.preventDefault()
|
||||
input.menu.move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
input.event.preventDefault()
|
||||
input.menu.move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pageup") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(input.menu.selected() - PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(input.menu.selected() + PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "home") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "end") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(Number.POSITIVE_INFINITY)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
input.event.preventDefault()
|
||||
input.select()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && name === "u") {
|
||||
input.event.preventDefault()
|
||||
input.setQuery("")
|
||||
input.field()?.setText("")
|
||||
}
|
||||
}
|
||||
|
||||
function match<T extends PanelEntry>(query: string, entries: T[]) {
|
||||
const text = query.trim()
|
||||
if (!text) {
|
||||
|
|
@ -201,6 +133,128 @@ function match<T extends PanelEntry>(query: string, entries: T[]) {
|
|||
.map((item) => item.obj)
|
||||
}
|
||||
|
||||
function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
entries: Accessor<T[]>
|
||||
limit: number
|
||||
onClose: () => void
|
||||
onSelect: (item: T) => void
|
||||
isCurrent?: (item: T) => boolean
|
||||
closeOnFirstUp?: boolean
|
||||
onKey?: (event: KeyEvent, item: T | undefined) => boolean
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const items = createMemo<T[]>(() => match(query(), input.entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.isCurrent || query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex(input.isCurrent)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.onKey?.(event, selected())) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = event.name.toLowerCase()
|
||||
if (input.closeOnFirstUp && name === "up" && menu.selected() === 0) {
|
||||
event.preventDefault()
|
||||
input.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (name === "escape" || (ctrl && name === "c")) {
|
||||
event.preventDefault()
|
||||
input.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
event.preventDefault()
|
||||
menu.move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
menu.move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pageup") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() - PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() + PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "home") {
|
||||
event.preventDefault()
|
||||
menu.reveal(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "end") {
|
||||
event.preventDefault()
|
||||
menu.reveal(Number.POSITIVE_INFINITY)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
event.preventDefault()
|
||||
const item = selected()
|
||||
if (item) {
|
||||
input.onSelect(item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && name === "u") {
|
||||
event.preventDefault()
|
||||
setQuery("")
|
||||
field?.setText("")
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
items,
|
||||
menu,
|
||||
inputRef(input: InputRenderable) {
|
||||
field = input
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function PanelShell(props: {
|
||||
title: string
|
||||
countVisible?: boolean
|
||||
|
|
@ -350,8 +404,6 @@ export function RunCommandMenuBody(props: {
|
|||
onNew: () => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
const entries = createMemo<CommandEntry[]>(() => {
|
||||
|
|
@ -413,8 +465,8 @@ export function RunCommandMenuBody(props: {
|
|||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "Manage queued prompts",
|
||||
footer: `${props.queued().length} queued`,
|
||||
display: "View pending work",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
.map((item) => item.prompt.text)
|
||||
|
|
@ -466,8 +518,6 @@ export function RunCommandMenuBody(props: {
|
|||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
||||
]
|
||||
})
|
||||
const items = createMemo<CommandEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: CommandEntry) => {
|
||||
if (item.action === "model") {
|
||||
props.onModel()
|
||||
|
|
@ -516,56 +566,39 @@ export function RunCommandMenuBody(props: {
|
|||
|
||||
props.onCommand(item.name)
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: pick,
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Commands"
|
||||
countVisible={false}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={!query().trim()}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
/>
|
||||
|
|
@ -581,8 +614,6 @@ export function RunSubagentSelectBody(props: {
|
|||
onSelect: (sessionID: string) => void
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props.tabs().map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
|
|
@ -597,72 +628,35 @@ export function RunSubagentSelectBody(props: {
|
|||
}
|
||||
}),
|
||||
)
|
||||
const items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onSelect(item.sessionID)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.sessionID),
|
||||
isCurrent: (item) => item.current,
|
||||
closeOnFirstUp: true,
|
||||
onRows: props.onRows,
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select subagent"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={menu.rows}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No subagents found"
|
||||
border={false}
|
||||
|
|
@ -679,89 +673,46 @@ export function RunQueuedPromptSelectBody(props: {
|
|||
theme: Accessor<RunFooterTheme>
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: "queued · ctrl+e edit · ctrl+d remove",
|
||||
footer: prompt.delivery,
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = selected()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
|
||||
event.preventDefault()
|
||||
props.onDelete(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
if (item && ctrl && event.name === "e") {
|
||||
event.preventDefault()
|
||||
props.onEdit(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({
|
||||
event,
|
||||
menu,
|
||||
field: () => field,
|
||||
setQuery,
|
||||
select: () => {
|
||||
const item = selected()
|
||||
if (item) props.onEdit(item.prompt)
|
||||
},
|
||||
close: props.onClose,
|
||||
})
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: props.onClose,
|
||||
onRows: props.onRows,
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Queued prompts"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
title="Pending work"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={menu.rows}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No queued prompts"
|
||||
empty="No pending work"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
|
|
@ -778,8 +729,6 @@ export function RunSkillSelectBody(props: {
|
|||
onClose: () => void
|
||||
onSelect: (name: string) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<SkillEntry[]>(() =>
|
||||
(props.commands() ?? [])
|
||||
.filter((item) => item.source === "skill")
|
||||
|
|
@ -792,50 +741,31 @@ export function RunSkillSelectBody(props: {
|
|||
}))
|
||||
.sort((a, b) => a.display.localeCompare(b.display)),
|
||||
)
|
||||
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onSelect(item.name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.name),
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Skills"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
|
|
@ -856,8 +786,6 @@ export function RunVariantSelectBody(props: {
|
|||
onClose: () => void
|
||||
onSelect: (variant: string | undefined) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<VariantEntry[]>(() => [
|
||||
{
|
||||
category: "",
|
||||
|
|
@ -876,64 +804,32 @@ export function RunVariantSelectBody(props: {
|
|||
current: props.current() === variant,
|
||||
})),
|
||||
])
|
||||
const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: VariantEntry) => {
|
||||
props.onSelect(item.variant)
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.variant),
|
||||
isCurrent: (item) => item.current,
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select variant"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
|
|
@ -954,8 +850,6 @@ export function RunModelSelectBody(props: {
|
|||
onClose: () => void
|
||||
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<ModelEntry[]>(() =>
|
||||
(props.providers() ?? [])
|
||||
.flatMap((provider) =>
|
||||
|
|
@ -997,71 +891,39 @@ export function RunModelSelectBody(props: {
|
|||
return a.display.localeCompare(b.display)
|
||||
}),
|
||||
)
|
||||
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: ModelEntry) => {
|
||||
props.onSelect({ providerID: item.providerID, modelID: item.modelID })
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
|
||||
isCurrent: (item) => item.current,
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select model"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.providers() ? "No results found" : "Models loading"}
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={!query().trim()}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
|||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
|
|
@ -20,41 +21,6 @@ type RunFooterMenuRow =
|
|||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
|
||||
function previewMargin(limit: number) {
|
||||
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
|
||||
}
|
||||
|
||||
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < value) {
|
||||
return Math.min(max, input.selected)
|
||||
}
|
||||
|
||||
if (input.selected >= value + input.limit) {
|
||||
return Math.min(max, input.selected - input.limit + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = previewMargin(input.limit)
|
||||
if (input.dir < 0 && input.selected < value + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
|
||||
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
|
|
@ -63,15 +29,9 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
|||
|
||||
const reveal = (index: number) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
setSelected(0)
|
||||
setOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, index))
|
||||
const next = reconcileSelection(index, count)
|
||||
setSelected(next)
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
|
|
@ -81,28 +41,16 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
|||
|
||||
createEffect(() => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (selected() >= count) {
|
||||
setSelected(count - 1)
|
||||
}
|
||||
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
|
||||
const next = reconcileSelection(selected(), count)
|
||||
setSelected(next)
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
})
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
const count = input.count()
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, selected() + dir))
|
||||
const next = moveSelection(selected(), { count, delta: dir, policy: "clamp" })
|
||||
setSelected(next)
|
||||
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
|
||||
setOffset((value) => moveSelectionOffset(value, { count, limit: limit(), selected: next, direction: dir }))
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -169,8 +117,8 @@ export function RunFooterMenu(props: {
|
|||
const dir = props.selected() === previous + 1 ? 1 : props.selected() === previous - 1 ? -1 : undefined
|
||||
setGroupOffset((value) =>
|
||||
dir
|
||||
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
||||
: revealOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
? moveSelectionOffset(value, { count: all.length, limit: limit(), selected, direction: dir })
|
||||
: revealSelectionOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
)
|
||||
previous = props.selected()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ import {
|
|||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
promptCopy,
|
||||
pushPromptHistory,
|
||||
slashHead,
|
||||
} from "./prompt.shared"
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
|
|
@ -104,44 +105,12 @@ function clamp(rows: number): number {
|
|||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||
...(prompt.command ? { command: prompt.command } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPrompt(shell: boolean): RunPrompt {
|
||||
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||
}
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
return hash === -1 ? input : input.slice(0, hash)
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) {
|
||||
return { base: input }
|
||||
}
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const line = input.slice(hash + 1)
|
||||
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) {
|
||||
return { base }
|
||||
}
|
||||
|
||||
const start = Number(match[1])
|
||||
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, line: { start, end } }
|
||||
}
|
||||
|
||||
function slashQuery(text: string, cursor: number) {
|
||||
const head = slashHead(text.slice(0, cursor))
|
||||
const head = parseSlashHead(text.slice(0, cursor))
|
||||
if (!head || head.end !== cursor) {
|
||||
return
|
||||
}
|
||||
|
|
@ -150,7 +119,7 @@ function slashQuery(text: string, cursor: number) {
|
|||
}
|
||||
|
||||
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name.length === 0) {
|
||||
return { type: "none" as const }
|
||||
}
|
||||
|
|
@ -175,7 +144,7 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
|
|||
return
|
||||
}
|
||||
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name !== command.name) {
|
||||
return
|
||||
}
|
||||
|
|
@ -359,16 +328,16 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return []
|
||||
}
|
||||
|
||||
const next = extractLineRange(value)
|
||||
const next = parseFileLineRange(value)
|
||||
const list = await input.findFiles(next.base)
|
||||
return list.map((item): Auto => {
|
||||
const url = pathToFileURL(path.resolve(input.directory(), item))
|
||||
let filename = item
|
||||
if (next.line && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||
url.searchParams.set("start", String(next.line.start))
|
||||
if (next.line.end !== undefined) {
|
||||
url.searchParams.set("end", String(next.line.end))
|
||||
if (next.lineRange && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.lineRange.startLine}${next.lineRange.endLine ? `-${next.lineRange.endLine}` : ""}`
|
||||
url.searchParams.set("start", String(next.lineRange.startLine))
|
||||
if (next.lineRange.endLine !== undefined) {
|
||||
url.searchParams.set("end", String(next.lineRange.endLine))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -452,7 +421,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return mixed
|
||||
}
|
||||
|
||||
const next = removeLineRange(query())
|
||||
const next = stripFileLineRange(query())
|
||||
if (mode() === "mention") {
|
||||
return [
|
||||
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
|
|
@ -587,7 +556,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
draft = clonePrompt(value)
|
||||
draft = promptCopy(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
|
|
@ -726,7 +695,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
stash = clonePrompt(draft)
|
||||
stash = promptCopy(draft)
|
||||
}
|
||||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
|
|
@ -804,7 +773,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
syncDraft()
|
||||
hide()
|
||||
|
||||
const current = clonePrompt(draft)
|
||||
const current = promptCopy(draft)
|
||||
try {
|
||||
const content = await input.onEditorOpen({
|
||||
value: inputValue?.value ?? current.text,
|
||||
|
|
@ -847,7 +816,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const head = slashHead(area.plainText)
|
||||
const head = parseSlashHead(area.plainText)
|
||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
|
||||
const text = `/${next.name}${separator}`
|
||||
|
|
@ -864,7 +833,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
submitPrompt(clonePrompt(draft))
|
||||
submitPrompt(promptCopy(draft))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1127,7 +1096,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = clonePrompt(next)
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
|
|
@ -1183,7 +1152,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const onSubmit = () => {
|
||||
syncDraft()
|
||||
submitPrompt(clonePrompt(draft))
|
||||
submitPrompt(promptCopy(draft))
|
||||
}
|
||||
|
||||
const submitText = (text: string) => {
|
||||
|
|
|
|||
|
|
@ -115,10 +115,6 @@ function createEmptySubagentState(): FooterSubagentState {
|
|||
}
|
||||
|
||||
function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
||||
if (next.type === "queue") {
|
||||
return { queue: next.queue }
|
||||
}
|
||||
|
||||
if (next.type === "first") {
|
||||
return { first: next.first }
|
||||
}
|
||||
|
|
@ -131,7 +127,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||
return {
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: next.queue,
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
}
|
||||
|
|
@ -141,7 +136,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
|
|||
return {
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: next.queue,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +150,6 @@ export class RunFooter implements FooterApi {
|
|||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
|
||||
private closes = new Set<() => void>()
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
|
|
@ -226,7 +219,6 @@ export class RunFooter implements FooterApi {
|
|||
const [state, setState] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: options.modelLabel,
|
||||
usage: "",
|
||||
first: options.first,
|
||||
|
|
@ -328,7 +320,6 @@ export class RunFooter implements FooterApi {
|
|||
onStatus: footer.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
onSubagentInterrupt: options.onSubagentInterrupt,
|
||||
onQueuedRemove: footer.handleQueuedRemove,
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
|
@ -355,13 +346,6 @@ export class RunFooter implements FooterApi {
|
|||
}
|
||||
}
|
||||
|
||||
public onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void {
|
||||
this.queuedRemoves.add(fn)
|
||||
return () => {
|
||||
this.queuedRemoves.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
|
|
@ -487,7 +471,6 @@ export class RunFooter implements FooterApi {
|
|||
const state = {
|
||||
phase: next.phase ?? prev.phase,
|
||||
status: typeof next.status === "string" ? next.status : prev.status,
|
||||
queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue,
|
||||
model: typeof next.model === "string" ? next.model : prev.model,
|
||||
usage: typeof next.usage === "string" ? next.usage : prev.usage,
|
||||
first: typeof next.first === "boolean" ? next.first : prev.first,
|
||||
|
|
@ -665,11 +648,6 @@ export class RunFooter implements FooterApi {
|
|||
this.requestExitHandler = fn
|
||||
}
|
||||
|
||||
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
|
||||
const fn = [...this.queuedRemoves][0]
|
||||
return fn ? await fn(messageID) : false
|
||||
}
|
||||
|
||||
private handleInputClear = (): void => {
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
|
|
@ -1080,7 +1058,6 @@ export class RunFooter implements FooterApi {
|
|||
for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
|
||||
this.themeRefreshTimeouts.length = 0
|
||||
this.prompts.clear()
|
||||
this.queuedRemoves.clear()
|
||||
this.closes.clear()
|
||||
this.scrollback.destroy()
|
||||
for (const theme of [...this.themes]) this.destroyTheme(theme)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import type {
|
|||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -102,7 +101,6 @@ type RunFooterViewProps = {
|
|||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
|
@ -159,10 +157,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
return tabs().findIndex((item) => item.sessionID === sessionID) + 1
|
||||
})
|
||||
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
|
||||
const model = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
return current ? modelInfo(props.providers(), current).model : props.state().model
|
||||
})
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
|
|
@ -179,7 +173,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
|
|
@ -192,19 +185,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
const options = {
|
||||
color: theme().highlight,
|
||||
style: "blocks" as const,
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}
|
||||
return {
|
||||
frames: createFrames({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
color: createColors({
|
||||
color: theme().highlight,
|
||||
style: "blocks",
|
||||
inactiveFactor: 0.6,
|
||||
minAlpha: 0.3,
|
||||
}),
|
||||
frames: createFrames(options),
|
||||
color: createColors(options),
|
||||
}
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
|
|
@ -340,7 +329,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
return "EXIT"
|
||||
}
|
||||
|
||||
return shell() ? "SHELL" : "BUILD"
|
||||
return shell() ? "SHELL" : undefined
|
||||
})
|
||||
const modeColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
|
|
@ -375,17 +364,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
|
||||
return usage()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = props.currentModel()
|
||||
if (!prompt() || shell() || !current) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
model: model(),
|
||||
variant: props.currentVariant(),
|
||||
}
|
||||
})
|
||||
const statusColor = createMemo(() => {
|
||||
if (exiting()) {
|
||||
return theme().error
|
||||
|
|
@ -403,7 +381,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
|
||||
const hasModelStatus = createMemo(() => responsive().statusline.showModel && Boolean(modelStatus()))
|
||||
const contextHints = createMemo(() => {
|
||||
if (!prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
return []
|
||||
|
|
@ -414,7 +391,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queue()} queued` })
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||
|
|
@ -495,7 +472,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
commands: [
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "Manage queued prompts",
|
||||
title: "View pending work",
|
||||
group: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
|
|
@ -656,12 +633,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
onClose={closePanel}
|
||||
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
|
||||
onEdit={async (item) => {
|
||||
if (!(await props.onQueuedRemove(item.messageID))) return
|
||||
closePanel()
|
||||
queueMicrotask(() => composer.replacePrompt(item.prompt))
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
|
|
@ -804,11 +775,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
>
|
||||
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{modeLabel()}</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={modeLabel()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<box
|
||||
flexDirection="row"
|
||||
|
|
@ -844,28 +819,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={responsive().statusline.showModel && modelStatus()}>
|
||||
{(info) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
{info().model}
|
||||
<Show when={info().variant}>
|
||||
{(variant) => (
|
||||
<>
|
||||
<span style={{ fg: theme().warning, bold: true }}> {variant()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={24}>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
|
||||
<Show when={index() > 0 || (hasActivityMeta() && index() === 0)}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
|
||||
|
|
@ -879,7 +837,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
{(hint) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
|
||||
<Show when={hasActivityMeta() || hasContextHints()}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
const FOOTER_WIDTH_BREAKPOINTS = {
|
||||
compact: 80,
|
||||
commandHint: 66,
|
||||
model: 120,
|
||||
context: 120,
|
||||
spacious: 150,
|
||||
} as const
|
||||
|
||||
export function footerWidthPolicy(width: number) {
|
||||
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
|
||||
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
|
||||
const context = width >= FOOTER_WIDTH_BREAKPOINTS.context
|
||||
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
|
||||
|
||||
return {
|
||||
|
|
@ -20,8 +20,7 @@ export function footerWidthPolicy(width: number) {
|
|||
showActivityMeta: compact,
|
||||
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
|
||||
showContextHints: compact,
|
||||
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
|
||||
showModel: model,
|
||||
contextHintLimit: !compact ? 0 : spacious ? undefined : context ? 2 : 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
} from "../util/form"
|
||||
import type { FormAnswerField } from "../util/form"
|
||||
import type { FormReply, MiniFormRequest } from "./types"
|
||||
|
||||
type AnswerField = Exclude<FormField, { type: "external" }>
|
||||
export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue }
|
||||
|
||||
export type FormBodyState = {
|
||||
formID: string
|
||||
|
|
@ -15,31 +28,14 @@ export type FormBodyState = {
|
|||
error: string
|
||||
}
|
||||
|
||||
export type FormRow = {
|
||||
value: string | boolean
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function createFormBodyState(form: FormInfo): FormBodyState {
|
||||
const answers = Object.fromEntries(
|
||||
form.fields.flatMap((field) =>
|
||||
field.type !== "external" && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
)
|
||||
const custom = Object.fromEntries(
|
||||
form.fields.flatMap((field) => {
|
||||
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||
if (field.options.some((option) => option.value === field.default)) return []
|
||||
return [[field.key, field.default]]
|
||||
}),
|
||||
)
|
||||
const initial = formInitialValues(form.fields)
|
||||
return {
|
||||
formID: form.id,
|
||||
field: 0,
|
||||
answers,
|
||||
custom,
|
||||
selected: formSelected(form.fields[0], answers[form.fields[0]?.key ?? ""]),
|
||||
answers: initial.answers,
|
||||
custom: initial.custom,
|
||||
selected: formSelected(form.fields[0], initial.answers[form.fields[0]?.key ?? ""]),
|
||||
editing: formTextual(form.fields[0]),
|
||||
externalReady: {},
|
||||
submitting: false,
|
||||
|
|
@ -65,10 +61,6 @@ export function formUnsupported(form: FormInfo): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
export function formLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
export function formCurrent(form: FormInfo, state: FormBodyState) {
|
||||
return form.fields[state.field]
|
||||
}
|
||||
|
|
@ -89,46 +81,11 @@ export function formSingle(form: FormInfo) {
|
|||
)
|
||||
}
|
||||
|
||||
export function formTextual(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
return field.type === "number" || field.type === "integer" || (field.type === "string" && !field.options)
|
||||
}
|
||||
|
||||
export function formPlaceholder(field: FormField | undefined) {
|
||||
if (field?.type === "string") return field.placeholder ?? "Type your answer"
|
||||
return "Enter a number"
|
||||
}
|
||||
|
||||
export function formCustom(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "string" && field.options) return field.custom === true
|
||||
return field.type === "multiselect" && field.custom === true
|
||||
}
|
||||
|
||||
export function formRows(field: FormField | undefined): FormRow[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
]
|
||||
const options = field.type === "multiselect" ? field.options : field.type === "string" ? field.options : undefined
|
||||
if (!options) return []
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formSelected(field: FormField | undefined, value: FormValue | undefined) {
|
||||
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||
const index = formRows(field).findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && formCustom(field)) return formRows(field).length
|
||||
return 0
|
||||
}
|
||||
|
||||
export function formMove(state: FormBodyState, form: FormInfo, direction: -1 | 1): FormBodyState {
|
||||
const field = formCurrent(form, state)
|
||||
const total = formRows(field).length + (formCustom(field) ? 1 : 0)
|
||||
|
|
@ -166,40 +123,6 @@ export function formSetDraft(state: FormBodyState, field: FormField | undefined,
|
|||
return { ...state, custom: { ...state.custom, [field.key]: value } }
|
||||
}
|
||||
|
||||
export function formValidateValue(field: AnswerField, value: FormValue | undefined): string | undefined {
|
||||
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0)))
|
||||
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return "Expected text"
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "uri" && !validURL(value)) return "Expected a URL"
|
||||
if (field.format === "date" && !validDate(value)) return "Expected a date (YYYY-MM-DD)"
|
||||
if (field.format === "date-time" && Number.isNaN(new Date(value).getTime())) return "Expected a date and time"
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value))
|
||||
return "Select an available option"
|
||||
return
|
||||
}
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return
|
||||
}
|
||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||
if (!Array.isArray(value)) return "Expected selections"
|
||||
if (field.required && value.length === 0) return "Select at least one option"
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item)))
|
||||
return "Select only available options"
|
||||
}
|
||||
|
||||
export function formValidate(form: FormInfo, state: FormBodyState): string | undefined {
|
||||
const unsupported = formUnsupported(form)
|
||||
if (unsupported) return unsupported
|
||||
|
|
@ -249,13 +172,11 @@ export function formPick(state: FormBodyState, form: FormInfo): FormBodyState {
|
|||
const row = rows[state.selected]
|
||||
if (!row) return state
|
||||
if (field.type === "multiselect") {
|
||||
const answer = state.answers[field.key]
|
||||
const values = Array.isArray(answer) ? [...answer] : []
|
||||
const value = String(row.value)
|
||||
const index = values.indexOf(value)
|
||||
if (index === -1) values.push(value)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
return { ...state, answers: { ...state.answers, [field.key]: values }, error: "" }
|
||||
return {
|
||||
...state,
|
||||
answers: { ...state.answers, [field.key]: formToggleMultiselect(state.answers[field.key], String(row.value)) },
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
...state,
|
||||
|
|
@ -271,14 +192,7 @@ export function formCommitInput(state: FormBodyState, form: FormInfo, text: stri
|
|||
const input = text.trim()
|
||||
const value = !input ? undefined : field.type === "number" || field.type === "integer" ? Number(input) : input
|
||||
if (field.type === "multiselect") {
|
||||
const answer = state.answers[field.key]
|
||||
const values = Array.isArray(answer) ? [...answer] : []
|
||||
const previous = state.custom[field.key]
|
||||
if (previous) {
|
||||
const index = values.indexOf(previous)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
}
|
||||
if (input && !values.includes(input)) values.push(input)
|
||||
const values = formSetMultiselectCustom(state.answers[field.key], state.custom[field.key], input)
|
||||
const invalid = formValidateValue(field, values)
|
||||
if (invalid) return formSetError(state, invalid)
|
||||
return {
|
||||
|
|
@ -311,11 +225,8 @@ export function formAcknowledge(state: FormBodyState, form: FormInfo): FormBodyS
|
|||
return formSetField(next, form, formSingle(form) ? state.field : state.field + 1)
|
||||
}
|
||||
|
||||
export function formDisplay(field: AnswerField, value: FormValue | undefined) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
formRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
return Array.isArray(value) ? value.map(label).join(", ") : label(value)
|
||||
export function formDisplay(field: FormAnswerField, value: FormValue | undefined) {
|
||||
return formDisplayValue(field, value, "")
|
||||
}
|
||||
|
||||
export function formErrorMessage(error: unknown) {
|
||||
|
|
@ -328,18 +239,3 @@ export function formErrorMessage(error: unknown) {
|
|||
}
|
||||
return "Form request failed"
|
||||
}
|
||||
|
||||
function validURL(value: string) {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,6 @@
|
|||
// Pure state machine for the permission UI.
|
||||
//
|
||||
// Lives outside the JSX component so it can be tested independently. The
|
||||
// machine has three stages:
|
||||
//
|
||||
// permission → initial view with Allow once / Always / Reject options
|
||||
// always → confirmation step (Confirm / Cancel)
|
||||
// reject → text input for rejection message
|
||||
//
|
||||
// permissionRun() is the main transition: given the current state and the
|
||||
// selected option, it returns a new state and optionally a PermissionReply
|
||||
// to send to the SDK. The component calls this on enter/click.
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../util/permission"
|
||||
import { toolPath } from "./tool"
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
|
|
@ -30,46 +14,11 @@ export type PermissionBodyState = {
|
|||
submitting: boolean
|
||||
}
|
||||
|
||||
export type PermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionStep = {
|
||||
state: PermissionBodyState
|
||||
reply?: PermissionReply
|
||||
}
|
||||
|
||||
function dict(v: unknown): Dict {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { ...v }
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: MiniPermissionRequest): { input: Dict; metadata: Dict } {
|
||||
const state = request.tool?.state
|
||||
const metadata = {
|
||||
...(state && state.status !== "streaming" ? dict(state.structured) : {}),
|
||||
...dict(request.metadata),
|
||||
}
|
||||
if (!state || state.status === "streaming") return { input: {}, metadata }
|
||||
return { input: dict(state.input), metadata }
|
||||
}
|
||||
|
||||
function patterns(request: MiniPermissionRequest): string[] {
|
||||
return request.resources.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(
|
||||
request: Pick<MiniPermissionRequest, "id" | "sessionID">,
|
||||
): PermissionBodyState {
|
||||
|
|
@ -95,57 +44,26 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
|||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const source = data(request)
|
||||
const info = toolPermissionInfo(request.action, source.input, source.metadata, pats, directory)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.action === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${toolPath(dir, { home: true, directory })}`,
|
||||
lines: pats.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (request.action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.action}`,
|
||||
lines: [`Tool: ${request.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: MiniPermissionRequest): string[] {
|
||||
const save = request.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string) {
|
||||
const state = request.tool?.state
|
||||
return permissionPresentation(
|
||||
{
|
||||
action: request.action,
|
||||
resources: request.resources,
|
||||
metadata: request.metadata,
|
||||
input: state?.status === "streaming" ? undefined : state?.input,
|
||||
structured: state?.status === "streaming" ? undefined : state?.structured,
|
||||
},
|
||||
(value) => toolPath(value, { home: true, directory }),
|
||||
)
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
return permissionOptionLabel(option)
|
||||
}
|
||||
|
||||
export { permissionAlwaysLines }
|
||||
|
||||
export function permissionReply(
|
||||
sessionID: string,
|
||||
requestID: string,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { RunPromptPart } from "./types"
|
||||
import { slashHead } from "./prompt.shared"
|
||||
import { realignPromptMentions } from "../prompt/mention"
|
||||
import { parseSlashHead } from "../prompt/parse"
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
|
||||
export function resolveEditorSlashValue(text: string) {
|
||||
const head = slashHead(text)
|
||||
const head = parseSlashHead(text)
|
||||
if (!head || head.name.toLowerCase() !== "editor") {
|
||||
return text
|
||||
}
|
||||
|
|
@ -13,95 +14,27 @@ export function resolveEditorSlashValue(text: string) {
|
|||
}
|
||||
|
||||
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
|
||||
const matches = new Map<number, Mention | undefined>()
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
const matches = realignPromptMentions(
|
||||
content,
|
||||
parts.map((part) => {
|
||||
if (part.type !== "file" && part.type !== "agent") return
|
||||
return promptPartMention(part)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = promptPartText(part)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
|
||||
if (start === -1) {
|
||||
matches.set(index, undefined)
|
||||
continue
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
used.push({ start, end })
|
||||
matches.set(index, updatePromptPart(part, start, end, text))
|
||||
}
|
||||
|
||||
const next: RunPromptPart[] = []
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part.type !== "file" && part.type !== "agent") {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!promptPartText(part)) {
|
||||
next.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
const match = matches.get(index)
|
||||
if (match) {
|
||||
next.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
return parts.flatMap((part, index) => {
|
||||
if (part.type !== "file" && part.type !== "agent") return [part]
|
||||
const mention = promptPartMention(part)
|
||||
if (!mention?.text) return [part]
|
||||
const match = matches[index]
|
||||
return match ? [updatePromptPart(part, match.start, match.end, match.text)] : []
|
||||
})
|
||||
}
|
||||
|
||||
function promptPartText(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.value
|
||||
}
|
||||
|
||||
return part.source?.text.value
|
||||
}
|
||||
|
||||
function promptPartStart(part: Mention) {
|
||||
if (part.type === "agent") {
|
||||
return part.source?.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
return part.source?.text.start ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
|
||||
let searchFrom = 0
|
||||
let best = -1
|
||||
let distance = Number.POSITIVE_INFINITY
|
||||
const hinted = Number.isFinite(hint)
|
||||
|
||||
while (true) {
|
||||
const start = content.indexOf(text, searchFrom)
|
||||
if (start === -1) {
|
||||
return best
|
||||
}
|
||||
|
||||
const end = start + text.length
|
||||
searchFrom = start + 1
|
||||
if (used.some((range) => start < range.end && end > range.start)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hinted) {
|
||||
return start
|
||||
}
|
||||
|
||||
const nextDistance = Math.abs(start - hint)
|
||||
if (nextDistance < distance) {
|
||||
best = start
|
||||
distance = nextDistance
|
||||
}
|
||||
}
|
||||
function promptPartMention(part: Mention) {
|
||||
const source = part.type === "agent" ? part.source : part.source?.text
|
||||
if (!source) return
|
||||
return { start: source.start, end: source.end, text: source.value }
|
||||
}
|
||||
|
||||
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
|
||||
|
|
|
|||
|
|
@ -53,14 +53,6 @@ export function isNewCommand(input: string): boolean {
|
|||
return input.trim().toLowerCase() === "/new"
|
||||
}
|
||||
|
||||
export function slashHead(text: string) {
|
||||
if (!text.startsWith("/")) return
|
||||
const end = text.slice(1).search(/[ \t\n]/)
|
||||
if (end === -1) return { name: text.slice(1), arguments: "", end: text.length }
|
||||
const split = end + 1
|
||||
return { name: text.slice(1, split), arguments: text.slice(split + 1), end: split }
|
||||
}
|
||||
|
||||
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
|
||||
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
|
||||
const next: RunPrompt[] = []
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time; ordinary prompts waiting behind
|
||||
// an active ordinary turn are exposed for edit/removal until they begin.
|
||||
// Prompts arrive from the footer (user types and hits enter) and local
|
||||
// operations drain one at a time. Ordinary prompts submitted during an active
|
||||
// ordinary turn are admitted immediately to the server's durable queue.
|
||||
//
|
||||
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
|
|
@ -11,84 +11,54 @@
|
|||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
}
|
||||
|
||||
type Deferred<T = void> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
reject: (error?: unknown) => void
|
||||
}
|
||||
|
||||
export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt) => void
|
||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
queue: RunPrompt[]
|
||||
queued: FooterQueuedPrompt[]
|
||||
active?: RunPrompt
|
||||
admission?: Promise<void>
|
||||
ctrl?: AbortController
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
function defer<T = void>(): Deferred<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next
|
||||
reject = fail
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events and drains operations through input.run().
|
||||
// Ordinary prompts submitted during an ordinary active turn remain local and
|
||||
// are exposed by the footer for edit/removal until their turn begins.
|
||||
// Ordinary prompts submitted during an ordinary active turn are admitted as
|
||||
// durable queued work instead of remaining editable process-local state.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const stop = defer<{ type: "closed" }>()
|
||||
const done = defer()
|
||||
const stop = Promise.withResolvers<{ type: "closed" }>()
|
||||
const done = Promise.withResolvers<void>()
|
||||
const state: State = {
|
||||
queue: [],
|
||||
queued: [],
|
||||
closed: input.footer.isClosed,
|
||||
}
|
||||
let draining: Promise<void> | undefined
|
||||
let admissions = Promise.resolve()
|
||||
let admissionVersion = 0
|
||||
const admissionController = new AbortController()
|
||||
|
||||
const emit = (next: FooterEvent, row: Record<string, unknown>) => {
|
||||
input.trace?.write("ui.patch", row)
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const syncQueue = () => {
|
||||
const queue = state.queue.length
|
||||
emit({ type: "queue", queue }, { queue })
|
||||
emit(
|
||||
{
|
||||
type: "queued.prompts",
|
||||
prompts: [...state.queued],
|
||||
},
|
||||
{ queued: state.queued.length },
|
||||
)
|
||||
}
|
||||
|
||||
const removeLocalQueued = (queued: FooterQueuedPrompt) => {
|
||||
if (!state.queued.includes(queued)) return
|
||||
state.queued = state.queued.filter((item) => item !== queued)
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!state.closed || draining) {
|
||||
return
|
||||
|
|
@ -104,8 +74,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
state.queued.length = 0
|
||||
state.ctrl?.abort()
|
||||
admissionController.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
}
|
||||
|
|
@ -123,11 +93,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
continue
|
||||
}
|
||||
|
||||
const queued = state.queued.find((item) => item.prompt === prompt)
|
||||
if (queued) removeLocalQueued(queued)
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
syncQueue()
|
||||
if (!input.onNewSession) {
|
||||
emit(
|
||||
{
|
||||
|
|
@ -149,13 +115,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
patch: {
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
phase: "running",
|
||||
status: "starting new session",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
await input.onNewSession()
|
||||
|
|
@ -167,24 +131,23 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
? prompt
|
||||
: {
|
||||
...prompt,
|
||||
messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
|
||||
messageID: prompt.messageID ?? SessionMessage.ID.create(),
|
||||
}
|
||||
state.active = sent
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{ type: "turn.send" },
|
||||
{
|
||||
phase: "running",
|
||||
status: "sending prompt",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
const start = Date.now()
|
||||
const ctrl = new AbortController()
|
||||
const admission = Promise.withResolvers<void>()
|
||||
const version = admissionVersion
|
||||
state.ctrl = ctrl
|
||||
state.admission = admission.promise
|
||||
|
||||
try {
|
||||
await input.footer.idle()
|
||||
|
|
@ -203,13 +166,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent)
|
||||
input.onSend?.(sent, "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
}
|
||||
|
||||
const task = input.run(sent, ctrl.signal).then(
|
||||
const task = input.run(sent, ctrl.signal, admission.resolve).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
|
|
@ -223,10 +186,21 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
if (next.type === "error") {
|
||||
throw next.error
|
||||
}
|
||||
if (sent.mode !== "shell" && admissionVersion !== version) {
|
||||
do {
|
||||
const current = admissionVersion
|
||||
await admissions
|
||||
if (state.closed) break
|
||||
await input.settle()
|
||||
if (current === admissionVersion) break
|
||||
} while (!state.closed)
|
||||
}
|
||||
} finally {
|
||||
admission.resolve()
|
||||
if (state.ctrl === ctrl) {
|
||||
state.ctrl = undefined
|
||||
}
|
||||
if (state.admission === admission.promise) state.admission = undefined
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const duration = Locale.duration(Math.max(0, Date.now() - start))
|
||||
|
|
@ -249,14 +223,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
} finally {
|
||||
draining = undefined
|
||||
emit(
|
||||
{
|
||||
type: "turn.idle",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{ type: "turn.idle" },
|
||||
{
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -279,23 +249,22 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
if (
|
||||
active &&
|
||||
active.mode !== "shell" &&
|
||||
!active.command &&
|
||||
prompt.mode !== "shell" &&
|
||||
!prompt.command &&
|
||||
prompt.command?.source !== "skill" &&
|
||||
!isNewCommand(prompt.text)
|
||||
) {
|
||||
const queued: FooterQueuedPrompt = {
|
||||
messageID: SessionMessage.ID.create(),
|
||||
prompt,
|
||||
}
|
||||
state.queued = [...state.queued, queued]
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
input.onSend?.(sent, "queue")
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
drain()
|
||||
return
|
||||
|
|
@ -319,14 +288,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
const offClose = input.footer.onClose(() => {
|
||||
close()
|
||||
})
|
||||
const offRemoveQueued = input.footer.onQueuedRemove((messageID) => {
|
||||
const queued = state.queued.find((item) => item.messageID === messageID)
|
||||
if (!queued) return false
|
||||
state.queue = state.queue.filter((prompt) => prompt !== queued.prompt)
|
||||
removeLocalQueued(queued)
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
if (state.closed) {
|
||||
return
|
||||
|
|
@ -341,8 +302,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
offRemoveQueued()
|
||||
close()
|
||||
await draining?.catch(() => {})
|
||||
await admissions
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -729,6 +729,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
onCommit: rememberLocal,
|
||||
trace: log,
|
||||
onCatalogRefresh: requestCatalogRefresh,
|
||||
contextLimit: (model) =>
|
||||
state.providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.limit?.context,
|
||||
})
|
||||
if (footer.isClosed) {
|
||||
await handle.close()
|
||||
|
|
@ -780,6 +782,22 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}, RESIZE_DELAY)
|
||||
})
|
||||
|
||||
const renderPromptError = async (prompt: RunPrompt, error: unknown, signal?: AbortSignal) => {
|
||||
if (signal?.aborted || footer.isClosed) return
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
}
|
||||
|
||||
const runQueue = async () => {
|
||||
await firstPaint
|
||||
if (footer.isClosed) return
|
||||
|
|
@ -798,10 +816,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
footer,
|
||||
initialInput: input.initialInput,
|
||||
trace: log,
|
||||
onSend: (prompt) => {
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
if (prompt.mode !== "shell") {
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
|
|
@ -811,6 +829,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
settle: async () => {
|
||||
const next = await ensureStream()
|
||||
await next.handle.waitForIdle()
|
||||
},
|
||||
onNewSession: createSession
|
||||
? async () => {
|
||||
try {
|
||||
|
|
@ -856,6 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
},
|
||||
})
|
||||
footer.event({ type: "stream.view", view: { type: "prompt" } })
|
||||
footer.event({ type: "queued.prompts", prompts: [] })
|
||||
footer.event({
|
||||
type: "stream.patch",
|
||||
patch: {
|
||||
|
|
@ -891,7 +928,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}
|
||||
}
|
||||
: undefined,
|
||||
run: async (prompt, signal) => {
|
||||
run: async (prompt, signal, admitted) => {
|
||||
if (state.demo && (await state.demo.prompt(prompt, signal))) {
|
||||
return
|
||||
}
|
||||
|
|
@ -900,15 +937,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
|
||||
try {
|
||||
const next = await ensureStream()
|
||||
await next.handle.runPromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
})
|
||||
await next.handle.runPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
signal,
|
||||
},
|
||||
admitted,
|
||||
)
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
|
|
@ -918,22 +958,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
// pending for the next prompt-shaped turn.
|
||||
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
await renderPromptError(prompt, error, signal)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
86
packages/tui/src/mini/stream-v2.fragment.ts
Normal file
86
packages/tui/src/mini/stream-v2.fragment.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export type FragmentRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
type FragmentState = {
|
||||
text: string
|
||||
projected?: string
|
||||
}
|
||||
|
||||
export type FragmentUpdate = FragmentRef & {
|
||||
key: string
|
||||
previous: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type FragmentRestore =
|
||||
| { type: "append"; suffix: string }
|
||||
| { type: "covered" }
|
||||
| { type: "conflict" }
|
||||
|
||||
export function fragmentRef(messageID: string, kind: "text" | "reasoning", ordinal: number): FragmentRef {
|
||||
return { messageID, partID: `${kind}:${ordinal}` }
|
||||
}
|
||||
|
||||
export function createFragmentReconciler() {
|
||||
const fragments = new Map<string, FragmentState>()
|
||||
const key = (fragment: FragmentRef) => `${fragment.messageID}\u0000${fragment.partID}`
|
||||
|
||||
return {
|
||||
clear() {
|
||||
fragments.clear()
|
||||
},
|
||||
key,
|
||||
value(fragment: FragmentRef) {
|
||||
return fragments.get(key(fragment))?.text
|
||||
},
|
||||
project(fragment: FragmentRef, text: string, visible: boolean): FragmentUpdate {
|
||||
const id = key(fragment)
|
||||
const current = fragments.get(id)
|
||||
fragments.set(id, {
|
||||
text,
|
||||
projected: visible ? text : current?.projected,
|
||||
})
|
||||
return { ...fragment, key: id, previous: current?.text ?? "", text }
|
||||
},
|
||||
delta(fragment: FragmentRef, delta: string): FragmentUpdate | undefined {
|
||||
const id = key(fragment)
|
||||
const current = fragments.get(id)
|
||||
// Replay may start after an unseen prefix, so consume a covered chunk
|
||||
// from anywhere in the remaining projection rather than only its start.
|
||||
const covered = current?.projected?.indexOf(delta) ?? -1
|
||||
if (current?.projected && covered >= 0) {
|
||||
current.projected = current.projected.slice(covered + delta.length)
|
||||
return
|
||||
}
|
||||
const previous = current?.text ?? ""
|
||||
const text = previous + delta
|
||||
fragments.set(id, { text, projected: current?.projected })
|
||||
return { ...fragment, key: id, previous, text }
|
||||
},
|
||||
end(fragment: FragmentRef, text: string): FragmentUpdate {
|
||||
const id = key(fragment)
|
||||
const previous = fragments.get(id)?.text ?? ""
|
||||
fragments.set(id, { text })
|
||||
return { ...fragment, key: id, previous, text }
|
||||
},
|
||||
restore(fragment: FragmentRef, text: string): FragmentRestore {
|
||||
const id = key(fragment)
|
||||
const current = fragments.get(id)
|
||||
if (!current) {
|
||||
fragments.set(id, { text, projected: text })
|
||||
return { type: "append", suffix: text }
|
||||
}
|
||||
if (text.startsWith(current.text)) {
|
||||
const suffix = text.slice(current.text.length)
|
||||
fragments.set(id, { text, projected: text })
|
||||
return { type: "append", suffix }
|
||||
}
|
||||
if (current.text.startsWith(text)) return { type: "covered" }
|
||||
return { type: "conflict" }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type FragmentReconciler = ReturnType<typeof createFragmentReconciler>
|
||||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Locale } from "../util/locale"
|
||||
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||
import type {
|
||||
FooterSubagentDetail,
|
||||
FooterSubagentState,
|
||||
|
|
@ -105,10 +106,7 @@ type ChildState = {
|
|||
title?: string
|
||||
lastUpdatedAt: number
|
||||
frames: Frame[]
|
||||
text: Map<string, string>
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
fragments: FragmentReconciler
|
||||
tools: Map<string, ToolTrack>
|
||||
toolSources: Map<string, SessionMessageAssistantTool>
|
||||
finishedTools: Set<string>
|
||||
|
|
@ -225,8 +223,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
let blockerEpoch = 0
|
||||
let closed = false
|
||||
const active = (signal = input.signal) => !closed && !input.signal.aborted && !signal.aborted
|
||||
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
|
||||
|
||||
const admitChild = (sessionID: string): ChildState | undefined => {
|
||||
const existing = children.get(sessionID)
|
||||
if (!existing && children.size >= FAMILY_LIST_LIMIT) return
|
||||
|
|
@ -238,10 +234,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
background: false,
|
||||
lastUpdatedAt: 0,
|
||||
frames: [],
|
||||
text: new Map(),
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
fragments: createFragmentReconciler(),
|
||||
tools: new Map(),
|
||||
toolSources: new Map(),
|
||||
finishedTools: new Set(),
|
||||
|
|
@ -337,10 +330,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
|
||||
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
||||
child.frames = []
|
||||
child.text.clear()
|
||||
child.projectedText.clear()
|
||||
child.reasoning.clear()
|
||||
child.projectedReasoning.clear()
|
||||
child.fragments.clear()
|
||||
child.finishedTools.clear()
|
||||
child.toolSources.clear()
|
||||
child.messageIDs.clear()
|
||||
|
|
@ -356,33 +346,29 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
let reasoningOrdinal = 0
|
||||
for (const item of message.content) {
|
||||
if (item.type === "text") {
|
||||
const id = `text:${textOrdinal++}`
|
||||
const key = fragmentKey(message.id, id)
|
||||
child.text.set(key, item.text)
|
||||
child.projectedText.set(key, item.text)
|
||||
setFrame(child, key, {
|
||||
const fragment = fragmentRef(message.id, "text", textOrdinal++)
|
||||
const update = child.fragments.project(fragment, item.text, true)
|
||||
setFrame(child, update.key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: item.text,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: id,
|
||||
partID: fragment.partID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (item.type === "reasoning") {
|
||||
const id = `reasoning:${reasoningOrdinal++}`
|
||||
const key = fragmentKey(message.id, id)
|
||||
child.reasoning.set(key, item.text)
|
||||
child.projectedReasoning.set(key, item.text)
|
||||
const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
|
||||
const update = child.fragments.project(fragment, item.text, true)
|
||||
if (input.thinking)
|
||||
setFrame(child, key, {
|
||||
setFrame(child, update.key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${item.text}`,
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: id,
|
||||
partID: fragment.partID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
@ -678,40 +664,35 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const id = `text:${event.data.ordinal}`
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
const projected = child.projectedText.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.text.get(key) ?? "") + event.data.delta
|
||||
child.text.set(key, next)
|
||||
setFrame(child, key, {
|
||||
const update = child.fragments.delta(
|
||||
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||
event.data.delta,
|
||||
)
|
||||
if (!update) return
|
||||
setFrame(child, update.key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: next,
|
||||
text: update.text,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
const id = `text:${event.data.ordinal}`
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
child.text.set(key, event.data.text)
|
||||
child.projectedText.delete(key)
|
||||
setFrame(child, key, {
|
||||
const update = child.fragments.end(
|
||||
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||
event.data.text,
|
||||
)
|
||||
setFrame(child, update.key, {
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: event.data.text,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
})
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
|
|
@ -721,41 +702,36 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const id = `reasoning:${event.data.ordinal}`
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
const projected = child.projectedReasoning.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const next = (child.reasoning.get(key) ?? "") + event.data.delta
|
||||
child.reasoning.set(key, next)
|
||||
const update = child.fragments.delta(
|
||||
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||
event.data.delta,
|
||||
)
|
||||
if (!update) return
|
||||
if (!input.thinking) return
|
||||
setFrame(child, key, {
|
||||
setFrame(child, update.key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${next}`,
|
||||
text: `Thinking: ${update.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
const id = `reasoning:${event.data.ordinal}`
|
||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
||||
child.reasoning.set(key, event.data.text)
|
||||
child.projectedReasoning.delete(key)
|
||||
const update = child.fragments.end(
|
||||
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||
event.data.text,
|
||||
)
|
||||
if (!input.thinking) return
|
||||
setFrame(child, key, {
|
||||
setFrame(child, update.key, {
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: `Thinking: ${event.data.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
})
|
||||
notifyDetail(child)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import type {
|
|||
PermissionV2Request,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionPendingInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { formatContextUsage } from "../util/session"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
import { normalizeTool, toolOutputText } from "./tool"
|
||||
import type {
|
||||
|
|
@ -19,6 +22,7 @@ import type {
|
|||
LocalReplayRow,
|
||||
MiniPermissionRequest,
|
||||
MiniFormRequest,
|
||||
FooterQueuedPrompt,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
|
|
@ -45,6 +49,7 @@ type StreamInput = {
|
|||
trace?: Trace
|
||||
signal?: AbortSignal
|
||||
onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise<unknown>
|
||||
contextLimit?: (model: NonNullable<RunInput["model"]>) => number | undefined
|
||||
}
|
||||
|
||||
export type SessionTurnInput = {
|
||||
|
|
@ -63,7 +68,9 @@ export type SessionResizeReplayInput = {
|
|||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||
|
|
@ -73,11 +80,12 @@ export type SessionTransport = {
|
|||
|
||||
type Wait = {
|
||||
messageID: string
|
||||
failureMessageID: string
|
||||
promoted: boolean
|
||||
promotionObserved: boolean
|
||||
interrupted: boolean
|
||||
failureRendered: boolean
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
terminalError?: Error
|
||||
}
|
||||
|
||||
// One active session.shell call. The HTTP response is the completion signal;
|
||||
|
|
@ -117,10 +125,7 @@ type State = {
|
|||
globalForms: MiniFormRequest[]
|
||||
view: FooterView
|
||||
messageIDs: Set<string>
|
||||
text: Map<string, string>
|
||||
projectedText: Map<string, string>
|
||||
reasoning: Map<string, string>
|
||||
projectedReasoning: Map<string, string>
|
||||
fragments: FragmentReconciler
|
||||
tools: Map<string, ToolState>
|
||||
toolSources: Map<string, SessionMessageAssistantTool>
|
||||
finishedTools: Set<string>
|
||||
|
|
@ -136,6 +141,9 @@ type State = {
|
|||
rootActive: boolean
|
||||
buffered?: ReplayBuffer
|
||||
errors: Set<string>
|
||||
pending: Map<string, FooterQueuedPrompt>
|
||||
admitted: Set<string>
|
||||
stepModel: RunInput["model"]
|
||||
}
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||
|
|
@ -169,6 +177,16 @@ function errorMessage(error: { message?: string; _tag?: string }) {
|
|||
return error.message || error._tag || "Session execution failed"
|
||||
}
|
||||
|
||||
function pendingPrompt(item: SessionPendingInfo): FooterQueuedPrompt | undefined {
|
||||
if (item.type !== "user") return undefined
|
||||
return {
|
||||
messageID: item.id,
|
||||
prompt: { messageID: item.id, text: item.data.text, parts: [] },
|
||||
delivery: item.delivery,
|
||||
admittedSeq: item.admittedSeq,
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
|
|
@ -202,12 +220,12 @@ function nextEvent(stream: AsyncIterator<RunV2Event>, signal: AbortSignal) {
|
|||
})
|
||||
}
|
||||
|
||||
async function prepareFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
|
||||
if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
|
||||
async function prepareInitialFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
|
||||
if (file.mime !== "text/plain") return { type: "file" as const, file: { uri: file.url, name: file.filename } }
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
: await (readTextFile?.(file.url) ?? Promise.reject(new Error("Local text file acquisition is unavailable")))
|
||||
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
return { type: "text" as const, text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
|
||||
function promptFileMention(part: PromptFilePart) {
|
||||
|
|
@ -233,6 +251,25 @@ function promptFiles(next: SessionTurnInput) {
|
|||
)
|
||||
}
|
||||
|
||||
async function prepareAttachments(
|
||||
next: SessionTurnInput,
|
||||
mode: "command" | "prompt",
|
||||
readTextFile?: StreamInput["readTextFile"],
|
||||
) {
|
||||
const initial = next.includeFiles ? next.files : []
|
||||
if (mode === "command") {
|
||||
return {
|
||||
text: [],
|
||||
files: [...initial.map((file) => ({ uri: file.url, name: file.filename })), ...promptFiles(next)],
|
||||
}
|
||||
}
|
||||
const prepared = await Promise.all(initial.map((file) => prepareInitialFile(file, readTextFile)))
|
||||
return {
|
||||
text: prepared.flatMap((file) => (file.type === "text" ? [file.text] : [])),
|
||||
files: [...prepared.flatMap((file) => (file.type === "file" ? [file.file] : [])), ...promptFiles(next)],
|
||||
}
|
||||
}
|
||||
|
||||
function promptAgents(next: SessionTurnInput) {
|
||||
return next.prompt.parts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
|
|
@ -352,6 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
let sdk = input.sdk
|
||||
let generation = 0
|
||||
let activeAttempt: Attempt | undefined
|
||||
let settlementClient: OpenCodeClient | undefined
|
||||
input.signal?.addEventListener("abort", () => controller.abort(), { once: true })
|
||||
const state: State = {
|
||||
permissions: [],
|
||||
|
|
@ -359,10 +397,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
globalForms: [],
|
||||
view: { type: "prompt" },
|
||||
messageIDs: new Set(),
|
||||
text: new Map(),
|
||||
projectedText: new Map(),
|
||||
reasoning: new Map(),
|
||||
projectedReasoning: new Map(),
|
||||
fragments: createFragmentReconciler(),
|
||||
tools: new Map(),
|
||||
toolSources: new Map(),
|
||||
finishedTools: new Set(),
|
||||
|
|
@ -375,6 +410,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
initial: true,
|
||||
rootActive: false,
|
||||
errors: new Set(),
|
||||
pending: new Map(),
|
||||
admitted: new Set(),
|
||||
stepModel: undefined,
|
||||
}
|
||||
let readyResolve!: () => void
|
||||
let readyReject!: (error: unknown) => void
|
||||
|
|
@ -400,7 +438,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
emit: () => {
|
||||
if (state.closed || input.footer.isClosed) return
|
||||
const snapshot = subagents.snapshot()
|
||||
writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits: [], footer: { subagent: snapshot } })
|
||||
writeSessionOutput(
|
||||
{ footer: input.footer, trace: input.trace },
|
||||
{ commits: [], updates: [{ type: "stream.subagent", state: snapshot }] },
|
||||
)
|
||||
syncBlockers()
|
||||
},
|
||||
})
|
||||
|
|
@ -414,14 +455,40 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
input.onCommit?.(commit)
|
||||
return
|
||||
}
|
||||
const key = streamPartKey(commit.messageID, commit.partID)
|
||||
const text = commit.kind === "assistant" ? state.text.get(key) : state.reasoning.get(key)
|
||||
const text = state.fragments.value({ messageID: commit.messageID, partID: commit.partID })
|
||||
input.onCommit?.({
|
||||
...commit,
|
||||
text: commit.kind === "reasoning" && text ? `Thinking: ${text}` : (text ?? commit.text),
|
||||
})
|
||||
})
|
||||
writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined })
|
||||
writeSessionOutput(
|
||||
{ footer: input.footer, trace: input.trace },
|
||||
{ commits, updates: patch ? [{ type: "stream.patch", patch }] : undefined },
|
||||
)
|
||||
}
|
||||
|
||||
const syncPending = () => {
|
||||
const prompts = [...state.pending.values()].toSorted((left, right) => left.admittedSeq - right.admittedSeq)
|
||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
|
||||
const mergePending = (item: SessionPendingInfo) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
if (!prompt || state.messageIDs.has(prompt.messageID)) return
|
||||
state.admitted.add(prompt.messageID)
|
||||
state.pending.set(prompt.messageID, prompt)
|
||||
syncPending()
|
||||
}
|
||||
|
||||
const promoteWait = (wait: Wait, observed: boolean, messageID = wait.messageID) => {
|
||||
const transition = messageID !== wait.failureMessageID || (observed ? !wait.promotionObserved : !wait.promoted)
|
||||
wait.promoted = true
|
||||
if (observed) wait.promotionObserved = true
|
||||
if (!transition) return
|
||||
wait.failureMessageID = messageID
|
||||
wait.failureRendered = false
|
||||
wait.terminalError = undefined
|
||||
}
|
||||
|
||||
const syncBlockers = () => {
|
||||
|
|
@ -438,13 +505,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
{ footer: input.footer, trace: input.trace },
|
||||
{
|
||||
commits: [],
|
||||
footer: {
|
||||
view: next,
|
||||
patch:
|
||||
next.type === "prompt"
|
||||
? { phase: state.rootActive ? "running" : "idle", status: blockerStatus(next) }
|
||||
: { status: blockerStatus(next) },
|
||||
},
|
||||
updates: [
|
||||
{
|
||||
type: "stream.patch",
|
||||
patch:
|
||||
next.type === "prompt"
|
||||
? { phase: state.rootActive ? "running" : "idle", status: blockerStatus(next) }
|
||||
: { status: blockerStatus(next) },
|
||||
},
|
||||
{ type: "stream.view", view: next },
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -506,15 +576,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
|
||||
if (message.type === "user") {
|
||||
const waiting = state.wait?.messageID === message.id
|
||||
if (waiting && state.wait) state.wait.promoted = true
|
||||
if (!render || state.messageIDs.has(message.id)) return
|
||||
const admitted = state.admitted.delete(message.id)
|
||||
if (state.wait && (admitted || (waiting && state.wait.failureMessageID === message.id)))
|
||||
promoteWait(state.wait, false, message.id)
|
||||
if (state.pending.delete(message.id)) syncPending()
|
||||
if (state.messageIDs.has(message.id)) return
|
||||
state.messageIDs.add(message.id)
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
|
||||
return
|
||||
}
|
||||
if (message.type === "skill") {
|
||||
if (state.wait?.messageID === message.id) state.wait.promoted = true
|
||||
if (state.wait?.messageID === message.id) promoteWait(state.wait, false)
|
||||
if (!render || state.skillMessages.has(message.id)) {
|
||||
state.skillMessages.add(message.id)
|
||||
return
|
||||
|
|
@ -560,47 +634,44 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
let reasoningOrdinal = 0
|
||||
for (const item of message.content) {
|
||||
if (item.type === "text") {
|
||||
const id = `text:${textOrdinal++}`
|
||||
const key = streamPartKey(message.id, id)
|
||||
const sent = state.text.get(key)?.length ?? 0
|
||||
state.text.set(key, item.text)
|
||||
if (render) state.projectedText.set(key, item.text)
|
||||
if (render && item.text.length > sent)
|
||||
const fragment = fragmentRef(message.id, "text", textOrdinal++)
|
||||
const update = state.fragments.project(fragment, item.text, render)
|
||||
if (render && item.text.length > update.previous.length)
|
||||
write([
|
||||
{
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: item.text.slice(sent),
|
||||
text: item.text.slice(update.previous.length),
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: id,
|
||||
partID: fragment.partID,
|
||||
},
|
||||
])
|
||||
continue
|
||||
}
|
||||
if (item.type === "reasoning") {
|
||||
const id = `reasoning:${reasoningOrdinal++}`
|
||||
const key = streamPartKey(message.id, id)
|
||||
const sent = state.reasoning.get(key)?.length ?? 0
|
||||
state.reasoning.set(key, item.text)
|
||||
if (render) state.projectedReasoning.set(key, item.text)
|
||||
if (render && input.thinking && item.text.length > sent)
|
||||
const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
|
||||
const update = state.fragments.project(fragment, item.text, render)
|
||||
if (render && input.thinking && item.text.length > update.previous.length)
|
||||
write([
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
|
||||
text:
|
||||
update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
|
||||
phase: "progress",
|
||||
messageID: message.id,
|
||||
partID: id,
|
||||
partID: fragment.partID,
|
||||
},
|
||||
])
|
||||
continue
|
||||
}
|
||||
renderTool(message.id, item, render)
|
||||
}
|
||||
if (render && message.error && !state.errors.has(message.id)) {
|
||||
if (message.error && !state.errors.has(message.id)) {
|
||||
state.errors.add(message.id)
|
||||
if (!render) return
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
write([
|
||||
{
|
||||
kind: "error",
|
||||
|
|
@ -613,6 +684,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
}
|
||||
|
||||
const projectedMessages = async (client: OpenCodeClient, signal: AbortSignal) =>
|
||||
(
|
||||
await client.message.list(
|
||||
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
|
||||
{ signal },
|
||||
)
|
||||
).data.toReversed()
|
||||
|
||||
const settleSession = async (client: OpenCodeClient) => {
|
||||
await client.session.wait({ sessionID: input.sessionID }, { signal: controller.signal })
|
||||
for (const message of await projectedMessages(client, controller.signal)) renderMessage(message, true, true)
|
||||
state.rootActive = false
|
||||
write([], { phase: "idle", status: blockerStatus(state.view) })
|
||||
await input.footer.idle()
|
||||
}
|
||||
|
||||
const resolvePermissionSources = async (
|
||||
client: OpenCodeClient,
|
||||
permissions: PermissionV2Request[],
|
||||
|
|
@ -655,8 +742,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
) => {
|
||||
const client = attempt.client
|
||||
const options = { signal: attempt.signal }
|
||||
const [messages, permissions, forms, globals, active] = await Promise.all([
|
||||
client.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, options),
|
||||
const [projected, pending, permissions, forms, globals, active] = await Promise.all([
|
||||
projectedMessages(client, attempt.signal),
|
||||
client.session.pending.list({ sessionID: input.sessionID }, options),
|
||||
client.permission.list({ sessionID: input.sessionID }, options),
|
||||
client.form.list({ sessionID: input.sessionID }, options),
|
||||
input.location
|
||||
|
|
@ -670,7 +758,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
client.session.active(options),
|
||||
])
|
||||
if (!current(attempt)) return
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
|
||||
state.pending = new Map(pending.flatMap((item) => {
|
||||
const prompt = pendingPrompt(item)
|
||||
return prompt ? [[prompt.messageID, prompt] as const] : []
|
||||
}))
|
||||
syncPending()
|
||||
state.permissions = permissions
|
||||
pruneToolSources()
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
|
|
@ -695,11 +787,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
phase: state.rootActive ? "running" : "idle",
|
||||
status: state.rootActive ? "assistant responding" : blockerStatus(state.view),
|
||||
})
|
||||
if (!state.rootActive && state.wait && (state.wait.promoted || state.wait.interrupted)) {
|
||||
const current = state.wait
|
||||
state.wait = undefined
|
||||
current.resolve()
|
||||
}
|
||||
if (!state.rootActive) await input.footer.idle()
|
||||
if (!current(attempt)) return
|
||||
}
|
||||
|
||||
const apply = (attempt: Attempt, event: RunV2Event) => {
|
||||
|
|
@ -737,19 +826,48 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
input.trace?.write("recv.event", event)
|
||||
subagents.main(client, event, attempt.signal)
|
||||
if (event.type === "session.input.admitted") {
|
||||
if (event.data.input.type !== "user") return
|
||||
mergePending({
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...event.data.input,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.promoted") {
|
||||
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
const waiting = state.wait?.messageID === event.data.inputID
|
||||
if (state.wait) promoteWait(state.wait, true, event.data.inputID)
|
||||
state.admitted.delete(event.data.inputID)
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
state.pending.delete(event.data.inputID)
|
||||
syncPending()
|
||||
const visible = state.messageIDs.has(event.data.inputID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inputID)
|
||||
if (!waiting && pending && !visible) {
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inputID,
|
||||
},
|
||||
])
|
||||
}
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
||||
write([], { phase: "running", status: "assistant responding" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.skill.activated") {
|
||||
const messageID = messageIDFromEvent(event.id)
|
||||
if (state.wait?.messageID === messageID) state.wait.promoted = true
|
||||
if (state.wait?.messageID === messageID) promoteWait(state.wait, true)
|
||||
if (state.skillMessages.has(messageID)) return
|
||||
state.skillMessages.add(messageID)
|
||||
write([skillCommit(messageID, event.data.name)])
|
||||
|
|
@ -800,16 +918,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const id = `text:${event.data.ordinal}`
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const projected = state.projectedText.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
state.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const previous = state.text.get(key) ?? ""
|
||||
state.text.set(key, previous + event.data.delta)
|
||||
const fragment = fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal)
|
||||
if (!state.fragments.delta(fragment, event.data.delta)) return
|
||||
write([
|
||||
{
|
||||
kind: "assistant",
|
||||
|
|
@ -817,74 +927,67 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
text: event.data.delta,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: fragment.partID,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.ended") {
|
||||
const id = `text:${event.data.ordinal}`
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const previous = state.text.get(key) ?? ""
|
||||
state.text.set(key, event.data.text)
|
||||
if (event.data.text.length > previous.length)
|
||||
const update = state.fragments.end(
|
||||
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||
event.data.text,
|
||||
)
|
||||
if (event.data.text.length > update.previous.length)
|
||||
write([
|
||||
{
|
||||
kind: "assistant",
|
||||
source: "assistant",
|
||||
text: event.data.text.slice(previous.length),
|
||||
text: event.data.text.slice(update.previous.length),
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
},
|
||||
])
|
||||
state.projectedText.delete(key)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.started") {
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const id = `reasoning:${event.data.ordinal}`
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const projected = state.projectedReasoning.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
|
||||
return
|
||||
}
|
||||
const previous = state.reasoning.get(key) ?? ""
|
||||
state.reasoning.set(key, previous + event.data.delta)
|
||||
const update = state.fragments.delta(
|
||||
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||
event.data.delta,
|
||||
)
|
||||
if (!update) return
|
||||
if (input.thinking)
|
||||
write([
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
||||
text: update.previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
const id = `reasoning:${event.data.ordinal}`
|
||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
||||
const previous = state.reasoning.get(key) ?? ""
|
||||
state.reasoning.set(key, event.data.text)
|
||||
if (input.thinking && event.data.text.length > previous.length)
|
||||
const update = state.fragments.end(
|
||||
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||
event.data.text,
|
||||
)
|
||||
if (input.thinking && event.data.text.length > update.previous.length)
|
||||
write([
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
|
||||
text: update.previous ? event.data.text.slice(update.previous.length) : `Thinking: ${event.data.text}`,
|
||||
phase: "progress",
|
||||
messageID: event.data.assistantMessageID,
|
||||
partID: id,
|
||||
partID: update.partID,
|
||||
},
|
||||
])
|
||||
state.projectedReasoning.delete(key)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
|
|
@ -1008,13 +1111,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
event.data.tokens.reasoning +
|
||||
event.data.tokens.cache.read +
|
||||
event.data.tokens.cache.write
|
||||
const usage = total > 0 ? total.toLocaleString() : ""
|
||||
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
|
||||
state.stepModel = undefined
|
||||
const usage = total > 0 ? formatContextUsage(total, limit ? Math.round((total / limit) * 100) : undefined) : ""
|
||||
write([], {
|
||||
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
state.stepModel = undefined
|
||||
const rendered = state.errors.has(event.data.assistantMessageID)
|
||||
state.errors.add(event.data.assistantMessageID)
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
|
|
@ -1043,25 +1149,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
state.rootActive = false
|
||||
write([], { phase: "idle", status: "" })
|
||||
const current = state.wait
|
||||
if (!current || (!current.promoted && !current.interrupted)) return
|
||||
state.wait = undefined
|
||||
if (!current) return
|
||||
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (current.failureRendered) {
|
||||
current.resolve()
|
||||
return
|
||||
}
|
||||
current.reject(new Error(errorMessage(event.data.error)))
|
||||
if (!current.failureRendered) current.terminalError = new Error(errorMessage(event.data.error))
|
||||
return
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
|
||||
return
|
||||
current.terminalError = new Error(`Session interrupted: ${event.data.reason}`)
|
||||
}
|
||||
current.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1244,27 +1342,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
}
|
||||
|
||||
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
|
||||
// wires interruption, sends, then blocks until the live settled event (or a
|
||||
// hydration pass over an idle session) resolves it.
|
||||
// Prompt-shaped turns complete through the process-local idle fence. Live
|
||||
// lifecycle events remain presentation and best-effort outcome metadata.
|
||||
const runTurnWait = async (
|
||||
next: SessionTurnInput,
|
||||
messageID: string,
|
||||
turn: { promoted?: boolean; send: () => Promise<unknown> },
|
||||
client: OpenCodeClient,
|
||||
send: () => Promise<SessionPendingInfo | void>,
|
||||
onAdmitted?: () => void,
|
||||
) => {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
const done = new Promise<void>((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
const active: Wait = {
|
||||
messageID,
|
||||
promoted: turn.promoted === true,
|
||||
failureMessageID: messageID,
|
||||
promoted: false,
|
||||
promotionObserved: false,
|
||||
interrupted: false,
|
||||
failureRendered: false,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
|
|
@ -1273,14 +1366,26 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
await turn.send()
|
||||
await done
|
||||
const admitted = await send()
|
||||
if (admitted) mergePending(admitted)
|
||||
onAdmitted?.()
|
||||
await settleSession(client)
|
||||
if (active.terminalError && !active.failureRendered)
|
||||
write([
|
||||
{
|
||||
kind: "error",
|
||||
source: "system",
|
||||
text: active.terminalError.message,
|
||||
phase: "start",
|
||||
messageID: active.failureMessageID,
|
||||
},
|
||||
])
|
||||
} catch (error) {
|
||||
if (state.wait === active) state.wait = undefined
|
||||
if (next.signal?.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
next.signal?.removeEventListener("abort", interrupt)
|
||||
if (state.wait === active) state.wait = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1299,10 +1404,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
if (!current(attempt)) return false
|
||||
reset = true
|
||||
state.messageIDs.clear()
|
||||
state.text.clear()
|
||||
state.projectedText.clear()
|
||||
state.reasoning.clear()
|
||||
state.projectedReasoning.clear()
|
||||
state.fragments.clear()
|
||||
state.tools.clear()
|
||||
state.toolSources.clear()
|
||||
state.finishedTools.clear()
|
||||
|
|
@ -1326,34 +1428,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
row.commit.partID &&
|
||||
(row.commit.kind === "assistant" || row.commit.kind === "reasoning")
|
||||
) {
|
||||
const key = streamPartKey(row.commit.messageID, row.commit.partID)
|
||||
const prefix = row.commit.kind === "reasoning" ? "Thinking: " : ""
|
||||
const text = row.commit.text.startsWith(prefix) ? row.commit.text.slice(prefix.length) : row.commit.text
|
||||
const current = row.commit.kind === "assistant" ? state.text.get(key) : state.reasoning.get(key)
|
||||
if (current === undefined) {
|
||||
input.footer.append(row.commit)
|
||||
if (row.commit.kind === "assistant") {
|
||||
state.text.set(key, text)
|
||||
state.projectedText.set(key, text)
|
||||
} else {
|
||||
state.reasoning.set(key, text)
|
||||
state.projectedReasoning.set(key, text)
|
||||
}
|
||||
const restored = state.fragments.restore(
|
||||
{ messageID: row.commit.messageID, partID: row.commit.partID },
|
||||
text,
|
||||
)
|
||||
if (restored.type === "covered") continue
|
||||
if (restored.type === "append") {
|
||||
if (restored.suffix)
|
||||
input.footer.append(restored.suffix === text ? row.commit : { ...row.commit, text: restored.suffix })
|
||||
continue
|
||||
}
|
||||
if (text.startsWith(current)) {
|
||||
const suffix = text.slice(current.length)
|
||||
if (suffix) input.footer.append({ ...row.commit, text: suffix })
|
||||
if (row.commit.kind === "assistant") {
|
||||
state.text.set(key, text)
|
||||
state.projectedText.set(key, text)
|
||||
} else {
|
||||
state.reasoning.set(key, text)
|
||||
state.projectedReasoning.set(key, text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (current.startsWith(text)) continue
|
||||
}
|
||||
if (row.commit.kind === "error" && row.commit.messageID) {
|
||||
if (state.errors.has(row.commit.messageID)) continue
|
||||
|
|
@ -1377,6 +1463,46 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
|
||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
|
||||
const messageID = next.prompt.messageID
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
const command = next.prompt.command
|
||||
const attachments = await prepareAttachments(next, command ? "command" : "prompt", input.readTextFile)
|
||||
const agents = promptAgents(next)
|
||||
if (!command) {
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID, delivery })
|
||||
return client.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
text: [next.prompt.text, ...attachments.text].join("\n\n"),
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery,
|
||||
},
|
||||
{ signal: next.signal },
|
||||
)
|
||||
}
|
||||
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery,
|
||||
},
|
||||
{ signal: next.signal },
|
||||
)
|
||||
}
|
||||
|
||||
const replayOnResize = (next: SessionResizeReplayInput) => {
|
||||
queuedResizeReplay = next
|
||||
if (resizeReplay) return resizeReplay
|
||||
|
|
@ -1403,7 +1529,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
|
||||
return {
|
||||
async runPromptTurn(next) {
|
||||
async queuePromptTurn(next) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
const client = settlementClient ?? sdk
|
||||
await settleSession(client)
|
||||
if (settlementClient === client) settlementClient = undefined
|
||||
},
|
||||
async runPromptTurn(next, admitted) {
|
||||
if (next.prompt.mode === "shell") {
|
||||
await runShellTurn(next)
|
||||
return
|
||||
|
|
@ -1417,43 +1556,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
const command = next.prompt.command
|
||||
if (command?.source === "skill") {
|
||||
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
await runTurnWait(
|
||||
next,
|
||||
messageID,
|
||||
client,
|
||||
() =>
|
||||
client.session.skill(
|
||||
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
admitted,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
// Agent and model ride the command payload; the server switches only
|
||||
// when the command itself does not pin them.
|
||||
const files = [
|
||||
...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
|
||||
...promptFiles(next),
|
||||
]
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: files.length ? files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1465,29 +1582,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
const prepared = await Promise.all(
|
||||
(next.includeFiles ? next.files : []).map((file) => prepareFile(file, input.readTextFile)),
|
||||
)
|
||||
const attachments = [
|
||||
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
...promptFiles(next),
|
||||
]
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
client.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: attachments.length ? attachments : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Thin bridge between transport output and the footer API.
|
||||
//
|
||||
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// Transports produce immutable StreamCommit[] rows and typed mutable-footer
|
||||
// updates. This module forwards both to the footer API, adding trace writes
|
||||
// along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so transport code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
import type { FooterApi, FooterEvent, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
|
|
@ -18,7 +18,7 @@ type OutputInput = {
|
|||
|
||||
type StreamOutput = {
|
||||
commits: StreamCommit[]
|
||||
footer?: FooterOutput
|
||||
updates?: Extract<FooterEvent, { type: "stream.patch" | "stream.view" | "stream.subagent" }>[]
|
||||
}
|
||||
|
||||
// Default to "running" phase when a status string arrives without an explicit phase.
|
||||
|
|
@ -134,32 +134,19 @@ export function writeSessionOutput(input: OutputInput, out: StreamOutput): void
|
|||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
if (out.footer?.patch) {
|
||||
const next = patch(out.footer.patch)
|
||||
input.trace?.write("ui.patch", next)
|
||||
input.footer.event({
|
||||
type: "stream.patch",
|
||||
patch: next,
|
||||
})
|
||||
for (const update of out.updates ?? []) {
|
||||
if (update.type === "stream.patch") {
|
||||
const next = { ...update, patch: patch(update.patch) }
|
||||
input.trace?.write("ui.patch", next.patch)
|
||||
input.footer.event(next)
|
||||
continue
|
||||
}
|
||||
if (update.type === "stream.subagent") {
|
||||
input.trace?.write("ui.subagent", traceSubagentState(update.state))
|
||||
input.footer.event(update)
|
||||
continue
|
||||
}
|
||||
input.trace?.write("ui.patch", { view: update.view })
|
||||
input.footer.event(update)
|
||||
}
|
||||
|
||||
if (out.footer?.subagent) {
|
||||
input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
|
||||
input.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: out.footer.subagent,
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.footer?.view) {
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("ui.patch", {
|
||||
view: out.footer.view,
|
||||
})
|
||||
input.footer.event({
|
||||
type: "stream.view",
|
||||
view: out.footer.view,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
// palette if detection fails.
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { resolveThemeColors } from "../theme/resolve"
|
||||
import { terminalMode } from "../theme/system"
|
||||
import type { ThemeJson } from "../theme/v1"
|
||||
import type { EntryKind, RunTuiConfig } from "./types"
|
||||
|
||||
type Tone = {
|
||||
|
|
@ -63,21 +67,6 @@ export type RunTheme = {
|
|||
}
|
||||
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
type ThemeJson = {
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
type SharedSyntaxTheme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
|
|
@ -94,7 +83,7 @@ function rgba(hex: string, value?: number): RGBA {
|
|||
return value === undefined ? color : alpha(color, value)
|
||||
}
|
||||
|
||||
function mode(bg: RGBA): "dark" | "light" {
|
||||
function colorMode(bg: RGBA): "dark" | "light" {
|
||||
return luminance(bg) > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
|
|
@ -118,46 +107,6 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
|
|||
)
|
||||
}
|
||||
|
||||
function ansiToRgba(code: number): RGBA {
|
||||
if (code < 16) {
|
||||
const ansi = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(ansi[code] ?? "#000000")
|
||||
}
|
||||
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const b = index % 6
|
||||
const g = Math.floor(index / 6) % 6
|
||||
const r = Math.floor(index / 36)
|
||||
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
|
||||
return RGBA.fromInts(value(r), value(g), value(b))
|
||||
}
|
||||
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
|
||||
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
return RGBA.fromInts(
|
||||
Math.round((base.r + (overlay.r - base.r) * value) * 255),
|
||||
|
|
@ -236,54 +185,10 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number)
|
|||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
const resolveColor = (value: ColorValue, chain: string[] = []): RGBA => {
|
||||
if (value instanceof RGBA) return value
|
||||
|
||||
if (typeof value === "number") {
|
||||
return RGBA.fromIndex(value, ansiToRgba(value))
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return resolveColor(value[pick], chain)
|
||||
}
|
||||
|
||||
if (value === "transparent" || value === "none") {
|
||||
return RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
return RGBA.fromHex(value)
|
||||
}
|
||||
|
||||
if (chain.includes(value)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
}
|
||||
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText:
|
||||
theme.theme.selectedListItemText === undefined
|
||||
? resolved.background!
|
||||
: resolveColor(theme.theme.selectedListItemText),
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
...resolved.theme,
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,11 +370,11 @@ function map(
|
|||
syntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const footerMode = colorMode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
|
||||
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
|
||||
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
|
||||
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.13 : 0.06)
|
||||
const statusAccentBase =
|
||||
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
|
||||
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
|
||||
|
|
@ -616,9 +521,7 @@ export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConf
|
|||
const pick =
|
||||
config?.mode === "dark" || config?.mode === "light"
|
||||
? config.mode
|
||||
: colors.defaultBackground
|
||||
? mode(RGBA.fromHex(colors.defaultBackground))
|
||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||
: (terminalMode(colors) ?? renderer.themeMode ?? colorMode(RGBA.fromHex(bg)))
|
||||
const { generateSyntax } = await import("../theme")
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// Per-tool display rules shared across `opencode run` output paths.
|
||||
//
|
||||
// Each known tool (shell, edit, write, subagent, etc.) has a ToolRule that controls
|
||||
// five display hooks:
|
||||
// four display hooks:
|
||||
//
|
||||
// view → visibility policy for progress/final scrollback entries and
|
||||
// whether completed finals can render as structured snapshots
|
||||
// run → inline summary for the non-interactive `run` command output
|
||||
// scroll → text formatting for start/progress/final scrollback entries
|
||||
// permission → display info for the permission UI (icon, title, diff)
|
||||
// snap → structured snapshot (code block, diff, task card) for rich
|
||||
// scrollback entries
|
||||
//
|
||||
|
|
@ -18,9 +17,18 @@ import stripAnsi from "strip-ansi"
|
|||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { LANGUAGE_EXTENSIONS } from "../util/filetype"
|
||||
import { Locale } from "../util/locale"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../util/tool-display"
|
||||
import { formatPath } from "../util/path-format"
|
||||
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type { MiniToolPart } from "./types"
|
||||
export { canonicalToolName } from "../util/tool-display"
|
||||
|
||||
export type ToolView = {
|
||||
output: boolean
|
||||
|
|
@ -92,35 +100,12 @@ export type ToolInline = {
|
|||
body?: string
|
||||
}
|
||||
|
||||
export type ToolPermissionInfo = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolProps = {
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
frame: ToolFrame
|
||||
}
|
||||
|
||||
type ToolPermissionProps = {
|
||||
directory?: string
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
type ToolPermissionCtx = {
|
||||
directory?: string
|
||||
input: ToolDict
|
||||
meta: ToolDict
|
||||
patterns: string[]
|
||||
}
|
||||
|
||||
type ToolName =
|
||||
| "invalid"
|
||||
| "shell"
|
||||
|
|
@ -144,7 +129,6 @@ type ToolRule = {
|
|||
view: ToolView
|
||||
run: (props: ToolProps) => ToolInline
|
||||
scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
|
||||
permission?: (props: ToolPermissionProps) => ToolPermissionInfo
|
||||
snap?: (props: ToolProps) => ToolSnapshot | undefined
|
||||
}
|
||||
|
||||
|
|
@ -168,21 +152,6 @@ function props(frame: ToolFrame): ToolProps {
|
|||
}
|
||||
}
|
||||
|
||||
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
|
||||
return {
|
||||
directory: ctx.directory,
|
||||
input: ctx.input,
|
||||
metadata: ctx.meta,
|
||||
patterns: ctx.patterns,
|
||||
}
|
||||
}
|
||||
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
|
@ -193,13 +162,6 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri
|
|||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||
}
|
||||
|
||||
export function canonicalToolName(name: string) {
|
||||
if (name === "bash") return "shell"
|
||||
if (name === "task") return "subagent"
|
||||
if (name === "apply_patch") return "patch"
|
||||
return name
|
||||
}
|
||||
|
||||
function normalizeInput(name: string, value: unknown) {
|
||||
const input = dict(value)
|
||||
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
|
||||
|
|
@ -228,7 +190,7 @@ function normalizeFile(value: unknown): PatchFile | undefined {
|
|||
? "moved"
|
||||
: legacy)
|
||||
const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined
|
||||
const deletions = num(file.deletions)
|
||||
const deletions = finiteNumber(file.deletions)
|
||||
return {
|
||||
...file,
|
||||
file: name,
|
||||
|
|
@ -250,8 +212,10 @@ function normalizeStructured(name: string, value: unknown) {
|
|||
...structured,
|
||||
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
|
||||
...(name === "subagent" && sessionID ? { sessionID } : {}),
|
||||
...(name === "shell" && num(structured.exit) === undefined && num(structured.exitCode) !== undefined
|
||||
? { exit: num(structured.exitCode) }
|
||||
...(name === "shell" &&
|
||||
finiteNumber(structured.exit) === undefined &&
|
||||
finiteNumber(structured.exitCode) !== undefined
|
||||
? { exit: finiteNumber(structured.exitCode) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -265,19 +229,11 @@ export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessage
|
|||
state: {
|
||||
...tool.state,
|
||||
input: normalizeInput(name, tool.state.input),
|
||||
structured: normalizeStructured(name, tool.state.structured),
|
||||
structured: normalizeStructured(name, toolDisplayMetadata(tool.state)),
|
||||
},
|
||||
} as SessionMessageAssistantTool
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
function list<T>(v: unknown): T[] {
|
||||
if (!Array.isArray(v)) {
|
||||
return []
|
||||
|
|
@ -286,22 +242,6 @@ function list<T>(v: unknown): T[] {
|
|||
return v
|
||||
}
|
||||
|
||||
function info(data: ToolDict, skip: string[] = []): string {
|
||||
const list = Object.entries(data).filter(([key, val]) => {
|
||||
if (skip.includes(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
|
||||
})
|
||||
|
||||
if (list.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return `[${list.map(([key, val]) => `${key}=${String(val)}`).join(", ")}]`
|
||||
}
|
||||
|
||||
function span(frame: ToolFrame): string {
|
||||
const start = frame.time.start
|
||||
const end = frame.time.end
|
||||
|
|
@ -335,7 +275,7 @@ function toolError(ctx: ToolFrame): string {
|
|||
}
|
||||
|
||||
function fallbackStart(ctx: ToolFrame): string {
|
||||
const extra = info(ctx.input)
|
||||
const extra = primitiveInputSummary(ctx.input)
|
||||
if (!extra) {
|
||||
return `⚙ ${ctx.name}`
|
||||
}
|
||||
|
|
@ -361,28 +301,11 @@ function fallbackFinal(ctx: ToolFrame): string {
|
|||
}
|
||||
|
||||
export function toolPath(input?: string, opts: { home?: boolean; directory?: string } = {}): string {
|
||||
if (!input) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const cwd = opts.directory ?? process.cwd()
|
||||
const home = os.homedir()
|
||||
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const rel = path.relative(cwd, abs)
|
||||
|
||||
if (!rel) {
|
||||
return "."
|
||||
}
|
||||
|
||||
if (!rel.startsWith("..")) {
|
||||
return rel.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
if (opts.home && home && (abs === home || abs.startsWith(home + path.sep))) {
|
||||
return abs.replace(home, "~").replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
return abs.replaceAll("\\", "/")
|
||||
return formatPath(input, {
|
||||
base: opts.directory ?? process.cwd(),
|
||||
home: opts.home ? os.homedir() : undefined,
|
||||
forwardSlashes: true,
|
||||
})
|
||||
}
|
||||
|
||||
function displayPath(p: ToolProps, input?: string, opts: { home?: boolean } = {}) {
|
||||
|
|
@ -438,7 +361,7 @@ function runList(p: ToolProps): ToolInline {
|
|||
|
||||
function runRead(p: ToolProps): ToolInline {
|
||||
const file = displayPath(p, p.input.path)
|
||||
const description = info(p.frame.input, ["path"]) || undefined
|
||||
const description = primitiveInputSummary(p.frame.input, ["path"]) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${file}`,
|
||||
|
|
@ -748,7 +671,7 @@ function scrollShellFinal(p: ToolProps): string {
|
|||
return fail(p.frame)
|
||||
}
|
||||
|
||||
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
||||
const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code)
|
||||
const time = span(p.frame)
|
||||
if (code === undefined) {
|
||||
if (!time) {
|
||||
|
|
@ -763,7 +686,7 @@ function scrollShellFinal(p: ToolProps): string {
|
|||
|
||||
function scrollReadStart(p: ToolProps): string {
|
||||
const file = displayPath(p, p.input.path)
|
||||
const extra = info(p.frame.input, ["path"])
|
||||
const extra = primitiveInputSummary(p.frame.input, ["path"])
|
||||
const tail = extra ? ` ${extra}` : ""
|
||||
return `→ Read ${file}${tail}`.trim()
|
||||
}
|
||||
|
|
@ -953,109 +876,6 @@ function scrollWebSearchStart(p: ToolProps): string {
|
|||
return `◈ ${title} "${query}"`
|
||||
}
|
||||
|
||||
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || p.patterns[0] || ""
|
||||
const diff = (list<PatchFile>(p.metadata.files)[0]?.patch ?? text(p.metadata.diff)) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||
lines: [],
|
||||
diff,
|
||||
patch: diff ? undefined : text(p.input.patchText) || undefined,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${toolPath(file, { home: true, directory: p.directory })}`,
|
||||
lines: file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Glob "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Grep "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const dir = text(dict(p.input).path) || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${toolPath(dir, { home: true, directory: p.directory })}`,
|
||||
lines: dir ? [`Path: ${toolPath(dir, { home: true, directory: p.directory })}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permBash(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const cmd = p.input.command || ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Shell command",
|
||||
lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const type = p.input.agent || "general"
|
||||
const desc = p.input.description
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(type)} Subagent`,
|
||||
lines: desc ? [`◉ ${desc}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const url = p.input.url || ""
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
lines: url ? [`URL: ${url}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const query = p.input.query || ""
|
||||
const title = webSearchProviderLabel(p.metadata.provider)
|
||||
return {
|
||||
icon: "◈",
|
||||
title: query ? `${title} "${query}"` : title,
|
||||
lines: query ? [`Query: ${query}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.path || ""
|
||||
const line = typeof p.input.line === "number" ? p.input.line : undefined
|
||||
const char = typeof p.input.character === "number" ? p.input.character : undefined
|
||||
const pos = line !== undefined && char !== undefined ? `${line}:${char}` : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: lspTitle(p.input, { home: true, directory: p.directory }),
|
||||
lines: [
|
||||
...(p.input.operation ? [`Operation: ${p.input.operation}`] : []),
|
||||
...(file ? [`Path: ${toolPath(file, { home: true, directory: p.directory })}`] : []),
|
||||
...(pos ? [`Position: ${pos}`] : []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_RULES = {
|
||||
invalid: {
|
||||
view: {
|
||||
|
|
@ -1078,7 +898,6 @@ const TOOL_RULES = {
|
|||
progress: scrollBashProgress,
|
||||
final: scrollShellFinal,
|
||||
},
|
||||
permission: permBash,
|
||||
},
|
||||
write: {
|
||||
view: {
|
||||
|
|
@ -1103,7 +922,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollEditStart,
|
||||
},
|
||||
permission: permEdit,
|
||||
},
|
||||
patch: {
|
||||
view: {
|
||||
|
|
@ -1140,7 +958,6 @@ const TOOL_RULES = {
|
|||
start: scrollTaskStart,
|
||||
final: scrollTaskFinal,
|
||||
},
|
||||
permission: permTask,
|
||||
},
|
||||
question: {
|
||||
view: {
|
||||
|
|
@ -1164,7 +981,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollReadStart,
|
||||
},
|
||||
permission: permRead,
|
||||
},
|
||||
glob: {
|
||||
view: {
|
||||
|
|
@ -1176,7 +992,6 @@ const TOOL_RULES = {
|
|||
start: scrollGlobStart,
|
||||
final: scrollGlobFinal,
|
||||
},
|
||||
permission: permGlob,
|
||||
},
|
||||
grep: {
|
||||
view: {
|
||||
|
|
@ -1187,7 +1002,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollGrepStart,
|
||||
},
|
||||
permission: permGrep,
|
||||
},
|
||||
list: {
|
||||
view: {
|
||||
|
|
@ -1198,7 +1012,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollListStart,
|
||||
},
|
||||
permission: permList,
|
||||
},
|
||||
lsp: {
|
||||
view: {
|
||||
|
|
@ -1209,7 +1022,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollLspStart,
|
||||
},
|
||||
permission: permLsp,
|
||||
},
|
||||
webfetch: {
|
||||
view: {
|
||||
|
|
@ -1220,7 +1032,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollWebfetchStart,
|
||||
},
|
||||
permission: permWebfetch,
|
||||
},
|
||||
websearch: {
|
||||
view: {
|
||||
|
|
@ -1231,7 +1042,6 @@ const TOOL_RULES = {
|
|||
scroll: {
|
||||
start: scrollWebSearchStart,
|
||||
},
|
||||
permission: permWebSearch,
|
||||
},
|
||||
skill: {
|
||||
view: {
|
||||
|
|
@ -1385,33 +1195,6 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
|
|||
return fallbackFinal(ctx)
|
||||
}
|
||||
|
||||
export function toolPermissionInfo(
|
||||
name: string,
|
||||
input: ToolDict,
|
||||
meta: ToolDict,
|
||||
patterns: string[],
|
||||
directory?: string,
|
||||
): ToolPermissionInfo | undefined {
|
||||
const normalized = canonicalToolName(name)
|
||||
const draw = rule(normalized)?.permission
|
||||
if (!draw) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return draw(
|
||||
permission({
|
||||
directory,
|
||||
input: normalizeInput(normalized, input),
|
||||
meta: normalizeStructured(normalized, meta),
|
||||
patterns,
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const draw = rule(ctx.name)?.snap
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// V2 events / demo actions → StreamCommit[] + FooterOutput
|
||||
// V2 events / demo actions → StreamCommit[] + FooterEvent[]
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
|
|
@ -58,6 +58,9 @@ export type RunProviderModel = {
|
|||
cost?: {
|
||||
input: number
|
||||
}
|
||||
limit?: {
|
||||
context: number
|
||||
}
|
||||
status?: string
|
||||
variants?: Record<string, unknown>
|
||||
}
|
||||
|
|
@ -84,6 +87,8 @@ export type RunPrompt = {
|
|||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
prompt: RunPrompt
|
||||
delivery: "steer" | "queue"
|
||||
admittedSeq: number
|
||||
}
|
||||
|
||||
export type RunAgent = {
|
||||
|
|
@ -161,7 +166,6 @@ export type FooterPhase = "idle" | "running"
|
|||
export type FooterState = {
|
||||
phase: FooterPhase
|
||||
status: string
|
||||
queue: number
|
||||
model: string
|
||||
usage: string
|
||||
first: boolean
|
||||
|
|
@ -311,13 +315,6 @@ export type FooterSubagentState = {
|
|||
forms: MiniFormRequest[]
|
||||
}
|
||||
|
||||
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
subagent?: FooterSubagentState
|
||||
}
|
||||
|
||||
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||
// transport both emit these to update footer state without reaching into
|
||||
// internal signals directly.
|
||||
|
|
@ -345,10 +342,6 @@ export type FooterEvent =
|
|||
variants: string[]
|
||||
current: string | undefined
|
||||
}
|
||||
| {
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "queued.prompts"
|
||||
prompts: FooterQueuedPrompt[]
|
||||
|
|
@ -362,14 +355,8 @@ export type FooterEvent =
|
|||
model: string
|
||||
selection: NonNullable<RunInput["model"]>
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "turn.idle"
|
||||
queue: number
|
||||
}
|
||||
| { type: "turn.send" }
|
||||
| { type: "turn.idle" }
|
||||
| {
|
||||
type: "turn.duration"
|
||||
duration: string
|
||||
|
|
@ -445,7 +432,6 @@ export type LocalReplayRow = {
|
|||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
// Model variant resolution and persistence.
|
||||
//
|
||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
||||
// Resolution priority: CLI --variant flag > valid session history > saved preference.
|
||||
//
|
||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
||||
// variant and the persisted file.
|
||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
||||
import type { RunInput, RunProvider } from "./types"
|
||||
import { cycleModelVariant, normalizeModelVariant } from "../model-preference"
|
||||
|
||||
export function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
|
||||
const provider = providers?.find((item) => item.id === model.providerID)
|
||||
|
|
@ -28,20 +29,7 @@ export function formatModelLabel(
|
|||
}
|
||||
|
||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (variants.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
const idx = variants.indexOf(current)
|
||||
if (idx === -1 || idx === variants.length - 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return variants[idx + 1]
|
||||
return cycleModelVariant(current, variants)
|
||||
}
|
||||
|
||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
||||
|
|
@ -49,20 +37,13 @@ export function pickVariant(model: RunInput["model"], input: RunSession | Sessio
|
|||
}
|
||||
|
||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (variants.length === 0 || variants.includes(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
const normalized = normalizeModelVariant(value)
|
||||
return normalized && (variants.length === 0 || variants.includes(normalized)) ? normalized : undefined
|
||||
}
|
||||
|
||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
||||
// history. fitVariant() checks saved and session values against the available
|
||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
||||
// Picks the active variant. CLI flag wins, then valid session history, then the
|
||||
// saved preference. Saved and session values are dropped when the provider no
|
||||
// longer offers them.
|
||||
export function resolveVariant(
|
||||
input: string | undefined,
|
||||
session: string | undefined,
|
||||
|
|
@ -70,14 +51,8 @@ export function resolveVariant(
|
|||
variants: string[],
|
||||
): string | undefined {
|
||||
if (input !== undefined) {
|
||||
return input
|
||||
return normalizeModelVariant(input)
|
||||
}
|
||||
|
||||
const fallback = fitVariant(saved, variants)
|
||||
const current = fitVariant(session, variants)
|
||||
if (current !== undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fitVariant(session, variants) ?? fitVariant(saved, variants)
|
||||
}
|
||||
|
|
|
|||
124
packages/tui/src/model-preference.ts
Normal file
124
packages/tui/src/model-preference.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { readJson, writeJsonAtomic } from "./util/persistence"
|
||||
import { isRecord } from "./util/record"
|
||||
|
||||
export type ModelPreferenceModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export type ModelPreference = {
|
||||
recent: ModelPreferenceModel[]
|
||||
favorite: ModelPreferenceModel[]
|
||||
variant: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export type ModelPreferenceDocument = Record<string, unknown> & ModelPreference
|
||||
|
||||
function models(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap((item): ModelPreferenceModel[] => {
|
||||
if (!isRecord(item)) return []
|
||||
if (typeof item.providerID !== "string" || item.providerID.length === 0) return []
|
||||
if (typeof item.modelID !== "string" || item.modelID.length === 0) return []
|
||||
return [{ providerID: item.providerID, modelID: item.modelID }]
|
||||
})
|
||||
}
|
||||
|
||||
function variants(value: unknown) {
|
||||
if (!isRecord(value)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
if (key.length === 0 || typeof item !== "string" || item.length === 0) return []
|
||||
const variant = normalizeModelVariant(item)
|
||||
return variant === undefined ? [] : ([[key, variant]] as const)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeModelVariant(value: string | undefined) {
|
||||
return value === "default" ? undefined : value
|
||||
}
|
||||
|
||||
export function modelPreferenceKey(model: ModelPreferenceModel) {
|
||||
return `${model.providerID}/${model.modelID}`
|
||||
}
|
||||
|
||||
export function cycleModelVariant(current: string | undefined, variants: string[]) {
|
||||
const named = variants.filter((variant) => variant !== "default")
|
||||
if (named.length === 0) return undefined
|
||||
const value = normalizeModelVariant(current)
|
||||
if (value === undefined) return named[0]
|
||||
const index = named.indexOf(value)
|
||||
if (index === -1 || index === named.length - 1) return undefined
|
||||
return named[index + 1]
|
||||
}
|
||||
|
||||
export function decodeModelPreference(value: unknown): ModelPreferenceDocument {
|
||||
const root = isRecord(value) ? value : {}
|
||||
return {
|
||||
...root,
|
||||
recent: models(root.recent),
|
||||
favorite: models(root.favorite),
|
||||
variant: variants(root.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function preference(value: ModelPreferenceDocument): ModelPreference {
|
||||
return {
|
||||
recent: value.recent,
|
||||
favorite: value.favorite,
|
||||
variant: value.variant,
|
||||
}
|
||||
}
|
||||
|
||||
function patch(value: Partial<ModelPreference>) {
|
||||
return {
|
||||
...(value.recent === undefined ? {} : { recent: models(value.recent) }),
|
||||
...(value.favorite === undefined ? {} : { favorite: models(value.favorite) }),
|
||||
...(value.variant === undefined ? {} : { variant: variants(value.variant) }),
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelPreferenceRepository(filePath: string) {
|
||||
const state = {
|
||||
pending: Promise.resolve(),
|
||||
}
|
||||
const read = () =>
|
||||
readJson<unknown>(filePath)
|
||||
.then(decodeModelPreference)
|
||||
.catch(() => decodeModelPreference(undefined))
|
||||
|
||||
function update(change: (current: ModelPreference) => Partial<ModelPreference>) {
|
||||
const result = state.pending.then(async () => {
|
||||
const current = await read()
|
||||
const next = { ...current, ...patch(change(preference(current))) }
|
||||
await writeJsonAtomic(filePath, next)
|
||||
})
|
||||
state.pending = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
function load() {
|
||||
return state.pending.then(read).then(preference)
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
patch(value: Partial<ModelPreference>) {
|
||||
return update(() => value)
|
||||
},
|
||||
async resolveVariant(model: ModelPreferenceModel) {
|
||||
return (await load()).variant[modelPreferenceKey(model)]
|
||||
},
|
||||
saveVariant(model: ModelPreferenceModel, value: string | undefined) {
|
||||
const key = modelPreferenceKey(model)
|
||||
const next = normalizeModelVariant(value)
|
||||
return update((current) => {
|
||||
const variant = { ...current.variant }
|
||||
if (next === undefined) delete variant[key]
|
||||
if (next !== undefined) variant[key] = next
|
||||
return { variant }
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/tui/src/prompt/codec.ts
Normal file
20
packages/tui/src/prompt/codec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Prompt, PromptInput } from "@opencode-ai/schema"
|
||||
import type { Types } from "effect"
|
||||
|
||||
export type EditablePromptInput = Types.DeepMutable<PromptInput.Prompt>
|
||||
|
||||
export function projectedPromptInput(input: Pick<Prompt, "text" | "files" | "agents">): EditablePromptInput {
|
||||
return {
|
||||
text: input.text,
|
||||
files: input.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: input.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
139
packages/tui/src/prompt/mention.ts
Normal file
139
packages/tui/src/prompt/mention.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { PromptInput, PromptMention } from "@opencode-ai/schema"
|
||||
import type { EditablePromptInput } from "./codec"
|
||||
import { promptOffsetWidth } from "./display"
|
||||
import { expandTrackedPastedText } from "./part"
|
||||
|
||||
type TextRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type MentionItem = {
|
||||
index: number
|
||||
mention: PromptMention
|
||||
}
|
||||
|
||||
type CandidateRange = TextRange & {
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function realignPromptMentions(
|
||||
content: string,
|
||||
mentions: readonly (PromptMention | undefined)[],
|
||||
): Array<PromptMention | undefined> {
|
||||
const protectedRanges: TextRange[] = []
|
||||
const aligned = mentions.map((mention) => (mention && !mention.text ? { ...mention } : undefined))
|
||||
const groups = mentions.reduce((result, mention, index) => {
|
||||
if (!mention?.text) return result
|
||||
const group = result.get(mention.text) ?? []
|
||||
group.push({ mention, index })
|
||||
result.set(mention.text, group)
|
||||
return result
|
||||
}, new Map<string, MentionItem[]>())
|
||||
|
||||
for (const [text, items] of [...groups.entries()].sort(
|
||||
([left], [right]) => right.length - left.length || left.localeCompare(right),
|
||||
)) {
|
||||
const candidates = mentionRanges(content, text)
|
||||
const available = candidates.filter(
|
||||
(candidate) => !protectedRanges.some((range) => overlaps(candidate, range)),
|
||||
)
|
||||
for (const [item, candidate] of assignMentions(items, available)) {
|
||||
aligned[item.index] = {
|
||||
text,
|
||||
start: candidate.offset,
|
||||
end: candidate.offset + promptOffsetWidth(text),
|
||||
}
|
||||
}
|
||||
protectedRanges.push(...candidates)
|
||||
}
|
||||
|
||||
return aligned
|
||||
}
|
||||
|
||||
export function realignPromptInputMentions(content: string, input: PromptInput.Prompt): EditablePromptInput {
|
||||
const files = input.files ?? []
|
||||
const agents = input.agents ?? []
|
||||
const mentions = realignPromptMentions(content, [
|
||||
...files.map((file) => file.mention),
|
||||
...agents.map((agent) => agent.mention),
|
||||
])
|
||||
const align = <T extends { mention?: PromptMention }>(items: readonly T[] | undefined, offset = 0) =>
|
||||
items?.flatMap((item, index) => {
|
||||
if (!item.mention?.text) return [{ ...item, mention: item.mention ? { ...item.mention } : undefined }]
|
||||
const mention = mentions[offset + index]
|
||||
return mention ? [{ ...item, mention }] : []
|
||||
})
|
||||
|
||||
return {
|
||||
text: content,
|
||||
files: align(input.files),
|
||||
agents: align(input.agents, files.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandPromptInputPastedText(
|
||||
input: PromptInput.Prompt,
|
||||
pasted: readonly { text: string; source: { start: number; end: number } }[],
|
||||
): EditablePromptInput {
|
||||
const ranges = pasted.map((part) => ({ ...part.source, text: part.text }))
|
||||
const shift = (mention: PromptMention | undefined) => {
|
||||
if (!mention) return
|
||||
const offset = ranges.reduce(
|
||||
(total, range) =>
|
||||
range.end <= mention.start ? total + promptOffsetWidth(range.text) - (range.end - range.start) : total,
|
||||
0,
|
||||
)
|
||||
return { ...mention, start: mention.start + offset, end: mention.end + offset }
|
||||
}
|
||||
|
||||
return {
|
||||
text: expandTrackedPastedText(input.text, ranges),
|
||||
files: input.files?.map((file) => ({ ...file, mention: shift(file.mention) })),
|
||||
agents: input.agents?.map((agent) => ({ ...agent, mention: shift(agent.mention) })),
|
||||
}
|
||||
}
|
||||
|
||||
function mentionRanges(content: string, text: string): CandidateRange[] {
|
||||
const ranges: CandidateRange[] = []
|
||||
let searchFrom = 0
|
||||
while (true) {
|
||||
const start = content.indexOf(text, searchFrom)
|
||||
if (start === -1) return ranges
|
||||
ranges.push({ start, end: start + text.length, offset: promptOffsetWidth(content.slice(0, start)) })
|
||||
searchFrom = start + text.length
|
||||
}
|
||||
}
|
||||
|
||||
function assignMentions(items: MentionItem[], candidates: CandidateRange[]) {
|
||||
const ordered = items.toSorted((left, right) => left.mention.start - right.mention.start || left.index - right.index)
|
||||
const memo = new Map<string, { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> }>()
|
||||
|
||||
function solve(item: number, candidate: number): { matches: number; cost: number; pairs: Array<[MentionItem, CandidateRange]> } {
|
||||
if (item >= ordered.length || candidate >= candidates.length) return { matches: 0, cost: 0, pairs: [] }
|
||||
const key = `${item}:${candidate}`
|
||||
const cached = memo.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const tail = solve(item + 1, candidate + 1)
|
||||
const current = ordered[item]!
|
||||
const range = candidates[candidate]!
|
||||
const matched = {
|
||||
matches: tail.matches + 1,
|
||||
cost: tail.cost + Math.abs(range.offset - current.mention.start),
|
||||
pairs: [[current, range] as [MentionItem, CandidateRange], ...tail.pairs],
|
||||
}
|
||||
const result = [matched, solve(item + 1, candidate), solve(item, candidate + 1)].reduce((best, next) => {
|
||||
if (next.matches !== best.matches) return next.matches > best.matches ? next : best
|
||||
return next.cost < best.cost ? next : best
|
||||
})
|
||||
memo.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return solve(0, 0).pairs
|
||||
}
|
||||
|
||||
function overlaps(left: TextRange, right: TextRange) {
|
||||
return left.start < right.end && left.end > right.start
|
||||
}
|
||||
26
packages/tui/src/prompt/parse.ts
Normal file
26
packages/tui/src/prompt/parse.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export function parseFileLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) return { base: input }
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const match = input.slice(hash + 1).match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) return { base }
|
||||
|
||||
const startLine = Number(match[1])
|
||||
const endLine = match[2] && startLine < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, lineRange: { startLine, endLine } }
|
||||
}
|
||||
|
||||
export function stripFileLineRange(input: string) {
|
||||
return parseFileLineRange(input).base
|
||||
}
|
||||
|
||||
export function parseSlashHead(text: string, separator = /[ \t\n]/) {
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const end = text.slice(1).search(separator)
|
||||
if (end === -1) return { name: text.slice(1), arguments: "", end: text.length }
|
||||
|
||||
const split = end + 1
|
||||
return { name: text.slice(1, split), arguments: text.slice(split + 1), end: split }
|
||||
}
|
||||
|
|
@ -113,8 +113,8 @@ export function Composer(props: ComposerProps) {
|
|||
<box
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.border()}
|
||||
backgroundColor={themeV2.background()}
|
||||
borderColor={themeV2.border.default}
|
||||
backgroundColor={themeV2.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
|
|
@ -125,7 +125,7 @@ export function Composer(props: ComposerProps) {
|
|||
<Show
|
||||
when={tabList().length > 1}
|
||||
fallback={
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD}>
|
||||
{tabList()[0]?.label ?? ""}
|
||||
</text>
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ export function Composer(props: ComposerProps) {
|
|||
const isActive = createMemo(() => store.active === t.id)
|
||||
return (
|
||||
<text
|
||||
fg={isActive() ? themeV2.text() : themeV2.text.subdued()}
|
||||
fg={isActive() ? themeV2.text.default : themeV2.text.subdued}
|
||||
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{t.label}
|
||||
|
|
@ -146,7 +146,7 @@ export function Composer(props: ComposerProps) {
|
|||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={close}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -156,19 +156,19 @@ export function Composer(props: ComposerProps) {
|
|||
<For each={footerHints()}>
|
||||
{(hint) => (
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text() }}>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<b>{hint.label}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{hint.shortcut}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{hint.shortcut}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={tabList().length > 1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text() }}>
|
||||
<span style={{ fg: themeV2.text.default }}>
|
||||
<b>tabs</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>←/→</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>←/→</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||
return (
|
||||
<Show when={composer.active("shell")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No shell commands</text>}>
|
||||
<For each={entries()}>
|
||||
{(shell, index) => {
|
||||
const active = createMemo(() => index() === store.selected)
|
||||
|
|
@ -107,11 +107,13 @@ export function ShellTab(props: { sessionID: string }) {
|
|||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={themeV2.background.action({ focused: active() })}
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.action.primary.focused : themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={themeV2.text.action({ focused: active() })}
|
||||
fg={active() ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||
return (
|
||||
<Show when={composer.active("subagents")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued}> No subagents</text>}>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
const active = createMemo(() => index() === selected())
|
||||
|
|
@ -218,7 +218,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={themeV2.background.action({ focused: active(), selected: entry.current })}
|
||||
backgroundColor={
|
||||
active()
|
||||
? themeV2.background.action.primary.focused
|
||||
: entry.current
|
||||
? themeV2.background.action.primary.selected
|
||||
: themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
|
|
@ -227,7 +233,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||
>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||
<text
|
||||
fg={themeV2.text.action({ focused: active(), selected: entry.current })}
|
||||
fg={
|
||||
active()
|
||||
? themeV2.text.action.primary.focused
|
||||
: entry.current
|
||||
? themeV2.text.action.primary.selected
|
||||
: themeV2.text.action.primary.default
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
|
@ -235,14 +247,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||
</text>
|
||||
</box>
|
||||
<Show when={status()}>
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? themeV2.text.action({ focused: active(), selected: entry.current })
|
||||
: themeV2.text.subdued()
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
<text fg={active() ? themeV2.text.action.primary.focused : themeV2.text.subdued} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useClient } from "../../context/client"
|
||||
|
|
@ -9,6 +8,7 @@ import { useDialog } from "../../ui/dialog"
|
|||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
|
||||
export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) {
|
||||
const data = useData()
|
||||
|
|
@ -26,20 +26,15 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov
|
|||
})
|
||||
if (!result) return dialog.clear()
|
||||
const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined
|
||||
const prompt = message?.type === "user" ? projectedPromptInput(message) : undefined
|
||||
route.navigate({
|
||||
sessionID: result.id,
|
||||
type: "session",
|
||||
prompt:
|
||||
message?.type === "user"
|
||||
prompt
|
||||
? {
|
||||
text: message.text,
|
||||
files: message.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: structuredClone(unwrap(message.agents ?? [])),
|
||||
...prompt,
|
||||
agents: prompt.agents ?? [],
|
||||
pasted: [],
|
||||
}
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useClient } from "../../context/client"
|
|||
import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
|
|
@ -23,6 +24,12 @@ export function DialogMessage(props: {
|
|||
<DialogSelect
|
||||
title="Message Actions"
|
||||
options={[
|
||||
{
|
||||
title: "Jump to",
|
||||
value: "message.jump",
|
||||
description: "view message in session",
|
||||
onSelect: (dialog) => dialog.clear(),
|
||||
},
|
||||
{
|
||||
title: "Revert",
|
||||
value: "session.revert",
|
||||
|
|
@ -31,17 +38,7 @@ export function DialogMessage(props: {
|
|||
const value = message()
|
||||
if (value?.type === "user") {
|
||||
props.setPrompt?.({
|
||||
text: value.text,
|
||||
files: value.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: value.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
...projectedPromptInput(value),
|
||||
pasted: [],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
|||
import { Locale } from "../../util/locale"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
|
||||
export function DialogTimeline(props: {
|
||||
sessionID: string
|
||||
onMove: (messageID: string) => void
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
|
|
@ -26,7 +28,9 @@ export function DialogTimeline(props: {
|
|||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
|
||||
dialog.replace(() => (
|
||||
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ export function Footer() {
|
|||
const mcp = createMemo(
|
||||
() => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length,
|
||||
)
|
||||
const mcpError = createMemo(() =>
|
||||
(data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"),
|
||||
)
|
||||
const mcpError = createMemo(() => (data.location.mcp.server.list() ?? []).some((x) => x.status.status === "failed"))
|
||||
const permissions = createMemo(() => {
|
||||
if (route.data.type !== "session") return []
|
||||
return data.session.permission.list(route.data.sessionID) ?? []
|
||||
|
|
@ -54,35 +52,35 @@ export function Footer() {
|
|||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>{directory()}</text>
|
||||
<text fg={themeV2.text.subdued}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={store.welcome}>
|
||||
<text fg={themeV2.text()}>
|
||||
Get started <span style={{ fg: themeV2.text.subdued() }}>/connect</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Get started <span style={{ fg: themeV2.text.subdued }}>/connect</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<text fg={themeV2.text.feedback.warning()}>
|
||||
<span style={{ fg: themeV2.text.feedback.warning() }}>△</span> {permissions().length} Permission
|
||||
<text fg={themeV2.text.feedback.warning.default}>
|
||||
<span style={{ fg: themeV2.text.feedback.warning.default }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={mcp()}>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: themeV2.text.feedback.error() }}>⊙ </span>
|
||||
<span style={{ fg: themeV2.text.feedback.error.default }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: themeV2.text.feedback.success() }}>⊙ </span>
|
||||
<span style={{ fg: themeV2.text.feedback.success.default }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>/status</text>
|
||||
<text fg={themeV2.text.subdued}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -11,128 +11,27 @@ import { useClipboard } from "../../context/clipboard"
|
|||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
isFormAnswerField,
|
||||
} from "../../util/form"
|
||||
import type { FormAnswerField } from "../../util/form"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
type Field = Exclude<FormField, { type: "external" }>
|
||||
|
||||
function isField(field: FormField): field is Field {
|
||||
return field.type !== "external"
|
||||
}
|
||||
|
||||
function fieldLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
function truncate(label: string, max: number) {
|
||||
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
|
||||
}
|
||||
|
||||
function validateText(field: Field, text: string): string | undefined {
|
||||
if (field.type !== "string") return undefined
|
||||
if (field.minLength !== undefined && text.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && text.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(text)) return `Must match pattern: ${field.pattern}`
|
||||
} catch {
|
||||
return `Invalid pattern: ${field.pattern}`
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) return "Expected an email address"
|
||||
if (field.format === "uri") {
|
||||
try {
|
||||
new URL(text)
|
||||
} catch {
|
||||
return "Expected a URL"
|
||||
}
|
||||
}
|
||||
if (field.format === "date") {
|
||||
const date = new Date(`${text}T00:00:00.000Z`)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text)
|
||||
return "Expected a date (YYYY-MM-DD)"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function validateSelection(field: Field, value: FormValue | undefined): string | undefined {
|
||||
if (field.type !== "multiselect" || value === undefined) return undefined
|
||||
if (!Array.isArray(value)) return "Expected selections"
|
||||
if (field.required && value.length === 0) return "Select at least one option"
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||
return undefined
|
||||
}
|
||||
|
||||
function validateValue(field: Field, value: FormValue | undefined): string | undefined {
|
||||
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) {
|
||||
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||
}
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return "Expected text"
|
||||
const invalid = validateText(field, value)
|
||||
if (invalid) return invalid
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
||||
return "Select an available option"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return undefined
|
||||
}
|
||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||
const invalid = validateSelection(field, value)
|
||||
if (invalid) return invalid
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
!field.custom &&
|
||||
value.some((item) => !field.options.some((option) => option.value === item))
|
||||
) {
|
||||
return "Select only available options"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
]
|
||||
if (field.type === "multiselect" || (field.type === "string" && field.options))
|
||||
return (field.options ?? []).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
return []
|
||||
}
|
||||
|
||||
function selectedRow(field: Field | undefined, value: FormValue | undefined) {
|
||||
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||
const rows = fieldRows(field)
|
||||
const index = rows.findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && field.type === "string" && field.options && field.custom) return rows.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function display(field: Field, value: FormValue | undefined) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
fieldRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
if (Array.isArray(value)) return value.length === 0 ? "(none)" : value.map(label).join(", ")
|
||||
return label(value)
|
||||
}
|
||||
|
||||
function requestOptions(form: FormWithLocation) {
|
||||
if (form.sessionID !== "global" || !form.location) return undefined
|
||||
return {
|
||||
|
|
@ -145,29 +44,22 @@ function requestOptions(form: FormWithLocation) {
|
|||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const client = useClient()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keymap = Keymap.use()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const configuredFields = props.form.fields.filter(isField)
|
||||
const configuredFields = props.form.fields.filter(isFormAnswerField)
|
||||
const initial = formInitialValues(props.form.fields)
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: Object.fromEntries(
|
||||
configuredFields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
|
||||
) as Record<string, FormValue | undefined>,
|
||||
custom: Object.fromEntries(
|
||||
configuredFields.flatMap((field) => {
|
||||
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||
if (field.options.some((option) => option.value === field.default)) return []
|
||||
return [[field.key, field.default]]
|
||||
}),
|
||||
) as Record<string, string>,
|
||||
answers: initial.answers,
|
||||
custom: initial.custom,
|
||||
externalReady: {} as Record<string, boolean>,
|
||||
selected: selectedRow(configuredFields[0], configuredFields[0]?.default),
|
||||
selected: formSelected(configuredFields[0], configuredFields[0]?.default),
|
||||
editing: false,
|
||||
error: "",
|
||||
})
|
||||
|
|
@ -202,7 +94,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
})
|
||||
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
||||
const tabbed = createMemo(() => {
|
||||
const width = fields().reduce((sum, item) => sum + truncate(fieldLabel(item), 24).length + 3, "Confirm".length + 3)
|
||||
const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Confirm".length + 3)
|
||||
return width <= dimensions().width - 8
|
||||
})
|
||||
const answered = createMemo(
|
||||
|
|
@ -215,7 +107,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const field = createMemo(() => fields()[store.tab])
|
||||
const answerField = createMemo(() => {
|
||||
const current = field()
|
||||
return current && isField(current) ? current : undefined
|
||||
return current && isFormAnswerField(current) ? current : undefined
|
||||
})
|
||||
const externalField = createMemo(() => {
|
||||
const current = field()
|
||||
|
|
@ -225,7 +117,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const rows = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (!current) return []
|
||||
const configured = fieldRows(current)
|
||||
const configured = formRows(current)
|
||||
const value = store.answers[current.key]
|
||||
if (current.type !== "multiselect" || !Array.isArray(value)) return configured
|
||||
const known = new Set(configured.map((row) => row.value))
|
||||
|
|
@ -236,17 +128,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
})
|
||||
const textual = createMemo(() => {
|
||||
if (confirm()) return false
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
if (current.type === "number" || current.type === "integer") return true
|
||||
return current.type === "string" && current.options === undefined
|
||||
return formTextual(answerField())
|
||||
})
|
||||
const custom = createMemo(() => {
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
if (current.type === "string" && current.options !== undefined) return current.custom === true
|
||||
if (current.type === "multiselect") return current.custom === true
|
||||
return false
|
||||
return formCustom(answerField())
|
||||
})
|
||||
const multi = createMemo(() => answerField()?.type === "multiselect")
|
||||
const actionLabel = createMemo(() => {
|
||||
|
|
@ -293,7 +178,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: Field, value: FormValue) {
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
|
|
@ -316,7 +201,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function pick(value: FormValue, customValue?: string) {
|
||||
const current = answerField()
|
||||
if (!current) return
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return
|
||||
|
|
@ -333,19 +218,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function toggle(value: string) {
|
||||
const current = answerField()
|
||||
if (!current) return
|
||||
const existing = store.answers[current.key]
|
||||
const list = Array.isArray(existing) ? [...existing] : []
|
||||
const index = list.indexOf(value)
|
||||
if (index === -1) list.push(value)
|
||||
if (index !== -1) list.splice(index, 1)
|
||||
answer(current.key, list)
|
||||
answer(current.key, formToggleMultiselect(store.answers[current.key], value))
|
||||
}
|
||||
|
||||
function validateCurrent() {
|
||||
if (confirm()) return true
|
||||
const current = answerField()
|
||||
if (!current) return true
|
||||
const invalid = validateValue(current, store.answers[current.key])
|
||||
const invalid = formValidateValue(current, store.answers[current.key])
|
||||
if (!invalid) return true
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -355,7 +235,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
if (!confirm() && index > store.tab && !validateCurrent()) return
|
||||
const next = fields()[index]
|
||||
setStore("tab", index)
|
||||
setStore("selected", next && isField(next) ? selectedRow(next, store.answers[next.key]) : 0)
|
||||
setStore("selected", next && isFormAnswerField(next) ? formSelected(next, store.answers[next.key]) : 0)
|
||||
setStore("editing", false)
|
||||
setStore("error", "")
|
||||
}
|
||||
|
|
@ -393,7 +273,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
const existing = store.answers[current.key]
|
||||
const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : []
|
||||
const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -406,7 +286,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
|
||||
if (isTextual && (current.type === "number" || current.type === "integer")) {
|
||||
const value = Number(text)
|
||||
const invalid = validateValue(current, value)
|
||||
const invalid = formValidateValue(current, value)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -415,7 +295,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
}
|
||||
|
||||
if (isTextual && current.type === "string") {
|
||||
const invalid = validateValue(current, text)
|
||||
const invalid = formValidateValue(current, text)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -424,19 +304,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
}
|
||||
|
||||
if (!isTextual && isMulti) {
|
||||
const previous = store.custom[current.key]
|
||||
const existing = store.answers[current.key]
|
||||
const values = Array.isArray(existing) ? [...existing] : []
|
||||
if (previous) {
|
||||
const index = values.indexOf(previous)
|
||||
if (index !== -1) values.splice(index, 1)
|
||||
}
|
||||
if (!values.includes(text)) values.push(text)
|
||||
answer(current.key, values)
|
||||
answer(current.key, formSetMultiselectCustom(store.answers[current.key], store.custom[current.key], text))
|
||||
}
|
||||
|
||||
if (!isTextual && !isMulti) {
|
||||
const invalid = validateValue(current, text)
|
||||
const invalid = formValidateValue(current, text)
|
||||
if (invalid) {
|
||||
setStore("error", invalid)
|
||||
return false
|
||||
|
|
@ -518,14 +390,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
function submit() {
|
||||
const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true)
|
||||
if (unacknowledged) {
|
||||
setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`)
|
||||
setStore("error", `External action must be acknowledged: ${formLabel(unacknowledged)}`)
|
||||
return
|
||||
}
|
||||
const invalid = fields()
|
||||
.filter(isField)
|
||||
.find((field) => validateValue(field, store.answers[field.key]))
|
||||
.filter(isFormAnswerField)
|
||||
.find((field) => formValidateValue(field, store.answers[field.key]))
|
||||
if (invalid) {
|
||||
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
|
|
@ -752,27 +624,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.hue.accent(500)}
|
||||
borderColor={themeV2.hue.interactive[themeMode() === "light" ? 800 : 200]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{props.form.title}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.form.title}</text>
|
||||
</box>
|
||||
<Show when={message()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{message()}</text>
|
||||
<text fg={themeV2.text.default}>{message()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single() && !tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||
</text>
|
||||
<Show when={fields().length > 0}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
· {answered()}/{fields().length} completed
|
||||
</text>
|
||||
</Show>
|
||||
|
|
@ -789,10 +661,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
paddingRight={2}
|
||||
backgroundColor={
|
||||
isTab()
|
||||
? themeV2.background.formfield("selected")
|
||||
? themeV2.background.formfield.selected
|
||||
: tabHover() === index()
|
||||
? themeV2.background.formfield("focused")
|
||||
: themeV2.background()
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
|
|
@ -804,15 +676,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<text
|
||||
fg={
|
||||
isTab()
|
||||
? themeV2.text.formfield("selected")
|
||||
? themeV2.text.formfield.selected
|
||||
: tabHover() === index()
|
||||
? themeV2.text.formfield("focused")
|
||||
? themeV2.text.formfield.focused
|
||||
: isAnswered()
|
||||
? themeV2.text()
|
||||
: themeV2.text.subdued()
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{truncate(fieldLabel(item), 24)}
|
||||
{truncate(formLabel(item), 24)}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -821,10 +693,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<box
|
||||
backgroundColor={
|
||||
confirm()
|
||||
? themeV2.background.formfield("selected")
|
||||
? themeV2.background.formfield.selected
|
||||
: tabHover() === "confirm"
|
||||
? themeV2.background.formfield("focused")
|
||||
: themeV2.background()
|
||||
? themeV2.background.formfield.focused
|
||||
: themeV2.background.default
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
|
|
@ -833,7 +705,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
selectTabFromMouse()
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.formfield(confirm() ? "selected" : "default")}>Confirm</text>
|
||||
<text fg={confirm() ? themeV2.text.formfield.selected : themeV2.text.formfield.default}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -842,13 +714,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
{(external) => (
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<Show when={external().title}>
|
||||
<text fg={themeV2.text()}>{external().title}</text>
|
||||
<text fg={themeV2.text.default}>{external().title}</text>
|
||||
</Show>
|
||||
<Show when={external().description}>
|
||||
<text fg={themeV2.text.subdued()}>{external().description}</text>
|
||||
<text fg={themeV2.text.subdued}>{external().description}</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={themeV2.background.action()}
|
||||
fg={themeV2.background.action.primary.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
openExternal()
|
||||
|
|
@ -858,9 +730,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
</text>
|
||||
<text
|
||||
fg={
|
||||
store.answers[external().key] === true
|
||||
? themeV2.text.feedback.success()
|
||||
: themeV2.text.subdued()
|
||||
store.answers[external().key] === true ? themeV2.text.feedback.success.default : themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{store.answers[external().key] === true
|
||||
|
|
@ -876,8 +746,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<Show when={!confirm() && answerField()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={themeV2.text()}>
|
||||
{answerField()!.description ?? fieldLabel(answerField()!)}
|
||||
<text fg={themeV2.text.default}>
|
||||
{answerField()!.description ?? formLabel(answerField()!)}
|
||||
{answerField()!.required ? " (required)" : ""}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
|
|
@ -893,14 +763,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
|
||||
initialValue={
|
||||
input() || formDisplayValue(answerField()!, store.answers[answerField()!.key], "(none)")
|
||||
}
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={themeV2.text()}
|
||||
focusedTextColor={themeV2.text()}
|
||||
cursorColor={themeV2.text()}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -926,34 +798,38 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield("focused") : themeV2.background()
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={themeV2.text.formfield(active() ? "focused" : "default")}>
|
||||
{`${i() + 1}.`}
|
||||
</text>
|
||||
<text
|
||||
fg={active() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}
|
||||
>{`${i() + 1}.`}</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={
|
||||
active() ? themeV2.background.formfield("focused") : themeV2.background()
|
||||
active() ? themeV2.background.formfield.focused : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text
|
||||
fg={themeV2.text.formfield(
|
||||
active() ? "focused" : picked() ? "selected" : "default",
|
||||
)}
|
||||
fg={
|
||||
active()
|
||||
? themeV2.text.formfield.focused
|
||||
: picked()
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={themeV2.text.formfield("selected")}>{picked() ? " ✓" : ""}</text>
|
||||
<text fg={themeV2.text.formfield.selected}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={themeV2.text.subdued()}>{row.description}</text>
|
||||
<text fg={themeV2.text.subdued}>{row.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
|
@ -971,30 +847,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={themeV2.text.formfield(other() ? "focused" : "default")}>
|
||||
<text fg={other() ? themeV2.text.formfield.focused : themeV2.text.formfield.default}>
|
||||
{`${rows().length + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
|
||||
backgroundColor={other() ? themeV2.background.formfield.focused : themeV2.background.default}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
other()
|
||||
? themeV2.text.formfield("focused")
|
||||
? themeV2.text.formfield.focused
|
||||
: customPicked()
|
||||
? themeV2.text.feedback.success()
|
||||
: themeV2.text()
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.default
|
||||
}
|
||||
>
|
||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={themeV2.text.feedback.success()}>{customPicked() ? " ✓" : ""}</text>
|
||||
<text fg={themeV2.text.feedback.success.default}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
|
|
@ -1010,18 +886,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={themeV2.text()}
|
||||
focusedTextColor={themeV2.text()}
|
||||
cursorColor={themeV2.text()}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={themeV2.text.subdued()}>{input()}</text>
|
||||
<text fg={themeV2.text.subdued}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
|
@ -1034,7 +910,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
<Show when={confirm()}>
|
||||
<Show when={tabbed()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>Review</text>
|
||||
<text fg={themeV2.text.default}>Review</text>
|
||||
</box>
|
||||
</Show>
|
||||
<scrollbox
|
||||
|
|
@ -1049,12 +925,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: acknowledged()
|
||||
? themeV2.text.feedback.success()
|
||||
: themeV2.text.feedback.error(),
|
||||
? themeV2.text.feedback.success.default
|
||||
: themeV2.text.feedback.error.default,
|
||||
}}
|
||||
>
|
||||
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||
|
|
@ -1063,22 +939,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
</box>
|
||||
)
|
||||
}
|
||||
const value = () => display(item, store.answers[item.key])
|
||||
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
|
||||
const answered = () => store.answers[item.key] !== undefined
|
||||
const missing = () => !answered() && item.required === true
|
||||
const invalid = () => validateValue(item, store.answers[item.key])
|
||||
const invalid = () => formValidateValue(item, store.answers[item.key])
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||
<span style={{ fg: themeV2.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
invalid() || missing()
|
||||
? themeV2.text.feedback.error()
|
||||
? themeV2.text.feedback.error.default
|
||||
: answered()
|
||||
? themeV2.text()
|
||||
: themeV2.text.subdued(),
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued,
|
||||
}}
|
||||
>
|
||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||
|
|
@ -1102,41 +978,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={!single()}>
|
||||
<text fg={themeV2.text()}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued() }}>tab</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm() && !textual() && !externalField()}>
|
||||
<text fg={themeV2.text()}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={confirm() && fields().length > 0}>
|
||||
<text fg={themeV2.text()}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>scroll</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"↑↓"} <span style={{ fg: themeV2.text.subdued }}>scroll</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
fg={themeV2.text()}
|
||||
fg={themeV2.text.default}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (confirm()) submit()
|
||||
if (externalField()) acknowledgeExternal()
|
||||
}}
|
||||
>
|
||||
enter <span style={{ fg: themeV2.text.subdued() }}>{actionLabel()}</span>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>{actionLabel()}</span>
|
||||
</text>
|
||||
<Show when={externalField() && clipboard.write}>
|
||||
<text fg={themeV2.text()} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
|
||||
<text fg={themeV2.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text()} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: themeV2.text.subdued() }}>dismiss</span>
|
||||
<text fg={themeV2.text.default} onMouseUp={cancel}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={store.error}>
|
||||
<text fg={themeV2.text.feedback.error()}>{store.error}</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>{store.error}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ import type {
|
|||
import { useLocal } from "../../context/local"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../util/tool-display"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
|
|
@ -47,6 +53,7 @@ import { useDialog } from "../../ui/dialog"
|
|||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Composer } from "./composer"
|
||||
import { filetype } from "../../util/filetype"
|
||||
|
|
@ -55,6 +62,7 @@ import { errorMessage } from "../../util/error"
|
|||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
|
|
@ -451,7 +459,15 @@ export function Session() {
|
|||
id: "session.timeline",
|
||||
group: "Session",
|
||||
slash: { name: "timeline" },
|
||||
run: () => unavailable("The message timeline"),
|
||||
run: () => {
|
||||
dialog.replace(() => (
|
||||
<DialogTimeline
|
||||
sessionID={route.sessionID}
|
||||
onMove={jumpToMessage}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Fork session",
|
||||
|
|
@ -511,17 +527,7 @@ export function Session() {
|
|||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt?.set({
|
||||
text: message.text,
|
||||
files: message.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: message.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
})
|
||||
dialog.clear()
|
||||
|
|
@ -913,8 +919,8 @@ export function Session() {
|
|||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.raise(themeV2.background.surface.offset()),
|
||||
foregroundColor: themeV2.border(),
|
||||
backgroundColor: themeV2.raise(themeV2.background.surface.offset),
|
||||
foregroundColor: themeV2.border.default,
|
||||
},
|
||||
}}
|
||||
stickyScroll={!navigationMessage()}
|
||||
|
|
@ -1092,8 +1098,8 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
|||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
Press <span style={{ fg: themeV2.text() }}>{value()}</span> to move running work to the background
|
||||
<text fg={themeV2.text.subdued}>
|
||||
Press <span style={{ fg: themeV2.text.default }}>{value()}</span> to move running work to the background
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
|
@ -1203,13 +1209,13 @@ function SessionReasoningGroupView(props: {
|
|||
icon={expanded() ? "-" : "+"}
|
||||
color={
|
||||
!props.completed
|
||||
? themeV2.text()
|
||||
? themeV2.text.default
|
||||
: hover() || expanded()
|
||||
? themeV2.text.feedback.warning()
|
||||
? themeV2.text.feedback.warning.default
|
||||
: RGBA.fromValues(
|
||||
themeV2.text.feedback.warning().r,
|
||||
themeV2.text.feedback.warning().g,
|
||||
themeV2.text.feedback.warning().b,
|
||||
themeV2.text.feedback.warning.default.r,
|
||||
themeV2.text.feedback.warning.default.g,
|
||||
themeV2.text.feedback.warning.default.b,
|
||||
0.6,
|
||||
)
|
||||
}
|
||||
|
|
@ -1252,7 +1258,7 @@ function SessionReasoningGroupView(props: {
|
|||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.raise(themeV2.background.surface.offset())}
|
||||
borderColor={themeV2.raise(themeV2.background.surface.offset)}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<code
|
||||
|
|
@ -1262,7 +1268,7 @@ function SessionReasoningGroupView(props: {
|
|||
syntaxStyle={syntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={themeV2.text.subdued()}
|
||||
fg={themeV2.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -1320,7 +1326,7 @@ function SessionGroupView(props: {
|
|||
<Show when={grouped().length > 0}>
|
||||
<InlineToolRow
|
||||
icon={props.completed ? "→" : "✱"}
|
||||
color={hover() ? themeV2.text() : themeV2.text.subdued()}
|
||||
color={hover() ? themeV2.text.default : themeV2.text.subdued}
|
||||
complete={props.completed}
|
||||
pending={label()}
|
||||
spinner={!props.completed}
|
||||
|
|
@ -1366,25 +1372,25 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
|||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.text.feedback.error()}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}>
|
||||
<span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · interrupted</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -1403,7 +1409,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
|||
}
|
||||
return (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={themeV2.text.subdued()}>{text()}</text>
|
||||
<text fg={themeV2.text.subdued}>{text()}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1430,15 +1436,15 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (state() === "error") return themeV2.text.feedback.error()
|
||||
if (state() === "cancelled") return themeV2.text.feedback.warning()
|
||||
return themeV2.text.feedback.info()
|
||||
if (state() === "error") return themeV2.text.feedback.error.default
|
||||
if (state() === "cancelled") return themeV2.text.feedback.warning.default
|
||||
return themeV2.text.feedback.info.default
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
when={completion()}
|
||||
fallback={
|
||||
<InlineToolRow icon="◈" color={themeV2.text.subdued()} pending="Notice" complete={true}>
|
||||
<InlineToolRow icon="◈" color={themeV2.text.subdued} pending="Notice" complete={true}>
|
||||
{text()}
|
||||
</InlineToolRow>
|
||||
}
|
||||
|
|
@ -1446,7 +1452,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||
<box marginLeft={3}>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{suffix()}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{suffix()}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -1456,7 +1462,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<InlineToolRow icon="→" color={themeV2.text.subdued()} pending="Skill" complete={true}>
|
||||
<InlineToolRow icon="→" color={themeV2.text.subdued} pending="Skill" complete={true}>
|
||||
Skill {props.message.name}
|
||||
</InlineToolRow>
|
||||
)
|
||||
|
|
@ -1470,7 +1476,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
|||
const text = () =>
|
||||
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
|
||||
const content = createMemo(() => text().trim())
|
||||
const color = () => (status() === "failed" && !cancelled() ? themeV2.text.feedback.error() : themeV2.text.subdued())
|
||||
const color = () =>
|
||||
status() === "failed" && !cancelled() ? themeV2.text.feedback.error.default : themeV2.text.subdued
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" alignItems="center">
|
||||
|
|
@ -1502,8 +1509,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
|||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={themeV2.markdown()}
|
||||
bg={themeV2.background()}
|
||||
fg={themeV2.markdown.text}
|
||||
bg={themeV2.background.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -1515,12 +1522,12 @@ function CompactionQueued() {
|
|||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<box flexDirection="row" alignItems="center">
|
||||
<box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
|
||||
<box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={themeV2.text.subdued()}>◇</text>
|
||||
<text fg={themeV2.text.subdued()}>Compaction queued</text>
|
||||
<text fg={themeV2.text.subdued}>◇</text>
|
||||
<text fg={themeV2.text.subdued}>Compaction queued</text>
|
||||
</box>
|
||||
<box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
|
||||
<box border={["top"]} borderColor={themeV2.border.default} flexGrow={1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1566,15 +1573,15 @@ function RevertMessage(props: {
|
|||
marginTop={1}
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.background()}
|
||||
borderColor={themeV2.background.default}
|
||||
>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
|
||||
>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{props.count} message{props.count === 1 ? "" : "s"} reverted
|
||||
</text>
|
||||
<Show when={props.files.length > 0}>
|
||||
|
|
@ -1582,7 +1589,7 @@ function RevertMessage(props: {
|
|||
<For each={props.files}>
|
||||
{(file) => (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>{statusLabel(file.status)}</text>
|
||||
<text fg={themeV2.text.subdued}>{statusLabel(file.status)}</text>
|
||||
<FilePath
|
||||
value={file.file}
|
||||
maxWidth={Math.max(
|
||||
|
|
@ -1592,21 +1599,21 @@ function RevertMessage(props: {
|
|||
(file.additions > 0 ? stringWidth(`+${file.additions}`) + 1 : 0) -
|
||||
(file.deletions > 0 ? stringWidth(`-${file.deletions}`) + 1 : 0),
|
||||
)}
|
||||
fg={themeV2.text()}
|
||||
fg={themeV2.text.default}
|
||||
/>
|
||||
<Show when={file.additions > 0}>
|
||||
<text fg={themeV2.diff.text.added()}>+{file.additions}</text>
|
||||
<text fg={themeV2.diff.text.added}>+{file.additions}</text>
|
||||
</Show>
|
||||
<Show when={file.deletions > 0}>
|
||||
<text fg={themeV2.diff.text.removed()}>-{file.deletions}</text>
|
||||
<text fg={themeV2.diff.text.removed}>-{file.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<span style={{ fg: themeV2.text() }}>{redoKey()}</span> or /redo to restore
|
||||
<text fg={themeV2.text.subdued}>
|
||||
<span style={{ fg: themeV2.text.default }}>{redoKey()}</span> or /redo to restore
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -1624,13 +1631,13 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
|||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.background()}
|
||||
borderColor={themeV2.background.default}
|
||||
>
|
||||
<text fg={themeV2.text()}>$ {props.message.command}</text>
|
||||
<text fg={themeV2.text.default}>$ {props.message.command}</text>
|
||||
<Show when={output()}>
|
||||
<text fg={themeV2.text.subdued()}>{output()}</text>
|
||||
<text fg={themeV2.text.subdued}>{output()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -1641,7 +1648,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||
const data = useData()
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2, mode } = useTheme().contextual("elevated")
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
const queued = createMemo(
|
||||
|
|
@ -1655,7 +1662,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||
<Show when={props.message.text.trim() || files().length}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={queued() ? themeV2.border() : color()}
|
||||
borderColor={queued() ? themeV2.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box
|
||||
|
|
@ -1678,27 +1685,27 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={themeV2.text()}>{props.message.text}</text>
|
||||
<text fg={themeV2.text.default}>{props.message.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||
return (
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: themeV2.hue.accent(500),
|
||||
fg: themeV2.background(),
|
||||
bg: themeV2.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: themeV2.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
|
|
@ -1803,11 +1810,11 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
|||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.text.feedback.error()}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
|
|
@ -1815,14 +1822,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
|||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span
|
||||
style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}
|
||||
>
|
||||
<span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -1838,7 +1843,7 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
|||
<Show when={props.retry}>
|
||||
{(retry) => (
|
||||
<box paddingLeft={3} marginTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -1861,7 +1866,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
|
|||
<box flexDirection="column">
|
||||
<InlineToolRow
|
||||
icon="✱"
|
||||
color={themeV2.text.subdued()}
|
||||
color={themeV2.text.subdued}
|
||||
complete={!props.active}
|
||||
pending="Exploring"
|
||||
spinner={props.active}
|
||||
|
|
@ -1871,7 +1876,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
|
|||
<For each={props.parts}>
|
||||
{(part, index) => (
|
||||
<box paddingLeft={5}>
|
||||
<text fg={part.state.status === "error" ? themeV2.text.feedback.error() : themeV2.text.subdued()}>
|
||||
<text fg={part.state.status === "error" ? themeV2.text.feedback.error.default : themeV2.text.subdued}>
|
||||
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -1916,7 +1921,7 @@ function ReasoningPart(props: {
|
|||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.raise(themeV2.background())}
|
||||
borderColor={themeV2.raise(themeV2.background.default)}
|
||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
|
|
@ -1934,7 +1939,7 @@ function ReasoningPart(props: {
|
|||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.raise(themeV2.background())}
|
||||
borderColor={themeV2.raise(themeV2.background.default)}
|
||||
paddingLeft={inMinimal() ? 3 : 1}
|
||||
>
|
||||
<code
|
||||
|
|
@ -1944,7 +1949,7 @@ function ReasoningPart(props: {
|
|||
syntaxStyle={syntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={themeV2.text.subdued()}
|
||||
fg={themeV2.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -1970,12 +1975,12 @@ function ReasoningHeader(props: {
|
|||
const fg = () =>
|
||||
props.open
|
||||
? RGBA.fromValues(
|
||||
themeV2.text.feedback.warning().r,
|
||||
themeV2.text.feedback.warning().g,
|
||||
themeV2.text.feedback.warning().b,
|
||||
themeV2.text.feedback.warning.default.r,
|
||||
themeV2.text.feedback.warning.default.g,
|
||||
themeV2.text.feedback.warning.default.b,
|
||||
0.6,
|
||||
)
|
||||
: themeV2.text.feedback.warning()
|
||||
: themeV2.text.feedback.warning.default
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
|
|
@ -2021,8 +2026,8 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={themeV2.markdown()}
|
||||
bg={themeV2.background()}
|
||||
fg={themeV2.markdown.text}
|
||||
bg={themeV2.background.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -2036,7 +2041,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
return props.part.state.status === "streaming" ? {} : props.part.state.structured
|
||||
return toolDisplayMetadata(props.part.state)
|
||||
},
|
||||
get input() {
|
||||
return typeof props.part.state.input === "string" ? {} : props.part.state.input
|
||||
|
|
@ -2129,7 +2134,7 @@ function GenericTool(props: ToolProps) {
|
|||
<Show when={Object.keys(props.input).length > 0}>
|
||||
<box gap={1}>
|
||||
<text>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}> Input </span>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}> Input </span>
|
||||
</text>
|
||||
<box paddingLeft={1}>
|
||||
<code
|
||||
|
|
@ -2138,7 +2143,7 @@ function GenericTool(props: ToolProps) {
|
|||
syntaxStyle={syntax()}
|
||||
conceal={false}
|
||||
drawUnstyledText={false}
|
||||
fg={themeV2.text()}
|
||||
fg={themeV2.text.default}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -2147,10 +2152,13 @@ function GenericTool(props: ToolProps) {
|
|||
{(value) => (
|
||||
<box gap={1}>
|
||||
<text>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background()), fg: themeV2.text.subdued() }}> Output </span>
|
||||
<span style={{ bg: themeV2.raise(themeV2.background.default), fg: themeV2.text.subdued }}>
|
||||
{" "}
|
||||
Output{" "}
|
||||
</span>
|
||||
</text>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()} wrapMode="word">
|
||||
<text fg={themeV2.text.default} wrapMode="word">
|
||||
{value()}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -2202,10 +2210,10 @@ function InlineTool(props: {
|
|||
const clickable = createMemo(() => Boolean(props.onClick || failed()))
|
||||
const fg = createMemo(() => {
|
||||
if (props.color) return props.color
|
||||
if (permission()) return themeV2.text.feedback.warning()
|
||||
if (failed()) return themeV2.text.feedback.error()
|
||||
if (hover() && props.onClick) return themeV2.text()
|
||||
return themeV2.text.subdued()
|
||||
if (permission()) return themeV2.text.feedback.warning.default
|
||||
if (failed()) return themeV2.text.feedback.error.default
|
||||
if (hover() && props.onClick) return themeV2.text.default
|
||||
return themeV2.text.subdued
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -2213,7 +2221,7 @@ function InlineTool(props: {
|
|||
icon={props.icon}
|
||||
iconColor={props.iconColor}
|
||||
color={fg()}
|
||||
errorColor={themeV2.text.feedback.error()}
|
||||
errorColor={themeV2.text.feedback.error.default}
|
||||
failed={failed()}
|
||||
denied={Boolean(denied())}
|
||||
error={error()}
|
||||
|
|
@ -2337,7 +2345,7 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El
|
|||
function StatusBadge(props: { children: string }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<text flexShrink={0} bg={themeV2.raise(themeV2.background())} fg={themeV2.text.subdued()}>
|
||||
<text flexShrink={0} bg={themeV2.raise(themeV2.background.default)} fg={themeV2.text.subdued}>
|
||||
{" "}
|
||||
{props.children}{" "}
|
||||
</text>
|
||||
|
|
@ -2370,9 +2378,9 @@ function BlockTool(props: {
|
|||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()}
|
||||
backgroundColor={hover() ? themeV2.raise(themeV2.background.default) : themeV2.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.background()}
|
||||
borderColor={themeV2.background.default}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
|
|
@ -2388,10 +2396,12 @@ function BlockTool(props: {
|
|||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title()}</text>
|
||||
<text fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
|
||||
{title()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
|
||||
<Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
|
||||
{title().replace(/^# /, "")}
|
||||
</Spinner>
|
||||
</Show>
|
||||
|
|
@ -2404,26 +2414,26 @@ function BlockTool(props: {
|
|||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
|
||||
<text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
|
||||
{path().label}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
|
||||
<Spinner color={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}>
|
||||
{path().label.replace(/^# /, "")}
|
||||
</Spinner>
|
||||
</Show>
|
||||
<FilePath
|
||||
value={path().value}
|
||||
maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
|
||||
fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}
|
||||
fg={permission() ? themeV2.text.feedback.warning.default : themeV2.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
{props.children}
|
||||
<Show when={error()}>
|
||||
<text fg={themeV2.text.feedback.error()}>{error()}</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>{error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -2438,7 +2448,7 @@ function Shell(props: ToolProps) {
|
|||
const request = data.session.permission.list(ctx.sessionID)?.[0]
|
||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||
})
|
||||
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning() : themeV2.text()))
|
||||
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default))
|
||||
const shellID = createMemo(() => stringValue(props.metadata.shellID))
|
||||
const backgroundRunning = createMemo(() => {
|
||||
const id = shellID()
|
||||
|
|
@ -2500,7 +2510,7 @@ function Shell(props: ToolProps) {
|
|||
isRunning() || props.part.state.status === "streaming" ? (
|
||||
<Spinner color={color()}>Writing command...</Spinner>
|
||||
) : (
|
||||
<text fg={themeV2.text.subdued()}>Writing command...</text>
|
||||
<text fg={themeV2.text.subdued}>Writing command...</text>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -2508,14 +2518,14 @@ function Shell(props: ToolProps) {
|
|||
when={isRunning()}
|
||||
fallback={
|
||||
<text>
|
||||
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={color()}>
|
||||
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.default }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}>{limited().slice(input().length)}</span>
|
||||
</Spinner>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
@ -2541,10 +2551,10 @@ function Write(props: ToolProps) {
|
|||
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
|
||||
part={props.part}
|
||||
>
|
||||
<line_number fg={themeV2.text.subdued()} minWidth={3} paddingRight={1}>
|
||||
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={themeV2.text()}
|
||||
fg={themeV2.text.default}
|
||||
filetype={filetype(stringValue(props.input.path))}
|
||||
syntaxStyle={syntax()}
|
||||
content={code()}
|
||||
|
|
@ -2568,8 +2578,8 @@ function Glob(props: ToolProps) {
|
|||
<InlineTool icon="✱" pending="Finding files..." complete={stringValue(props.input.pattern)} part={props.part}>
|
||||
Glob "{stringValue(props.input.pattern)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.count)}>
|
||||
({numberValue(props.metadata.count)} {numberValue(props.metadata.count) === 1 ? "match" : "matches"})
|
||||
<Show when={finiteNumber(props.metadata.count)}>
|
||||
({finiteNumber(props.metadata.count)} {finiteNumber(props.metadata.count) === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
|
|
@ -2599,7 +2609,7 @@ function Read(props: ToolProps) {
|
|||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={themeV2.text.subdued()}>
|
||||
<text paddingLeft={3} fg={themeV2.text.subdued}>
|
||||
↳ Loaded {pathFormatter.format(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -2615,8 +2625,8 @@ function Grep(props: ToolProps) {
|
|||
<InlineTool icon="✱" pending="Searching content..." complete={stringValue(props.input.pattern)} part={props.part}>
|
||||
Grep "{stringValue(props.input.pattern)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {pathFormatter.format(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.matches)}>
|
||||
({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"})
|
||||
<Show when={finiteNumber(props.metadata.matches)}>
|
||||
({finiteNumber(props.metadata.matches)} {finiteNumber(props.metadata.matches) === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
|
|
@ -2634,7 +2644,7 @@ function WebSearch(props: ToolProps) {
|
|||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
|
||||
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "}
|
||||
<Show when={numberValue(props.metadata.numResults)}>({numberValue(props.metadata.numResults)} results)</Show>
|
||||
<Show when={finiteNumber(props.metadata.numResults)}>({finiteNumber(props.metadata.numResults)} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
|
@ -2708,7 +2718,7 @@ function Execute(props: ToolProps) {
|
|||
const content = createMemo(() => {
|
||||
const lines = ["execute"]
|
||||
for (const call of calls()) {
|
||||
const args = input(call.input ?? {})
|
||||
const args = primitiveInputSummary(call.input ?? {})
|
||||
lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
|
|
@ -2718,7 +2728,7 @@ function Execute(props: ToolProps) {
|
|||
<>
|
||||
<InlineTool
|
||||
icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
|
||||
color={hasRuntimeError() ? themeV2.text.feedback.error() : undefined}
|
||||
color={hasRuntimeError() ? themeV2.text.feedback.error.default : undefined}
|
||||
spinner={isLoading()}
|
||||
pending="execute"
|
||||
complete={true}
|
||||
|
|
@ -2730,7 +2740,7 @@ function Execute(props: ToolProps) {
|
|||
<box paddingLeft={3}>
|
||||
<For each={outputPreview().split("\n")}>
|
||||
{(line, index) => (
|
||||
<text paddingLeft={3} fg={themeV2.text.feedback.error()}>
|
||||
<text paddingLeft={3} fg={themeV2.text.feedback.error.default}>
|
||||
{index() === 0 ? "↳ " : " "}
|
||||
{line}
|
||||
</text>
|
||||
|
|
@ -2772,16 +2782,16 @@ function Edit(props: ToolProps) {
|
|||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode={ctx.diffWrapMode()}
|
||||
fg={themeV2.text()}
|
||||
addedBg={themeV2.diff.background.added()}
|
||||
removedBg={themeV2.diff.background.removed()}
|
||||
contextBg={themeV2.diff.background.context()}
|
||||
addedSignColor={themeV2.diff.highlight.added()}
|
||||
removedSignColor={themeV2.diff.highlight.removed()}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text()}
|
||||
lineNumberBg={themeV2.diff.background.context()}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</box>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
|
||||
|
|
@ -2846,7 +2856,7 @@ function ApplyPatch(props: ToolProps) {
|
|||
<Show
|
||||
when={file.type !== "delete"}
|
||||
fallback={
|
||||
<text fg={themeV2.diff.text.removed()}>
|
||||
<text fg={themeV2.diff.text.removed}>
|
||||
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
|
||||
</text>
|
||||
}
|
||||
|
|
@ -2860,16 +2870,16 @@ function ApplyPatch(props: ToolProps) {
|
|||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode={ctx.diffWrapMode()}
|
||||
fg={themeV2.text()}
|
||||
addedBg={themeV2.diff.background.added()}
|
||||
removedBg={themeV2.diff.background.removed()}
|
||||
contextBg={themeV2.diff.background.context()}
|
||||
addedSignColor={themeV2.diff.highlight.added()}
|
||||
removedSignColor={themeV2.diff.highlight.removed()}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text()}
|
||||
lineNumberBg={themeV2.diff.background.context()}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
|
@ -2892,7 +2902,7 @@ function ApplyPatch(props: ToolProps) {
|
|||
<FilePath
|
||||
value={file.resource}
|
||||
maxWidth={Math.max(2, ctx.width - 3)}
|
||||
fg={file.type === "delete" ? themeV2.diff.text.removed() : themeV2.text.subdued()}
|
||||
fg={file.type === "delete" ? themeV2.diff.text.removed : themeV2.text.subdued}
|
||||
/>
|
||||
</BlockTool>
|
||||
)}
|
||||
|
|
@ -2939,8 +2949,8 @@ function Question(props: ToolProps) {
|
|||
<For each={questions()}>
|
||||
{(q, i) => (
|
||||
<box flexDirection="column">
|
||||
<text fg={themeV2.text.subdued()}>{q.question}</text>
|
||||
<text fg={themeV2.text()}>{format(answers()?.[i()])}</text>
|
||||
<text fg={themeV2.text.subdued}>{q.question}</text>
|
||||
<text fg={themeV2.text.default}>{format(answers()?.[i()])}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -2981,7 +2991,7 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
|||
<box>
|
||||
<For each={errors()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={themeV2.text.feedback.error()}>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
|
|
@ -2991,23 +3001,10 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function input(input: Record<string, unknown>, omit?: string[]): string {
|
||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit?.includes(key)) return false
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
})
|
||||
if (primitives.length === 0) return ""
|
||||
return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const toolDisplays = new Set([
|
||||
"shell",
|
||||
"glob",
|
||||
|
|
@ -3025,9 +3022,7 @@ const toolDisplays = new Set([
|
|||
])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
|
||||
// them with the renamed views.
|
||||
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
|
|
@ -3073,8 +3068,8 @@ export function parseApplyPatchFiles(value: unknown) {
|
|||
const relativePath = stringValue(file.file) ?? stringValue(file.relativePath)
|
||||
const filePath = stringValue(file.filePath) ?? relativePath
|
||||
const patch = stringValue(file.patch)
|
||||
const additions = numberValue(file.additions)
|
||||
const deletions = numberValue(file.deletions)
|
||||
const additions = finiteNumber(file.additions)
|
||||
const deletions = finiteNumber(file.deletions)
|
||||
if (
|
||||
!type ||
|
||||
!relativePath ||
|
||||
|
|
@ -3110,8 +3105,8 @@ export function parseDiagnostics(value: unknown, filePath: string) {
|
|||
.flatMap((item) => {
|
||||
const diagnostic = recordValue(item)
|
||||
const start = recordValue(recordValue(diagnostic?.range)?.start)
|
||||
const line = numberValue(start?.line)
|
||||
const character = numberValue(start?.character)
|
||||
const line = finiteNumber(start?.line)
|
||||
const character = finiteNumber(start?.character)
|
||||
const message = stringValue(diagnostic?.message)
|
||||
if (diagnostic?.severity !== 1 || line === undefined || character === undefined || !message) return []
|
||||
return [{ range: { start: { line, character } }, message }]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { dirname } from "node:path"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
|
|
@ -9,8 +8,7 @@ import { useClient } from "../../context/client"
|
|||
import { SplitBorder } from "../../ui/border"
|
||||
import { useData } from "../../context/data"
|
||||
import { filetype } from "../../util/filetype"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../../util/permission"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
|
|
@ -19,20 +17,15 @@ import { SimulationSemantics } from "../../simulation/semantics"
|
|||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
const syntax = themeState.syntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => {
|
||||
return props.request.resources[0] ?? ""
|
||||
})
|
||||
const diff = createMemo(() => {
|
||||
const value = props.request.metadata?.diff
|
||||
return typeof value === "string" ? value : ""
|
||||
})
|
||||
const filepath = createMemo(() => props.file ?? "")
|
||||
const diff = createMemo(() => props.diff ?? "")
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffView = config.diffs?.view
|
||||
|
|
@ -52,8 +45,8 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background(),
|
||||
foregroundColor: themeV2.scrollbar(),
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -65,16 +58,16 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={themeV2.text()}
|
||||
addedBg={themeV2.diff.background.added()}
|
||||
removedBg={themeV2.diff.background.removed()}
|
||||
contextBg={themeV2.diff.background.context()}
|
||||
addedSignColor={themeV2.diff.highlight.added()}
|
||||
removedSignColor={themeV2.diff.highlight.removed()}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text()}
|
||||
lineNumberBg={themeV2.diff.background.context()}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
|
||||
fg={themeV2.text.default}
|
||||
addedBg={themeV2.diff.background.added}
|
||||
removedBg={themeV2.diff.background.removed}
|
||||
contextBg={themeV2.diff.background.context}
|
||||
addedSignColor={themeV2.diff.highlight.added}
|
||||
removedSignColor={themeV2.diff.highlight.removed}
|
||||
lineNumberFg={themeV2.diff.lineNumber.text}
|
||||
lineNumberBg={themeV2.diff.background.context}
|
||||
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
|
||||
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
|
||||
/>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
|
@ -83,7 +76,7 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
when={props.patch}
|
||||
fallback={
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>No diff provided</text>
|
||||
<text fg={themeV2.text.subdued}>No diff provided</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
|
|
@ -93,8 +86,8 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background(),
|
||||
foregroundColor: themeV2.scrollbar(),
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -104,7 +97,7 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={patch()}
|
||||
fg={themeV2.text.subdued()}
|
||||
fg={themeV2.text.subdued}
|
||||
/>
|
||||
</scrollbox>
|
||||
)}
|
||||
|
|
@ -114,27 +107,6 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TextBody(props: { title: string; description?: string; icon?: string }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<Show when={props.icon}>
|
||||
<text fg={themeV2.text.subdued()} flexShrink={0}>
|
||||
{props.icon}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>{props.title}</text>
|
||||
</box>
|
||||
<Show when={props.description}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{props.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
|
|
@ -144,14 +116,16 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
const pathFormatter = usePathFormatter()
|
||||
const session = createMemo(() => data.session.get(props.request.sessionID))
|
||||
|
||||
const input = createMemo(() => {
|
||||
const source = createMemo(() => {
|
||||
const tool = props.request.source
|
||||
if (!tool) return {}
|
||||
if (!tool) return { input: undefined, structured: undefined }
|
||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return {}
|
||||
if (message?.type !== "assistant") return { input: undefined, structured: undefined }
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
|
||||
return {}
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") {
|
||||
return { input: part.state.input, structured: part.state.structured }
|
||||
}
|
||||
return { input: undefined, structured: undefined }
|
||||
})
|
||||
|
||||
const { themeV2 } = useTheme()
|
||||
|
|
@ -164,30 +138,13 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
semanticLabel={`Always allow ${props.request.action}`}
|
||||
instance={props.request.id}
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
|
||||
<TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
This will allow the following patterns until OpenCode is restarted
|
||||
</text>
|
||||
<box>
|
||||
<For each={props.request.save ?? []}>
|
||||
{(pattern) => (
|
||||
<text fg={themeV2.text()}>
|
||||
{"- "}
|
||||
{pattern}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? themeV2.text.subdued : themeV2.text.default}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
options={{ confirm: "Confirm", cancel: "Cancel" }}
|
||||
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
|
||||
escapeKey="cancel"
|
||||
onSelect={(option) => {
|
||||
setStore("stage", "permission")
|
||||
|
|
@ -219,211 +176,60 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
</Match>
|
||||
<Match when={store.stage === "permission"}>
|
||||
{(() => {
|
||||
const info = () => {
|
||||
const permission = props.request.action
|
||||
const data = input()
|
||||
|
||||
if (permission === "edit") {
|
||||
const filepath = props.request.resources[0] ?? ""
|
||||
const patch = typeof data.patchText === "string" ? data.patchText : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||
body: <EditBody request={props.request} patch={patch} />,
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "read") {
|
||||
const raw = data.path
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${pathFormatter.format(filePath)}`,
|
||||
body: (
|
||||
<Show when={filePath}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "glob") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Glob "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "grep") {
|
||||
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `Grep "${pattern}"`,
|
||||
body: (
|
||||
<Show when={pattern}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "list") {
|
||||
const raw = data.path
|
||||
const dir = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${pathFormatter.format(dir)}`,
|
||||
body: (
|
||||
<Show when={dir}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "shell") {
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
return {
|
||||
body: (
|
||||
<Show when={command}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{"$ " + command}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "subagent" || permission === "task") {
|
||||
const agent =
|
||||
typeof data.agent === "string"
|
||||
? data.agent
|
||||
: typeof data.subagent_type === "string"
|
||||
? data.subagent_type
|
||||
: "Unknown"
|
||||
const desc = typeof data.description === "string" ? data.description : ""
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(agent)} Subagent`,
|
||||
body: (
|
||||
<Show when={desc}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text()}>{"◉ " + desc}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "webfetch") {
|
||||
const url = typeof data.url === "string" ? data.url : ""
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
body: (
|
||||
<Show when={url}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"URL: " + url}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "websearch") {
|
||||
const query = typeof data.query === "string" ? data.query : ""
|
||||
return {
|
||||
icon: "◈",
|
||||
title: `${webSearchProviderLabel(data.provider)} "${query}"`,
|
||||
body: (
|
||||
<Show when={query}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Query: " + query}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "external_directory") {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||
const pattern = props.request.resources[0]
|
||||
const derived =
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = pathFormatter.format(raw)
|
||||
const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${dir}`,
|
||||
body: (
|
||||
<Show when={patterns.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>Patterns</text>
|
||||
<box>
|
||||
<For each={patterns}>{(p) => <text fg={themeV2.text()}>{"- " + p}</text>}</For>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>This keeps the session running despite repeated failures.</text>
|
||||
const current = permissionPresentation(
|
||||
{
|
||||
action: props.request.action,
|
||||
resources: props.request.resources,
|
||||
metadata: props.request.metadata,
|
||||
input: source().input,
|
||||
structured: source().structured,
|
||||
},
|
||||
pathFormatter.format,
|
||||
)
|
||||
const presentationBody =
|
||||
props.request.action === "edit" ? (
|
||||
<EditBody file={current.file} diff={current.diff} patch={current.patch} />
|
||||
) : props.request.action === "external_directory" ? (
|
||||
<Show when={current.lines.length > 0}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={themeV2.text.subdued}>Patterns</text>
|
||||
<box>
|
||||
<For each={current.lines}>{(line) => <text fg={themeV2.text.default}>{line}</text>}</For>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${permission}`,
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>{"Tool: " + permission}</text>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const current = info()
|
||||
</Show>
|
||||
) : (
|
||||
<box paddingLeft={1}>
|
||||
<For each={current.lines}>
|
||||
{(line) => (
|
||||
<text
|
||||
fg={
|
||||
props.request.action === "shell" ||
|
||||
props.request.action === "subagent" ||
|
||||
props.request.action === "task"
|
||||
? themeV2.text.default
|
||||
: themeV2.text.subdued
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
|
||||
const header = () => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.feedback.warning()}>{"△"}</text>
|
||||
<text fg={themeV2.text()}>Permission required</text>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Permission required</text>
|
||||
</box>
|
||||
<Show when={current.title}>
|
||||
<Show when={props.request.action !== "shell" && current.title}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()} flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued} flexShrink={0}>
|
||||
{current.icon}
|
||||
</text>
|
||||
<text fg={themeV2.text()}>{current.title}</text>
|
||||
<text fg={themeV2.text.default}>{current.title}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
|
@ -435,11 +241,15 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||
instance={props.request.id}
|
||||
header={header()}
|
||||
body={current.body}
|
||||
body={presentationBody}
|
||||
options={
|
||||
props.request.save?.length
|
||||
? { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
: { once: "Allow once", reject: "Reject" }
|
||||
? {
|
||||
once: permissionOptionLabel("once"),
|
||||
always: permissionOptionLabel("always"),
|
||||
reject: permissionOptionLabel("reject"),
|
||||
}
|
||||
: { once: permissionOptionLabel("once"), reject: permissionOptionLabel("reject") }
|
||||
}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
|
|
@ -519,18 +329,18 @@ function RejectPrompt(props: {
|
|||
role: "dialog",
|
||||
label: `Reject permission: ${props.action}`,
|
||||
}))}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.text.feedback.error()}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={themeV2.text.feedback.error()}>{"△"}</text>
|
||||
<text fg={themeV2.text()}>Reject permission</text>
|
||||
<text fg={themeV2.text.feedback.error.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>Reject permission</text>
|
||||
</box>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={themeV2.text.subdued()}>Tell OpenCode what to do differently</text>
|
||||
<text fg={themeV2.text.subdued}>Tell OpenCode what to do differently</text>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
|
|
@ -540,7 +350,7 @@ function RejectPrompt(props: {
|
|||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.raise(themeV2.background())}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
|
|
@ -559,9 +369,9 @@ function RejectPrompt(props: {
|
|||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused
|
||||
textColor={themeV2.text()}
|
||||
focusedTextColor={themeV2.text()}
|
||||
cursorColor={themeV2.text()}
|
||||
textColor={themeV2.text.default}
|
||||
focusedTextColor={themeV2.text.default}
|
||||
cursorColor={themeV2.text.default}
|
||||
/>
|
||||
<box
|
||||
id="session.permission.reject.actions"
|
||||
|
|
@ -584,8 +394,8 @@ function RejectPrompt(props: {
|
|||
}))}
|
||||
onMouseUp={() => props.onConfirm(input.plainText)}
|
||||
>
|
||||
<text fg={themeV2.text()}>
|
||||
enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
|
|
@ -598,8 +408,8 @@ function RejectPrompt(props: {
|
|||
}))}
|
||||
onMouseUp={props.onCancel}
|
||||
>
|
||||
<text fg={themeV2.text()}>
|
||||
esc <span style={{ fg: themeV2.text.subdued() }}>cancel</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
esc <span style={{ fg: themeV2.text.subdued }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -724,9 +534,9 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
label: props.semanticLabel ?? props.title,
|
||||
expanded: store.expanded,
|
||||
}))}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.background.action("focused")}
|
||||
borderColor={themeV2.background.action.primary.focused}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
|
|
@ -744,8 +554,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
when={props.header}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={themeV2.text.feedback.warning()}>{"△"}</text>
|
||||
<text fg={themeV2.text()}>{props.title}</text>
|
||||
<text fg={themeV2.text.feedback.warning.default}>{"△"}</text>
|
||||
<text fg={themeV2.text.default}>{props.title}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
|
|
@ -763,7 +573,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.raise(themeV2.background())}
|
||||
backgroundColor={themeV2.raise(themeV2.background.default)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
|
|
@ -792,14 +602,24 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
}))}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
|
||||
backgroundColor={
|
||||
option === store.selected
|
||||
? themeV2.background.action.primary.focused
|
||||
: themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setStore("selected", option)}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", option)
|
||||
props.onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
|
||||
<text
|
||||
fg={
|
||||
option === store.selected
|
||||
? themeV2.text.action.primary.focused
|
||||
: themeV2.text.action.primary.default
|
||||
}
|
||||
>
|
||||
{props.options[option]}
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -808,16 +628,15 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
</box>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={themeV2.text()}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")}{" "}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text()}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{"⇆"} <span style={{ fg: themeV2.text.subdued }}>select</span>
|
||||
</text>
|
||||
<text fg={themeV2.text()}>
|
||||
enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
enter <span style={{ fg: themeV2.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
return (
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
|
|
@ -32,8 +32,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: themeV2.background(),
|
||||
foregroundColor: themeV2.scrollbar(),
|
||||
backgroundColor: themeV2.background.default,
|
||||
foregroundColor: themeV2.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -45,11 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
title={session()!.title}
|
||||
>
|
||||
<box paddingRight={1}>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={themeV2.text.subdued()}>{session()!.location.workspaceID}</text>
|
||||
<text fg={themeV2.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</pluginRuntime.Slot>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { SplitBorder } from "../../ui/border"
|
|||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { contextUsage } from "../../util/session"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
|
|
@ -37,11 +37,7 @@ export function SubagentFooter() {
|
|||
)
|
||||
|
||||
return {
|
||||
context: context
|
||||
? context.percent === undefined
|
||||
? Locale.number(context.tokens)
|
||||
: `${Locale.number(context.tokens)} (${context.percent}%)`
|
||||
: undefined,
|
||||
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
|
@ -61,18 +57,18 @@ export function SubagentFooter() {
|
|||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.border()}
|
||||
borderColor={themeV2.border.default}
|
||||
flexShrink={0}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text()}>
|
||||
<text fg={themeV2.text.default}>
|
||||
<b>{subagentInfo()}</b>
|
||||
</text>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={themeV2.text.subdued()} wrapMode="none">
|
||||
<text fg={themeV2.text.subdued} wrapMode="none">
|
||||
{[item().context, item().cost].filter(Boolean).join(" · ")}
|
||||
</text>
|
||||
)}
|
||||
|
|
@ -83,30 +79,36 @@ export function SubagentFooter() {
|
|||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? themeV2.background.action("hovered") : themeV2.background()}
|
||||
backgroundColor={
|
||||
hover() === "parent" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={themeV2.text()}>
|
||||
Parent <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.parent")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Parent <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.parent")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("prev")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.previous")}
|
||||
backgroundColor={hover() === "prev" ? themeV2.background.action("hovered") : themeV2.background()}
|
||||
backgroundColor={
|
||||
hover() === "prev" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={themeV2.text()}>
|
||||
Prev <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.previous")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Prev <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.previous")}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
onMouseOver={() => setHover("next")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatch("session.child.next")}
|
||||
backgroundColor={hover() === "next" ? themeV2.background.action("hovered") : themeV2.background()}
|
||||
backgroundColor={
|
||||
hover() === "next" ? themeV2.background.action.primary.hovered : themeV2.background.default
|
||||
}
|
||||
>
|
||||
<text fg={themeV2.text()}>
|
||||
Next <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.next")}</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
Next <span style={{ fg: themeV2.text.subdued }}>{shortcuts.get("session.child.next")}</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,10 +1 @@
|
|||
import path from "path"
|
||||
|
||||
export function abbreviateHome(input: string, home: string) {
|
||||
if (!home) return input
|
||||
const relative = path.relative(home, input)
|
||||
if (relative === "") return "~"
|
||||
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return input
|
||||
// Normalize to forward slashes so abbreviated display paths are identical across platforms.
|
||||
return "~/" + relative.split(path.sep).join("/")
|
||||
}
|
||||
export { abbreviateHome } from "./util/path-format"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import { DEFAULT_THEMES, type ColorValue, type Theme, type ThemeColor, type ThemeJson } from "./v1"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
|
||||
|
||||
|
|
@ -79,62 +78,11 @@ export function upsertTheme(name: string, theme: unknown) {
|
|||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
if (c instanceof RGBA) return c
|
||||
if (typeof c === "string") {
|
||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||
|
||||
if (chain.includes(c)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[c] ?? theme.theme[c as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, c])
|
||||
}
|
||||
if (typeof c === "number") {
|
||||
return ansiToRgba(c)
|
||||
}
|
||||
return resolveColor(c[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => {
|
||||
return [key, resolveColor(value as ColorValue)]
|
||||
}),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
// Handle selectedListItemText separately since it's optional
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
if (hasSelectedListItemText) {
|
||||
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
|
||||
} else {
|
||||
// Backward compatibility: if selectedListItemText is not defined, use background color
|
||||
// This preserves the current behavior for all existing themes
|
||||
resolved.selectedListItemText = resolved.background
|
||||
}
|
||||
|
||||
// Handle backgroundMenu - optional with fallback to backgroundElement
|
||||
if (theme.theme.backgroundMenu !== undefined) {
|
||||
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
|
||||
} else {
|
||||
resolved.backgroundMenu = resolved.backgroundElement
|
||||
}
|
||||
|
||||
// Handle thinkingOpacity - optional with default of 0.6
|
||||
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
const resolved = resolveThemeColors(theme, mode)
|
||||
return {
|
||||
...resolved,
|
||||
_hasSelectedListItemText: hasSelectedListItemText,
|
||||
thinkingOpacity,
|
||||
} as Theme
|
||||
...resolved.theme,
|
||||
_hasSelectedListItemText: resolved.hasSelectedListItemText,
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
packages/tui/src/theme/resolve.ts
Normal file
51
packages/tui/src/theme/resolve.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1"
|
||||
|
||||
export function resolveThemeColors(
|
||||
theme: ThemeJson,
|
||||
mode: "dark" | "light",
|
||||
resolveAnsi: (code: number) => RGBA = ansiToRgba,
|
||||
) {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(color: ColorValue, chain: string[] = []): RGBA {
|
||||
if (color instanceof RGBA) return color
|
||||
if (typeof color === "string") {
|
||||
if (color === "transparent" || color === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (color.startsWith("#")) return RGBA.fromHex(color)
|
||||
|
||||
if (chain.includes(color)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, color].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[color] ?? theme.theme[color as ThemeColor]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${color}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, color])
|
||||
}
|
||||
if (typeof color === "number") return resolveAnsi(color)
|
||||
return resolveColor(color[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
return {
|
||||
theme: {
|
||||
...(resolved as Record<ThemeColor, RGBA>),
|
||||
selectedListItemText: hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText!)
|
||||
: resolved.background!,
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
} satisfies Omit<Theme, "_hasSelectedListItemText" | "thinkingOpacity">,
|
||||
hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ export type Variant = {
|
|||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
|
|
|
|||
|
|
@ -1,144 +1,40 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type {
|
||||
ActionVariant,
|
||||
Mode,
|
||||
ResolvedActionState,
|
||||
ResolvedThemeView,
|
||||
} from "./index"
|
||||
import { ActionState, HueStep } from "./schema"
|
||||
|
||||
type StateFlags = Partial<Record<ActionState, boolean>>
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
|
||||
const textAction = actions((variant, state) => current().text.action[variant][state])
|
||||
const backgroundAction = actions((variant, state) => current().background.action[variant][state])
|
||||
const textFormfield = formfield((state) => current().text.formfield[state])
|
||||
const backgroundFormfield = formfield((state) => current().background.formfield[state])
|
||||
const hue = {
|
||||
gray: (step: HueStep) => current().hue.gray[step],
|
||||
red: (step: HueStep) => current().hue.red[step],
|
||||
orange: (step: HueStep) => current().hue.orange[step],
|
||||
yellow: (step: HueStep) => current().hue.yellow[step],
|
||||
green: (step: HueStep) => current().hue.green[step],
|
||||
cyan: (step: HueStep) => current().hue.cyan[step],
|
||||
blue: (step: HueStep) => current().hue.blue[step],
|
||||
purple: (step: HueStep) => current().hue.purple[step],
|
||||
accent: (step: HueStep) => current().hue.accent[step],
|
||||
interactive: (step: HueStep) => current().hue.interactive[step],
|
||||
neutral: (step: HueStep) => current().hue.neutral[step],
|
||||
}
|
||||
const text = Object.assign(() => current().text.default, {
|
||||
subdued: () => current().text.subdued,
|
||||
action: textAction,
|
||||
formfield: textFormfield,
|
||||
feedback: {
|
||||
error: feedbackText("error"),
|
||||
warning: feedbackText("warning"),
|
||||
success: feedbackText("success"),
|
||||
info: feedbackText("info"),
|
||||
},
|
||||
})
|
||||
const background = Object.assign(() => current().background.default, {
|
||||
surface: {
|
||||
offset: () => current().background.surface.offset,
|
||||
overlay: () => current().background.surface.overlay,
|
||||
},
|
||||
action: backgroundAction,
|
||||
formfield: backgroundFormfield,
|
||||
feedback: {
|
||||
error: () => current().background.feedback.error.default,
|
||||
warning: () => current().background.feedback.warning.default,
|
||||
success: () => current().background.feedback.success.default,
|
||||
info: () => current().background.feedback.info.default,
|
||||
},
|
||||
})
|
||||
const markdown = Object.assign(() => current().markdown.text, {
|
||||
heading: () => current().markdown.heading,
|
||||
link: () => current().markdown.link,
|
||||
linkText: () => current().markdown.linkText,
|
||||
code: () => current().markdown.code,
|
||||
blockQuote: () => current().markdown.blockQuote,
|
||||
emphasis: () => current().markdown.emphasis,
|
||||
strong: () => current().markdown.strong,
|
||||
horizontalRule: () => current().markdown.horizontalRule,
|
||||
listItem: () => current().markdown.listItem,
|
||||
listEnumeration: () => current().markdown.listEnumeration,
|
||||
image: () => current().markdown.image,
|
||||
imageText: () => current().markdown.imageText,
|
||||
codeBlock: () => current().markdown.codeBlock,
|
||||
})
|
||||
|
||||
function feedbackText(kind: "error" | "warning" | "success" | "info") {
|
||||
return Object.assign(() => current().text.feedback[kind].default, {
|
||||
subdued: () => current().text.feedback[kind].subdued,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
hue,
|
||||
get hue() {
|
||||
return current().hue
|
||||
},
|
||||
get categorical() {
|
||||
return current().categorical
|
||||
},
|
||||
get text() {
|
||||
return current().text
|
||||
},
|
||||
get background() {
|
||||
return current().background
|
||||
},
|
||||
get border() {
|
||||
return current().border
|
||||
},
|
||||
get scrollbar() {
|
||||
return current().scrollbar
|
||||
},
|
||||
get diff() {
|
||||
return current().diff
|
||||
},
|
||||
get syntax() {
|
||||
return current().syntax
|
||||
},
|
||||
get markdown() {
|
||||
return current().markdown
|
||||
},
|
||||
source: (color: RGBA) => current().source(color),
|
||||
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
|
||||
text,
|
||||
background,
|
||||
border: () => current().border.default,
|
||||
scrollbar: () => current().scrollbar.default,
|
||||
diff: {
|
||||
text: {
|
||||
added: () => current().diff.text.added,
|
||||
removed: () => current().diff.text.removed,
|
||||
context: () => current().diff.text.context,
|
||||
hunkHeader: () => current().diff.text.hunkHeader,
|
||||
},
|
||||
background: {
|
||||
added: () => current().diff.background.added,
|
||||
removed: () => current().diff.background.removed,
|
||||
context: () => current().diff.background.context,
|
||||
},
|
||||
highlight: {
|
||||
added: () => current().diff.highlight.added,
|
||||
removed: () => current().diff.highlight.removed,
|
||||
},
|
||||
lineNumber: {
|
||||
text: () => current().diff.lineNumber.text,
|
||||
background: {
|
||||
added: () => current().diff.lineNumber.background.added,
|
||||
removed: () => current().diff.lineNumber.background.removed,
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: () => current().syntax.comment,
|
||||
keyword: () => current().syntax.keyword,
|
||||
function: () => current().syntax.function,
|
||||
variable: () => current().syntax.variable,
|
||||
string: () => current().syntax.string,
|
||||
number: () => current().syntax.number,
|
||||
type: () => current().syntax.type,
|
||||
operator: () => current().syntax.operator,
|
||||
punctuation: () => current().syntax.punctuation,
|
||||
},
|
||||
markdown,
|
||||
}
|
||||
}
|
||||
|
||||
function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
|
||||
const primary = stateful((state) => get("primary", state))
|
||||
return Object.assign(primary, {
|
||||
destructive: stateful((state) => get("destructive", state)),
|
||||
})
|
||||
}
|
||||
|
||||
function formfield(get: (state: ResolvedActionState) => RGBA) {
|
||||
return stateful(get)
|
||||
}
|
||||
|
||||
function stateful(get: (state: ResolvedActionState) => RGBA) {
|
||||
return (states: ActionState | "default" | StateFlags = "default") => {
|
||||
if (typeof states === "string") return get(states)
|
||||
return get(ActionState.literals.find((state) => states[state]) ?? "default")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import type { ThemeFile } from "./index"
|
||||
import type { HueName, ThemeFile } from "./schema"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
|
|
@ -96,19 +105,20 @@ export const DEFAULT_THEME = {
|
|||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.900",
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.900",
|
||||
default: "$hue.neutral.800",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.600",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
|
|
@ -118,10 +128,10 @@ export const DEFAULT_THEME = {
|
|||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.100",
|
||||
default: "$hue.neutral.200",
|
||||
surface: {
|
||||
offset: "$hue.neutral.200",
|
||||
overlay: "$hue.neutral.300",
|
||||
offset: "$hue.neutral.300",
|
||||
overlay: "$hue.neutral.400",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
|
|
@ -308,17 +318,18 @@ export const DEFAULT_THEME = {
|
|||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.100",
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.100",
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
|
|
@ -330,10 +341,10 @@ export const DEFAULT_THEME = {
|
|||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.900",
|
||||
default: "$hue.neutral.800",
|
||||
surface: {
|
||||
offset: "$hue.neutral.800",
|
||||
overlay: "$hue.neutral.700",
|
||||
offset: "$hue.neutral.700",
|
||||
overlay: "$hue.neutral.600",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
|
|
@ -412,14 +423,14 @@ export const DEFAULT_THEME = {
|
|||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.overlay",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ export {
|
|||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
MarkdownToken,
|
||||
|
|
@ -30,6 +32,7 @@ export {
|
|||
} from "./schema"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
|
|
@ -40,4 +43,6 @@ export type {
|
|||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types"
|
||||
export { DEFAULT_CATEGORICAL } from "./defaults"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand"
|
||||
import { fallback } from "./fallback"
|
||||
import {
|
||||
|
|
@ -56,11 +56,12 @@ export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name
|
|||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = decoded.standalone
|
||||
? mergeTheme(core, definition)
|
||||
: mergeTheme(core, defaults, definition)
|
||||
const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme(merged as ThemeDefinition)
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
|
|
@ -69,15 +70,16 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
|||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue, hueSteps)
|
||||
const resolved = resolveView(base, hue, categorical, hueSteps)
|
||||
const contexts = Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, override]) => {
|
||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
||||
return [key, resolveView(contextual, hue, hueSteps)]
|
||||
return [key, resolveView(contextual, hue, categorical, hueSteps)]
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -141,15 +143,14 @@ function contextualActions(
|
|||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeView["hue"],
|
||||
categorical: ResolvedThemeView["categorical"],
|
||||
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
|
||||
): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
hue: ResolvedThemeView["hue"],
|
||||
): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
|
|
@ -201,7 +202,8 @@ function resolveHue(definition: HueDefinition) {
|
|||
}),
|
||||
) as HueScale
|
||||
for (const step of Object.keys(value)) {
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep))
|
||||
throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
}
|
||||
cache.set(name, result)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ const ColorValue = Schema.Union([
|
|||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export type HueName = Schema.Schema.Type<typeof HueName>
|
||||
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
|
||||
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
|
|
@ -137,15 +140,35 @@ const BackgroundDefinition = Schema.Struct({
|
|||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||
|
||||
export const SyntaxToken = Schema.Literals([
|
||||
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
|
||||
"comment",
|
||||
"keyword",
|
||||
"function",
|
||||
"variable",
|
||||
"string",
|
||||
"number",
|
||||
"type",
|
||||
"operator",
|
||||
"punctuation",
|
||||
])
|
||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||
|
||||
export const MarkdownToken = Schema.Literals([
|
||||
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
|
||||
"listEnumeration", "image", "imageText", "codeBlock",
|
||||
"text",
|
||||
"heading",
|
||||
"link",
|
||||
"linkText",
|
||||
"code",
|
||||
"blockQuote",
|
||||
"emphasis",
|
||||
"strong",
|
||||
"horizontalRule",
|
||||
"listItem",
|
||||
"listEnumeration",
|
||||
"image",
|
||||
"imageText",
|
||||
"codeBlock",
|
||||
])
|
||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||
|
|
@ -194,6 +217,7 @@ export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinit
|
|||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
@ -203,6 +227,7 @@ export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
|||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
@ -212,6 +237,7 @@ export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
|||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
@ -225,5 +251,8 @@ const FileMetadata = {
|
|||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
|
||||
export const ThemeFile = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
])
|
||||
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||
|
|
@ -21,17 +21,31 @@ export function selectThemeMode(
|
|||
file: ThemeFile,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(file)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = file[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
const selected = file[mode]
|
||||
if (!merges(selected)) return { theme: selected, mode, expanded: false }
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = mode === "light" ? "dark" : "light"
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = file[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode, expanded: true }
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition): definition is MergeModeDefinition {
|
||||
return "mergeMode" in definition && definition.mergeMode === true
|
||||
export function themeModes(file: ThemeFile): readonly Mode[] {
|
||||
if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(file: ThemeFile, mode: Mode) {
|
||||
return themeModes(file).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ export type ResolvedFormfieldState = ResolvedActionState
|
|||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly categorical: Categorical
|
||||
readonly source: (color: RGBA) => HueSource | undefined
|
||||
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||
|
|
|
|||
|
|
@ -1,44 +1,69 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import type { ThemeFile } from "./index"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
||||
|
||||
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||
const light = resolveV1(theme, "light")
|
||||
const dark = resolveV1(theme, "dark")
|
||||
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||
const lightMode = detectMode(light)
|
||||
const darkMode = detectMode(dark)
|
||||
if (lightMode === darkMode) {
|
||||
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
light: migrateMode(resolveV1(theme, "light"), "light"),
|
||||
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
|
||||
light: migrateMode(light, "light"),
|
||||
dark: migrateMode(dark, "dark"),
|
||||
}
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
||||
function detectMode(theme: Theme): Mode {
|
||||
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
const hues = inferHues(theme, mode)
|
||||
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
|
||||
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
|
||||
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
|
||||
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
|
||||
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const categorical = categoricalTokens.flatMap((token) => {
|
||||
const hue = hues.byToken[token]
|
||||
return hue ? [hue] : []
|
||||
})
|
||||
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
|
||||
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
|
||||
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
|
||||
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
|
||||
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
||||
|
||||
return {
|
||||
hue: {
|
||||
gray: neutralScale(theme, mode),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
const match = hues[name]
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
}),
|
||||
),
|
||||
|
|
@ -46,6 +71,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: text,
|
||||
subdued: textMuted,
|
||||
|
|
@ -80,7 +106,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||
overlay: backgroundMenu,
|
||||
},
|
||||
action: {
|
||||
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: primary },
|
||||
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
|
||||
destructive: { default: color("error") },
|
||||
},
|
||||
formfield: {
|
||||
|
|
@ -154,22 +180,45 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
|
||||
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
>((result, color) => {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return result
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
const nearest = chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
const current = result[nearest.name]
|
||||
if (current && current.distance <= nearest.distance) return result
|
||||
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
|
||||
}, {})
|
||||
const colors: readonly [V1HueToken, RGBA][] = [
|
||||
["accent", theme.accent],
|
||||
["success", theme.success],
|
||||
["warning", theme.warning],
|
||||
["primary", theme.primary],
|
||||
["error", theme.error],
|
||||
["info", theme.info],
|
||||
["secondary", theme.secondary],
|
||||
]
|
||||
return colors.reduce<{
|
||||
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
(result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
const current = result.byHue[nearest.name]
|
||||
return {
|
||||
byHue:
|
||||
current && current.distance <= nearest.distance
|
||||
? result.byHue
|
||||
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
},
|
||||
{ byHue: {}, byToken: {} },
|
||||
)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number): HueStep {
|
||||
|
|
@ -233,13 +282,13 @@ function selectedForeground(theme: Theme, background: RGBA) {
|
|||
|
||||
function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||
const value = toOklch(color)
|
||||
const anchor = mode === "light" ? 900 : 100
|
||||
const anchor = mode === "light" ? 800 : 200
|
||||
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
||||
const alpha = color.toInts()[3]
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
if (step === anchor) return [step, hex(color)]
|
||||
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
|
||||
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
|
||||
const generated = oklchToHex({
|
||||
l: value.l + (endpoint - value.l) * progress,
|
||||
c: value.c * (1 - progress * 0.5),
|
||||
|
|
@ -256,8 +305,14 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
|
|||
HueStep.literals.map((step) => {
|
||||
const exact = anchors.find((anchor) => anchor.step === step)
|
||||
if (exact) return [step, hex(exact.color)]
|
||||
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
|
||||
const upper = anchors.find((anchor) => anchor.step > step)!
|
||||
const first = anchors[0]!
|
||||
const last = anchors.at(-1)!
|
||||
const [lower, upper] =
|
||||
step < first.step
|
||||
? [first, anchors[1]!]
|
||||
: step > last.step
|
||||
? [anchors.at(-2)!, last]
|
||||
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, anchors.find((anchor) => anchor.step > step)!]
|
||||
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
||||
}),
|
||||
) as Record<HueStep, string>
|
||||
|
|
@ -265,11 +320,11 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
|
|||
|
||||
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
const light: { step: HueStep; color: RGBA }[] = [
|
||||
{ step: 100, color: theme.background },
|
||||
{ step: 200, color: theme.backgroundPanel },
|
||||
{ step: 300, color: theme.backgroundElement || theme.backgroundMenu },
|
||||
{ step: 700, color: theme.textMuted },
|
||||
{ step: 900, color: theme.text },
|
||||
{ step: 200, color: theme.background },
|
||||
{ step: 300, color: theme.backgroundPanel },
|
||||
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
|
||||
{ step: 600, color: theme.textMuted },
|
||||
{ step: 800, color: theme.text },
|
||||
]
|
||||
if (mode === "light") return light
|
||||
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
||||
|
|
@ -278,13 +333,18 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
|||
function interpolate(first: RGBA, second: RGBA, amount: number) {
|
||||
const start = toOklch(first)
|
||||
const end = toOklch(second)
|
||||
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
|
||||
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
|
||||
const endHue = Number.isFinite(end.h) ? end.h : startHue
|
||||
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
|
||||
const generated = oklchToHex({
|
||||
l: start.l + (end.l - start.l) * amount,
|
||||
c: start.c + (end.c - start.c) * amount,
|
||||
h: start.h + hue * amount,
|
||||
h: startHue + hue * amount,
|
||||
})
|
||||
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
|
||||
const alpha = Math.max(
|
||||
0,
|
||||
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
|
||||
)
|
||||
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,27 +30,27 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action("focused")}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
onMouseUp={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.action("focused")}>ok</text>
|
||||
<text fg={themeV2.text.action.primary.focused}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -57,15 +57,15 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={themeV2.text.subdued}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"] as const}>
|
||||
|
|
@ -73,14 +73,14 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? themeV2.background.action("focused") : undefined}
|
||||
backgroundColor={key === store.active ? themeV2.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>
|
||||
<text fg={key === store.active ? themeV2.text.action.primary.focused : themeV2.text.subdued}>
|
||||
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
|
||||
</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -76,32 +76,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Export session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text()}>Export as:</text>
|
||||
<text fg={themeV2.text.default}>Export as:</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={["markdown", "json"] as const}>
|
||||
{(format) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === format,
|
||||
selected: store.format === format,
|
||||
})}
|
||||
backgroundColor={
|
||||
store.active === format
|
||||
? themeV2.background.formfield.focused
|
||||
: store.format === format
|
||||
? themeV2.background.formfield.selected
|
||||
: themeV2.background.formfield.default
|
||||
}
|
||||
onMouseUp={() => selectFormat(format)}
|
||||
>
|
||||
<text
|
||||
fg={themeV2.text.formfield({
|
||||
focused: store.active === format,
|
||||
selected: store.format === format,
|
||||
})}
|
||||
fg={
|
||||
store.active === format
|
||||
? themeV2.text.formfield.focused
|
||||
: store.format === format
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
|
||||
</text>
|
||||
|
|
@ -114,19 +120,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === "thinking",
|
||||
selected: store.thinking,
|
||||
})}
|
||||
backgroundColor={
|
||||
store.active === "thinking"
|
||||
? themeV2.background.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.background.formfield.selected
|
||||
: themeV2.background.formfield.default
|
||||
}
|
||||
onMouseUp={() => {
|
||||
setStore("active", "thinking")
|
||||
setStore("thinking", !store.thinking)
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
|
||||
<text
|
||||
fg={
|
||||
store.active === "thinking"
|
||||
? themeV2.text.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
{store.thinking ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
|
||||
<text
|
||||
fg={
|
||||
store.active === "thinking"
|
||||
? themeV2.text.formfield.focused
|
||||
: store.thinking
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
Include thinking
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -135,19 +160,38 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === "debug",
|
||||
selected: store.debug,
|
||||
})}
|
||||
backgroundColor={
|
||||
store.active === "debug"
|
||||
? themeV2.background.formfield.focused
|
||||
: store.debug
|
||||
? themeV2.background.formfield.selected
|
||||
: themeV2.background.formfield.default
|
||||
}
|
||||
onMouseUp={() => {
|
||||
setStore("active", "debug")
|
||||
setStore("debug", !store.debug)
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
|
||||
<text
|
||||
fg={
|
||||
store.active === "debug"
|
||||
? themeV2.text.formfield.focused
|
||||
: store.debug
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
{store.debug ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
|
||||
<text
|
||||
fg={
|
||||
store.active === "debug"
|
||||
? themeV2.text.formfield.focused
|
||||
: store.debug
|
||||
? themeV2.text.formfield.selected
|
||||
: themeV2.text.formfield.default
|
||||
}
|
||||
>
|
||||
Events (debug)
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -156,18 +200,26 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
<box
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
onMouseUp={() => confirm("copy")}
|
||||
>
|
||||
<text fg={overlayTheme.text()}>Copy</text>
|
||||
<text fg={overlayTheme.text.default}>Copy</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
backgroundColor={themeV2.background.action({ focused: store.active === "export" })}
|
||||
backgroundColor={
|
||||
store.active === "export"
|
||||
? themeV2.background.action.primary.focused
|
||||
: themeV2.background.action.primary.default
|
||||
}
|
||||
onMouseUp={() => confirm("export")}
|
||||
>
|
||||
<text fg={themeV2.text.action({ focused: store.active === "export" })}>Export</text>
|
||||
<text
|
||||
fg={store.active === "export" ? themeV2.text.action.primary.focused : themeV2.text.action.primary.default}
|
||||
>
|
||||
Export
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -27,19 +27,24 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Session exported
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={close}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={themeV2.text()}>{props.path}</text>
|
||||
<text fg={themeV2.text.default}>{props.path}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box paddingLeft={3} paddingRight={3} backgroundColor={themeV2.background.action("focused")} onMouseUp={close}>
|
||||
<text fg={themeV2.text.action("focused")}>Close</text>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={themeV2.text.action.primary.focused}>Close</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -19,15 +19,15 @@ export function DialogHelp() {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
Help
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc/enter
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={themeV2.text.subdued()}>
|
||||
<text fg={themeV2.text.subdued}>
|
||||
Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context.
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -35,10 +35,10 @@ export function DialogHelp() {
|
|||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={themeV2.background.action("focused")}
|
||||
backgroundColor={themeV2.background.action.primary.focused}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
>
|
||||
<text fg={themeV2.text.action("focused")}>ok</text>
|
||||
<text fg={themeV2.text.action.primary.focused}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
|
|
@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
textColor={themeV2.text.formfield({ disabled: props.busy })}
|
||||
focusedTextColor={themeV2.text.formfield({ disabled: props.busy })}
|
||||
cursorColor={props.busy ? themeV2.background.formfield("disabled") : themeV2.text()}
|
||||
placeholderColor={themeV2.text.subdued}
|
||||
textColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
|
||||
focusedTextColor={props.busy ? themeV2.text.formfield.disabled : themeV2.text.formfield.default}
|
||||
cursorColor={props.busy ? themeV2.background.formfield.disabled : themeV2.text.default}
|
||||
/>
|
||||
<Show when={props.busy}>
|
||||
<Spinner color={themeV2.text.subdued()}>{props.busyText ?? "Working..."}</Spinner>
|
||||
<Spinner color={themeV2.text.subdued}>{props.busyText ?? "Working..."}</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued()}>processing...</text>}>
|
||||
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued}>processing...</text>}>
|
||||
<Show when={shortcuts.get("dialog.prompt.submit")}>
|
||||
<text fg={themeV2.text()}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued() }}>submit</span>
|
||||
<text fg={themeV2.text.default}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued }}>submit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useDialog, type DialogContext } from "./dialog"
|
|||
import { Locale } from "../util/locale"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useConfig } from "../config"
|
||||
import { moveSelection, reconcileSelection } from "./select-controller"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
|
|
@ -221,8 +222,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection) {
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next >= 0 && next !== store.selected) setStore("selected", next)
|
||||
const count = flat().length
|
||||
if (count === 0) return
|
||||
const next = reconcileSelection(store.selected, count)
|
||||
if (next !== store.selected) setStore("selected", next)
|
||||
return
|
||||
}
|
||||
if (resetSelection && store.filter.length > 0) {
|
||||
|
|
@ -266,8 +269,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
})
|
||||
return
|
||||
}
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next < 0) return
|
||||
const next = reconcileSelection(store.selected, flat().length)
|
||||
if (flat().length === 0) return
|
||||
setStore("selected", next)
|
||||
selection = flat()[next]
|
||||
},
|
||||
|
|
@ -296,10 +299,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
function move(direction: number) {
|
||||
if (props.locked) return
|
||||
if (flat().length === 0) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next, true)
|
||||
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false, preserve = true) {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { useTheme } from "../context/theme"
|
|||
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { Flag } from "@opencode-ai/util/flag"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
|
|
@ -59,7 +59,7 @@ export function Dialog(
|
|||
}}
|
||||
width={width()}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
|
|
@ -197,6 +197,8 @@ export function DialogProvider(props: ParentProps) {
|
|||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
const config = useConfig()
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
|
|
@ -216,14 +218,14 @@ export function DialogProvider(props: ParentProps) {
|
|||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copySelection()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
|
||||
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
|
|
|
|||
38
packages/tui/src/ui/select-controller.ts
Normal file
38
packages/tui/src/ui/select-controller.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export function reconcileSelection(selected: number, count: number) {
|
||||
return Math.max(0, Math.min(count - 1, selected))
|
||||
}
|
||||
|
||||
export function moveSelection(selected: number, input: { count: number; delta: number; policy: "clamp" | "wrap" }) {
|
||||
if (input.count <= 0) return 0
|
||||
const next = selected + input.delta
|
||||
if (input.policy === "clamp") return reconcileSelection(next, input.count)
|
||||
if (next < 0) return input.count - 1
|
||||
if (next >= input.count) return 0
|
||||
return next
|
||||
}
|
||||
|
||||
export function revealSelectionOffset(offset: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < offset) return Math.min(max, input.selected)
|
||||
if (input.selected >= offset + input.limit) return Math.min(max, input.selected - input.limit + 1)
|
||||
return Math.max(0, Math.min(max, offset))
|
||||
}
|
||||
|
||||
export function moveSelectionOffset(
|
||||
offset: number,
|
||||
input: { count: number; limit: number; selected: number; direction: -1 | 1 },
|
||||
) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = Math.max(0, Math.min(2, Math.floor((input.limit - 1) / 2)))
|
||||
if (input.direction < 0 && input.selected < offset + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
if (input.direction > 0 && input.selected > offset + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
return Math.max(0, Math.min(max, offset))
|
||||
}
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
|
|
@ -31,17 +31,17 @@ export function Toast() {
|
|||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.background()}
|
||||
borderColor={themeV2.text.feedback[current().variant]()}
|
||||
backgroundColor={themeV2.background.default}
|
||||
borderColor={themeV2.text.feedback[current().variant].default}
|
||||
border={["left", "right"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<Show when={current().title}>
|
||||
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text.default}>
|
||||
{current().title}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={themeV2.text()} wrapMode="word" width="100%">
|
||||
<text fg={themeV2.text.default} wrapMode="word" width="100%">
|
||||
{current().message}
|
||||
</text>
|
||||
</box>
|
||||
|
|
|
|||
146
packages/tui/src/util/form.ts
Normal file
146
packages/tui/src/util/form.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
|
||||
export type FormAnswerField = Exclude<FormField, { type: "external" }>
|
||||
|
||||
export type FormRow = {
|
||||
value: string | boolean
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function isFormAnswerField(field: FormField): field is FormAnswerField {
|
||||
return field.type !== "external"
|
||||
}
|
||||
|
||||
export function formLabel(field: FormField) {
|
||||
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||
}
|
||||
|
||||
export function formInitialValues(fields: ReadonlyArray<FormField>) {
|
||||
return {
|
||||
answers: Object.fromEntries(
|
||||
fields.flatMap((field) =>
|
||||
isFormAnswerField(field) && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
) as Record<string, FormValue | undefined>,
|
||||
custom: Object.fromEntries(
|
||||
fields.flatMap((field) => {
|
||||
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||
if (field.options.some((option) => option.value === field.default)) return []
|
||||
return [[field.key, field.default]]
|
||||
}),
|
||||
) as Record<string, string>,
|
||||
}
|
||||
}
|
||||
|
||||
export function formTextual(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
return field.type === "number" || field.type === "integer" || (field.type === "string" && !field.options)
|
||||
}
|
||||
|
||||
export function formCustom(field: FormField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "string" && field.options) return field.custom === true
|
||||
return field.type === "multiselect" && field.custom === true
|
||||
}
|
||||
|
||||
export function formRows(field: FormField | undefined): FormRow[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
]
|
||||
const options = field.type === "multiselect" ? field.options : field.type === "string" ? field.options : undefined
|
||||
if (!options) return []
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formSelected(field: FormField | undefined, value: FormValue | undefined) {
|
||||
if (!field || value === undefined || Array.isArray(value)) return 0
|
||||
const rows = formRows(field)
|
||||
const index = rows.findIndex((row) => row.value === value)
|
||||
if (index !== -1) return index
|
||||
if (typeof value === "string" && formCustom(field)) return rows.length
|
||||
return 0
|
||||
}
|
||||
|
||||
export function formValidateValue(field: FormAnswerField, value: FormValue | undefined): string | undefined {
|
||||
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0)))
|
||||
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return "Expected text"
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return `Must match pattern: ${field.pattern}`
|
||||
} catch {
|
||||
return `Invalid pattern: ${field.pattern}`
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "uri" && !validURL(value)) return "Expected a URL"
|
||||
if (field.format === "date" && !validDate(value)) return "Expected a date (YYYY-MM-DD)"
|
||||
if (field.format === "date-time" && Number.isNaN(new Date(value).getTime())) return "Expected a date and time"
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value))
|
||||
return "Select an available option"
|
||||
return
|
||||
}
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return
|
||||
}
|
||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||
if (!Array.isArray(value)) return "Expected selections"
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item)))
|
||||
return "Select only available options"
|
||||
}
|
||||
|
||||
export function formDisplayValue(field: FormAnswerField, value: FormValue | undefined, emptyMultiselect: string) {
|
||||
if (value === undefined) return ""
|
||||
const label = (item: string | number | boolean) =>
|
||||
formRows(field).find((row) => row.value === item)?.label ?? String(item)
|
||||
if (Array.isArray(value)) return value.length === 0 ? emptyMultiselect : value.map(label).join(", ")
|
||||
return label(value)
|
||||
}
|
||||
|
||||
export function formToggleMultiselect(value: FormValue | undefined, item: string) {
|
||||
const values = Array.isArray(value) ? value : []
|
||||
const index = values.indexOf(item)
|
||||
return index === -1 ? [...values, item] : values.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
export function formSetMultiselectCustom(value: FormValue | undefined, previous: string | undefined, next: string) {
|
||||
const values = Array.isArray(value) ? value : []
|
||||
const index = previous ? values.indexOf(previous) : -1
|
||||
const current = index === -1 ? [...values] : values.toSpliced(index, 1)
|
||||
return next && !current.includes(next) ? [...current, next] : current
|
||||
}
|
||||
|
||||
function validURL(value: string) {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
37
packages/tui/src/util/path-format.ts
Normal file
37
packages/tui/src/util/path-format.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import path from "path"
|
||||
|
||||
export function abbreviateHome(input: string, home: string) {
|
||||
if (!home) return input
|
||||
const paths = windowsPath(home) ? path.win32 : path.posix
|
||||
const relative = paths.relative(home, input)
|
||||
if (!relative) return "~"
|
||||
if (relative === ".." || relative.startsWith(".." + paths.sep) || paths.isAbsolute(relative)) return input
|
||||
return "~/" + relative.split(paths.sep).join("/")
|
||||
}
|
||||
|
||||
export function formatPath(
|
||||
input: string | undefined,
|
||||
options: { base: string; home?: string; forwardSlashes?: boolean },
|
||||
) {
|
||||
if (!input) return ""
|
||||
const windows = windowsPath(options.base)
|
||||
if (!windows && windowsPath(input)) {
|
||||
return options.forwardSlashes ? input.replaceAll("\\", "/") : input
|
||||
}
|
||||
|
||||
const paths = windows ? path.win32 : path.posix
|
||||
const absolute = paths.isAbsolute(input) ? input : paths.resolve(options.base, input)
|
||||
const relative = paths.relative(options.base, absolute)
|
||||
const formatted = !relative
|
||||
? "."
|
||||
: relative !== ".." && !relative.startsWith(".." + paths.sep) && !paths.isAbsolute(relative)
|
||||
? relative
|
||||
: options.home
|
||||
? abbreviateHome(absolute, options.home)
|
||||
: absolute
|
||||
return options.forwardSlashes ? formatted.replaceAll("\\", "/") : formatted
|
||||
}
|
||||
|
||||
function windowsPath(input: string) {
|
||||
return /^[A-Za-z]:[\\/]/.test(input) || input.startsWith("\\\\")
|
||||
}
|
||||
188
packages/tui/src/util/permission.ts
Normal file
188
packages/tui/src/util/permission.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { Locale } from "./locale"
|
||||
import { canonicalToolName, finiteNumber, webSearchProviderLabel } from "./tool-display"
|
||||
|
||||
type Dict = Record<string, unknown>
|
||||
|
||||
export type PermissionPresentation = {
|
||||
icon: string
|
||||
title: string
|
||||
lines: string[]
|
||||
diff?: string
|
||||
patch?: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type PermissionPresentationInput = {
|
||||
action: string
|
||||
resources: ReadonlyArray<unknown>
|
||||
metadata?: unknown
|
||||
input?: unknown
|
||||
structured?: unknown
|
||||
}
|
||||
|
||||
export function permissionPresentation(
|
||||
source: PermissionPresentationInput,
|
||||
formatPath: (value: string) => string = (value) => value,
|
||||
): PermissionPresentation {
|
||||
const action = canonicalToolName(source.action)
|
||||
const input = normalizeInput(action, source.input)
|
||||
const metadata = { ...dict(source.structured), ...dict(source.metadata) }
|
||||
const resources = source.resources.filter((item): item is string => typeof item === "string")
|
||||
|
||||
if (action === "edit") {
|
||||
const file = text(input.path) || resources[0] || ""
|
||||
const first = dict(Array.isArray(metadata.files) ? metadata.files[0] : undefined)
|
||||
const diff = text(first.patch) || text(first.diff) || text(metadata.diff) || undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${formatPath(file)}`,
|
||||
lines: [],
|
||||
diff,
|
||||
patch: diff ? undefined : text(input.patchText) || undefined,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "read" || action === "list") {
|
||||
const value = text(input.path) || resources[0] || ""
|
||||
const title = action === "read" ? "Read" : "List"
|
||||
return {
|
||||
icon: "→",
|
||||
title: `${title} ${formatPath(value)}`,
|
||||
lines: value ? [`Path: ${formatPath(value)}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "glob" || action === "grep") {
|
||||
const pattern = text(input.pattern) || resources[0] || ""
|
||||
const title = action === "glob" ? "Glob" : "Grep"
|
||||
return {
|
||||
icon: "✱",
|
||||
title: `${title} "${pattern}"`,
|
||||
lines: pattern ? [`Pattern: ${pattern}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "shell") {
|
||||
const command = text(input.command)
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Shell command",
|
||||
lines: command ? [`$ ${command}`] : resources.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "subagent") {
|
||||
const agent = text(input.agent) || "general"
|
||||
const description = text(input.description)
|
||||
return {
|
||||
icon: "#",
|
||||
title: `${Locale.titlecase(agent)} Subagent`,
|
||||
lines: description ? [`◉ ${description}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "webfetch") {
|
||||
const url = text(input.url) || text(metadata.url)
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
lines: url ? [`URL: ${url}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "websearch") {
|
||||
const query = text(input.query) || text(metadata.query)
|
||||
const title = webSearchProviderLabel(metadata.provider)
|
||||
return {
|
||||
icon: "◈",
|
||||
title: query ? `${title} "${query}"` : title,
|
||||
lines: query ? [`Query: ${query}`] : [],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "lsp") {
|
||||
const file = text(input.path)
|
||||
const operation = text(input.operation) || "request"
|
||||
const line = finiteNumber(input.line)
|
||||
const character = finiteNumber(input.character)
|
||||
const position = line !== undefined && character !== undefined ? `${line}:${character}` : undefined
|
||||
return {
|
||||
icon: "→",
|
||||
title: `LSP ${operation}${file ? ` ${formatPath(file)}${position ? `:${position}` : ""}` : ""}`,
|
||||
lines: [
|
||||
...(input.operation ? [`Operation: ${operation}`] : []),
|
||||
...(file ? [`Path: ${formatPath(file)}`] : []),
|
||||
...(position ? [`Position: ${position}`] : []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "external_directory") {
|
||||
const raw = text(metadata.parentDir) || text(metadata.filepath) || resources[0] || ""
|
||||
const directory = wildcardDirectory(raw)
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Access external directory ${formatPath(directory)}`,
|
||||
lines: resources.map((item) => `- ${item}`),
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
lines: ["This keeps the session running despite repeated failures."],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${source.action}`,
|
||||
lines: [`Tool: ${source.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardDirectory(value: string) {
|
||||
const wildcard = value.indexOf("*")
|
||||
if (wildcard === -1) return value
|
||||
const prefix = value.slice(0, wildcard)
|
||||
if (/^[\\/]+$/.test(prefix) || /^[A-Za-z]:[\\/]$/.test(prefix)) return prefix
|
||||
return prefix.replace(/[\\/]+$/, "")
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(input: { action: string; save?: ReadonlyArray<string> }): string[] {
|
||||
const save = input.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${input.action} until OpenCode is restarted.`]
|
||||
}
|
||||
return ["This will allow the following patterns until OpenCode is restarted.", ...save.map((item) => `- ${item}`)]
|
||||
}
|
||||
|
||||
export function permissionOptionLabel(option: "once" | "always" | "reject" | "confirm" | "cancel") {
|
||||
if (option === "once") return "Allow once"
|
||||
if (option === "always") return "Allow always"
|
||||
if (option === "reject") return "Reject"
|
||||
if (option === "confirm") return "Confirm"
|
||||
return "Cancel"
|
||||
}
|
||||
|
||||
function normalizeInput(action: string, value: unknown): Dict {
|
||||
const input = dict(value)
|
||||
const path = text(input.path) || text(input.filePath) || text(input.filepath)
|
||||
const agent = text(input.agent) || text(input.subagent_type)
|
||||
return {
|
||||
...input,
|
||||
...(["read", "edit", "list", "lsp"].includes(action) && path ? { path } : {}),
|
||||
...(action === "subagent" && agent ? { agent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function dict(value: unknown): Dict {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
return value as Dict
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ export function sessionEpilogue(input: { title: string; sessionID?: string }) {
|
|||
...wordmark(" "),
|
||||
"",
|
||||
` ${weak("Session")}${bold}${input.title}${reset}`,
|
||||
` ${weak("Continue")}${bold}opencode -s ${input.sessionID}${reset}`,
|
||||
` ${weak("Continue")}${bold}opencode2 -s ${input.sessionID}${reset}`,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Locale } from "./locale"
|
||||
|
||||
export function isDefaultTitle(title: string) {
|
||||
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
|
|
@ -33,3 +34,8 @@ export function contextUsage(
|
|||
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContextUsage(tokens: number, percent?: number) {
|
||||
const value = Locale.number(tokens)
|
||||
return percent === undefined ? value : `${value} (${percent}%)`
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue