Permission rework (#6319)
Co-authored-by: Github Action <action@github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "../../../provider/provider"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Wildcard } from "../../../util/wildcard"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -25,27 +22,7 @@ export const AgentCommand = cmd({
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const resolvedTools = await resolveTools(agent)
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
toolOverrides: agent.tools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function resolveTools(agent: Agent.Info) {
|
||||
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
|
||||
const toolOverrides = {
|
||||
...agent.tools,
|
||||
...(await ToolRegistry.enabled(agent)),
|
||||
}
|
||||
const availableTools = await ToolRegistry.tools(providerID, agent)
|
||||
const resolved: Record<string, boolean> = {}
|
||||
for (const tool of availableTools) {
|
||||
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === "permission.updated") {
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
const result = await select({
|
||||
message: `Permission required to run: ${permission.title}`,
|
||||
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
|
||||
options: [
|
||||
{ value: "once", label: "Allow once" },
|
||||
{ value: "always", label: "Always allow" },
|
||||
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
|
||||
{ value: "reject", label: "Reject" },
|
||||
],
|
||||
initialValue: "once",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { Global } from "@/global"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: PromptRef) => void
|
||||
@@ -373,7 +374,8 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.focus()
|
||||
if (props.visible !== false) input?.focus()
|
||||
if (props.visible === false) input?.blur()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -798,7 +800,7 @@ export function Prompt(props: PromptProps) {
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [agentStore, setAgentStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
|
||||
current: agents()[0].name,
|
||||
})
|
||||
const { theme } = useTheme()
|
||||
const colors = createMemo(() => [
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Config,
|
||||
Todo,
|
||||
Command,
|
||||
Permission,
|
||||
PermissionRequest,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
FormatterStatus,
|
||||
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: {
|
||||
[sessionID: string]: Permission[]
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
config: Config
|
||||
session: Session[]
|
||||
@@ -97,30 +97,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "permission.updated": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
if (!permissions) {
|
||||
setStore("permission", event.properties.sessionID, [event.properties])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
if (match.found) {
|
||||
draft[match.index] = event.properties
|
||||
return
|
||||
}
|
||||
draft.push(event.properties)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.replied": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
||||
const requests = store.permission[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"permission",
|
||||
@@ -132,6 +112,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("permission", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"permission",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
@@ -59,7 +59,7 @@ export function Footer() {
|
||||
<Match when={connected()}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<text fg={theme.warning}>
|
||||
<span style={{ fg: theme.warning }}>◉</span> {permissions().length} Permission
|
||||
<span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Show,
|
||||
Switch,
|
||||
useContext,
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "path"
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
addDefaultParsers,
|
||||
MacOSScrollAccel,
|
||||
type ScrollAcceleration,
|
||||
TextAttributes,
|
||||
} from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
|
||||
@@ -40,7 +40,7 @@ import type { EditTool } from "@/tool/edit"
|
||||
import type { PatchTool } from "@/tool/patch"
|
||||
import type { WebFetchTool } from "@/tool/webfetch"
|
||||
import type { TaskTool } from "@/tool/task"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
@@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
@@ -82,12 +83,12 @@ class CustomSpeedScroll implements ScrollAcceleration {
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
usernameVisible: () => boolean
|
||||
showDetails: () => boolean
|
||||
userMessageMarkdown: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
sync: ReturnType<typeof useSync>
|
||||
}>()
|
||||
@@ -106,8 +107,17 @@ export function Session() {
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const children = createMemo(() => {
|
||||
const parentID = session()?.parentID ?? session()?.id
|
||||
return sync.data.session
|
||||
.filter((x) => x.parentID === parentID || x.id === parentID)
|
||||
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
})
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
|
||||
const permissions = createMemo(() => {
|
||||
if (session().parentID) return sync.data.permission[route.sessionID] ?? []
|
||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -125,7 +135,6 @@ export function Session() {
|
||||
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
|
||||
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
|
||||
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
|
||||
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
|
||||
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
|
||||
|
||||
@@ -176,28 +185,6 @@ export function Session() {
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-navigate to whichever session currently needs permission input
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return
|
||||
const currentPermissions = permissions()
|
||||
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
|
||||
|
||||
if (!targetID) {
|
||||
const child = sync.data.session.find(
|
||||
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
|
||||
)
|
||||
if (child) targetID = child.id
|
||||
}
|
||||
|
||||
if (targetID && targetID !== currentSession.id) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: targetID,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
const keybind = useKeybind()
|
||||
@@ -248,29 +235,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
const first = permissions()[0]
|
||||
if (first) {
|
||||
const response = iife(() => {
|
||||
if (evt.ctrl || evt.meta) return
|
||||
if (evt.name === "return") return "once"
|
||||
if (evt.name === "a") return "always"
|
||||
if (evt.name === "d") return "reject"
|
||||
if (evt.name === "escape") return "reject"
|
||||
return
|
||||
})
|
||||
if (response) {
|
||||
sdk.client.permission.respond({
|
||||
permissionID: first.id,
|
||||
sessionID: route.sessionID,
|
||||
response: response,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toBottom() {
|
||||
setTimeout(() => {
|
||||
if (scroll) scroll.scrollTo(scroll.scrollHeight)
|
||||
@@ -280,18 +244,14 @@ export function Session() {
|
||||
const local = useLocal()
|
||||
|
||||
function moveChild(direction: number) {
|
||||
const parentID = session()?.parentID ?? session()?.id
|
||||
let children = sync.data.session
|
||||
.filter((x) => x.parentID === parentID || x.id === parentID)
|
||||
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
if (children.length === 1) return
|
||||
let next = children.findIndex((x) => x.id === session()?.id) + direction
|
||||
if (next >= children.length) next = 0
|
||||
if (next < 0) next = children.length - 1
|
||||
if (children[next]) {
|
||||
if (children().length === 1) return
|
||||
let next = children().findIndex((x) => x.id === session()?.id) + direction
|
||||
if (next >= children().length) next = 0
|
||||
if (next < 0) next = children().length - 1
|
||||
if (children()[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: children[next].id,
|
||||
sessionID: children()[next].id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -571,19 +531,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown",
|
||||
value: "session.toggle.user_message_markdown",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setUserMessageMarkdown((prev) => {
|
||||
const next = !prev
|
||||
kv.set("user_message_markdown", next)
|
||||
return next
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: animationsEnabled() ? "Disable animations" : "Enable animations",
|
||||
value: "session.toggle.animations",
|
||||
@@ -990,12 +937,12 @@ export function Session() {
|
||||
get width() {
|
||||
return contentWidth()
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
usernameVisible,
|
||||
showDetails,
|
||||
userMessageMarkdown,
|
||||
diffWrapMode,
|
||||
sync,
|
||||
}}
|
||||
@@ -1121,7 +1068,11 @@ export function Session() {
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session().parentID && permissions().length === 0}
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
@@ -1169,7 +1120,7 @@ function UserMessage(props: {
|
||||
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
|
||||
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
|
||||
const sync = useSync()
|
||||
const { theme, syntax } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
|
||||
@@ -1200,22 +1151,7 @@ function UserMessage(props: {
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={ctx.userMessageMarkdown()}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={false}
|
||||
syntaxStyle={syntax()}
|
||||
content={text()?.text ?? ""}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!ctx.userMessageMarkdown()}>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
@@ -1321,7 +1257,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||
<Match when={props.last || final()}>
|
||||
<box paddingLeft={3}>
|
||||
<text marginTop={1}>
|
||||
<span style={{ fg: local.agent.color(props.message.mode) }}>▣ </span>{" "}
|
||||
<span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>{" "}
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||
<Show when={duration()}>
|
||||
@@ -1397,112 +1333,77 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const { showDetails } = use()
|
||||
const sync = useSync()
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const component = createMemo(() => {
|
||||
// Hide tool if showDetails is false and tool completed successfully
|
||||
// But always show if there's an error or permission is required
|
||||
const shouldHide =
|
||||
!showDetails() &&
|
||||
props.part.state.status === "completed" &&
|
||||
!sync.data.permission[props.message.sessionID]?.some((x) => x.callID === props.part.callID)
|
||||
|
||||
if (shouldHide) {
|
||||
return undefined
|
||||
}
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
},
|
||||
get input() {
|
||||
return props.part.state.input ?? {}
|
||||
},
|
||||
get output() {
|
||||
return props.part.state.status === "completed" ? props.part.state.output : undefined
|
||||
},
|
||||
get permission() {
|
||||
const permissions = sync.data.permission[props.message.sessionID] ?? []
|
||||
const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID)
|
||||
return permissions[permissionIndex]
|
||||
},
|
||||
get tool() {
|
||||
return props.part.tool
|
||||
},
|
||||
get part() {
|
||||
return props.part
|
||||
},
|
||||
}
|
||||
|
||||
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
|
||||
|
||||
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
const input = props.part.state.input ?? {}
|
||||
const container = ToolRegistry.container(props.part.tool)
|
||||
const permissions = sync.data.permission[props.message.sessionID] ?? []
|
||||
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
|
||||
const permission = permissions[permissionIndex]
|
||||
|
||||
const style: BoxProps =
|
||||
container === "block" || permission
|
||||
? {
|
||||
border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 2,
|
||||
marginTop: 1,
|
||||
gap: 1,
|
||||
backgroundColor: theme.backgroundPanel,
|
||||
customBorderChars: SplitBorder.customBorderChars,
|
||||
borderColor: permissionIndex === 0 ? theme.warning : theme.background,
|
||||
}
|
||||
: {
|
||||
paddingLeft: 3,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
marginTop={margin()}
|
||||
{...style}
|
||||
renderBefore={function () {
|
||||
const el = this as BoxRenderable
|
||||
const parent = el.parent
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
if (el.height > 1) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
const children = parent.getChildren()
|
||||
const index = children.indexOf(el)
|
||||
const previous = children[index - 1]
|
||||
if (!previous) {
|
||||
setMargin(0)
|
||||
return
|
||||
}
|
||||
if (previous.height > 1 || previous.id.startsWith("text-")) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dynamic
|
||||
component={render}
|
||||
input={input}
|
||||
tool={props.part.tool}
|
||||
metadata={metadata}
|
||||
permission={permission?.metadata ?? {}}
|
||||
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
|
||||
/>
|
||||
{props.part.state.status === "error" && (
|
||||
<box paddingLeft={2}>
|
||||
<text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
|
||||
</box>
|
||||
)}
|
||||
{permission && (
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>Permission required to run this tool:</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
<b>enter</b>
|
||||
<span style={{ fg: theme.textMuted }}> accept</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
<b>a</b>
|
||||
<span style={{ fg: theme.textMuted }}> accept always</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
<b>d</b>
|
||||
<span style={{ fg: theme.textMuted }}> deny</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
})
|
||||
|
||||
return <Show when={component()}>{component()}</Show>
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<Read {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "grep"}>
|
||||
<Grep {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "list"}>
|
||||
<List {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "webfetch"}>
|
||||
<WebFetch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "codesearch"}>
|
||||
<CodeSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "websearch"}>
|
||||
<WebSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<Write {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<Edit {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "patch"}>
|
||||
<Patch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
@@ -1511,37 +1412,16 @@ type ToolProps<T extends Tool.Info> = {
|
||||
permission: Record<string, any>
|
||||
tool: string
|
||||
output?: string
|
||||
part: ToolPart
|
||||
}
|
||||
function GenericTool(props: ToolProps<any>) {
|
||||
return (
|
||||
<ToolTitle icon="⚙" fallback="Writing command..." when={true}>
|
||||
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
|
||||
{props.tool} {input(props.input)}
|
||||
</ToolTitle>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
type ToolRegistration<T extends Tool.Info = any> = {
|
||||
name: string
|
||||
container: "inline" | "block"
|
||||
render?: Component<ToolProps<T>>
|
||||
}
|
||||
const ToolRegistry = (() => {
|
||||
const state: Record<string, ToolRegistration> = {}
|
||||
function register<T extends Tool.Info>(input: ToolRegistration<T>) {
|
||||
state[input.name] = input
|
||||
return input
|
||||
}
|
||||
return {
|
||||
register,
|
||||
container(name: string) {
|
||||
return state[name]?.container
|
||||
},
|
||||
render(name: string) {
|
||||
return state[name]?.render
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
@@ -1553,67 +1433,135 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof BashTool>({
|
||||
name: "bash",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
|
||||
{props.input.description || "Shell"}
|
||||
</ToolTitle>
|
||||
<Show when={props.input.command}>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) {
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
const sync = useSync()
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
||||
if (!callID) return false
|
||||
return callID === props.part.callID
|
||||
})
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (props.complete) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||
|
||||
const denied = createMemo(() => error()?.includes("rejected permission"))
|
||||
|
||||
return (
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
renderBefore={function () {
|
||||
const el = this as BoxRenderable
|
||||
const parent = el.parent
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
if (el.height > 1) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
const children = parent.getChildren()
|
||||
const index = children.indexOf(el)
|
||||
const previous = children[index - 1]
|
||||
if (!previous) {
|
||||
setMargin(0)
|
||||
return
|
||||
}
|
||||
if (previous.height > 1 || previous.id.startsWith("text-")) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
<Show when={output()}>
|
||||
<box>
|
||||
</text>
|
||||
<Show when={error() && !denied()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick?.()
|
||||
}}
|
||||
>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
</text>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.output !== undefined}>
|
||||
<BlockTool title={"# " + (props.input.description ?? "Shell")}>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
<text fg={theme.text}>{output()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
|
||||
{props.input.command}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof ReadTool>({
|
||||
name: "read",
|
||||
container: "inline",
|
||||
render(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
function Write(props: ToolProps<typeof WriteTool>) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
return props.input.content
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WriteTool>({
|
||||
name: "write",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
return props.input.content
|
||||
})
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
return props.metadata.diagnostics?.[filePath] ?? []
|
||||
})
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
return props.metadata.diagnostics?.[filePath] ?? []
|
||||
})
|
||||
|
||||
const done = !!props.input.filePath
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="←" fallback="Preparing write..." when={done}>
|
||||
Wrote {props.input.filePath}
|
||||
</ToolTitle>
|
||||
<Show when={done}>
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)}>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
@@ -1623,180 +1571,168 @@ ToolRegistry.register<typeof WriteTool>({
|
||||
content={code()}
|
||||
/>
|
||||
</line_number>
|
||||
</Show>
|
||||
<Show when={diagnostics().length}>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GlobTool>({
|
||||
name: "glob",
|
||||
container: "inline",
|
||||
render(props) {
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GrepTool>({
|
||||
name: "grep",
|
||||
container: "inline",
|
||||
render(props) {
|
||||
return (
|
||||
<ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof ListTool>({
|
||||
name: "list",
|
||||
container: "inline",
|
||||
render(props) {
|
||||
const dir = createMemo(() => {
|
||||
if (props.input.path) {
|
||||
return normalizePath(props.input.path)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
|
||||
List {dir()}
|
||||
</ToolTitle>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TaskTool>({
|
||||
name: "task",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
|
||||
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
|
||||
</ToolTitle>
|
||||
<Show when={props.metadata.summary?.length}>
|
||||
<box>
|
||||
<For each={props.metadata.summary ?? []}>
|
||||
{(task, index) => {
|
||||
const summary = props.metadata.summary ?? []
|
||||
return (
|
||||
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
|
||||
{index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
|
||||
{task.state.status === "completed" ? task.state.title : ""}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
<Show when={diagnostics().length}>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||
Write {normalizePath(props.input.filePath!)}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Read(props: ToolProps<typeof ReadTool>) {
|
||||
return (
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Grep(props: ToolProps<typeof GrepTool>) {
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function List(props: ToolProps<typeof ListTool>) {
|
||||
const dir = createMemo(() => {
|
||||
if (props.input.path) {
|
||||
return normalizePath(props.input.path)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return (
|
||||
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
|
||||
List {dir()}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
||||
return (
|
||||
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
|
||||
WebFetch {(props.input as any).url}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
|
||||
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function WebSearch(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
|
||||
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const { navigate } = useRoute()
|
||||
|
||||
const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.summary?.length}>
|
||||
<BlockTool
|
||||
title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
|
||||
onClick={
|
||||
props.metadata.sessionId
|
||||
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<box>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
{props.input.description} ({props.metadata.summary?.length} toolcalls)
|
||||
</text>
|
||||
<Show when={current()}>
|
||||
<text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
|
||||
└ {Locale.titlecase(current()!.tool)}{" "}
|
||||
{current()!.state.status === "completed" ? current()!.state.title : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("session_child_cycle")}
|
||||
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
||||
</text>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("session_child_cycle")}
|
||||
<span style={{ fg: theme.textMuted }}> view subagents</span>
|
||||
</text>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool
|
||||
icon="◉"
|
||||
pending="Delegating..."
|
||||
complete={props.input.subagent_type ?? props.input.description}
|
||||
part={props.part}
|
||||
>
|
||||
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof WebFetchTool>({
|
||||
name: "webfetch",
|
||||
container: "inline",
|
||||
render(props) {
|
||||
return (
|
||||
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
|
||||
WebFetch {(props.input as any).url}
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
function Edit(props: ToolProps<typeof EditTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "codesearch",
|
||||
container: "inline",
|
||||
render(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<ToolTitle icon="◇" fallback="Searching code..." when={input.query}>
|
||||
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "websearch",
|
||||
container: "inline",
|
||||
render(props: ToolProps<any>) {
|
||||
const input = props.input as any
|
||||
const metadata = props.metadata as any
|
||||
return (
|
||||
<ToolTitle icon="◈" fallback="Searching web..." when={input.query}>
|
||||
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
||||
</ToolTitle>
|
||||
)
|
||||
},
|
||||
})
|
||||
const ft = createMemo(() => filetype(props.input.filePath))
|
||||
|
||||
ToolRegistry.register<typeof EditTool>({
|
||||
name: "edit",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const diffContent = createMemo(() => props.metadata.diff)
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
const arr = props.metadata.diagnostics?.[filePath] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
const ft = createMemo(() => filetype(props.input.filePath))
|
||||
|
||||
const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
|
||||
|
||||
const diagnostics = createMemo(() => {
|
||||
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
|
||||
const arr = props.metadata.diagnostics?.[filePath] ?? []
|
||||
return arr.filter((x) => x.severity === 1).slice(0, 3)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
|
||||
Edit {normalizePath(props.input.filePath!)}{" "}
|
||||
{input({
|
||||
replaceAll: props.input.replaceAll,
|
||||
})}
|
||||
</ToolTitle>
|
||||
<Show when={diffContent()}>
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={diffContent()}
|
||||
@@ -1818,66 +1754,69 @@ ToolRegistry.register<typeof EditTool>({
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={diagnostics().length}>
|
||||
<box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
<Show when={diagnostics().length}>
|
||||
<box>
|
||||
<For each={diagnostics()}>
|
||||
{(diagnostic) => (
|
||||
<text fg={theme.error}>
|
||||
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "}
|
||||
{diagnostic.message}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof PatchTool>({
|
||||
name: "patch",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
|
||||
Patch
|
||||
</ToolTitle>
|
||||
<Show when={props.output}>
|
||||
function Patch(props: ToolProps<typeof PatchTool>) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.output !== undefined}>
|
||||
<BlockTool title="# Patch">
|
||||
<box>
|
||||
<text fg={theme.text}>{props.output?.trim()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
|
||||
Patch
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register<typeof TodoWriteTool>({
|
||||
name: "todowrite",
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<Show when={!props.input.todos?.length}>
|
||||
<ToolTitle icon="⚙" fallback="Updating todos..." when={true}>
|
||||
Updating todos...
|
||||
</ToolTitle>
|
||||
</Show>
|
||||
<Show when={props.metadata.todos?.length}>
|
||||
function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.todos?.length}>
|
||||
<BlockTool title="# Todos">
|
||||
<box>
|
||||
<For each={props.input.todos ?? []}>
|
||||
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
|
||||
Updating todos...
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useSync } from "../../context/sync"
|
||||
import path from "path"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Locale } from "@/util/locale"
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
return path.relative(process.cwd(), input) || "."
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function filetype(input?: string) {
|
||||
if (!input) return "none"
|
||||
const ext = path.extname(input)
|
||||
const language = LANGUAGE_EXTENSIONS[ext]
|
||||
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
|
||||
return language
|
||||
}
|
||||
|
||||
function EditBody(props: { request: PermissionRequest }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const sync = useSync()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
|
||||
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = sync.data.config.tui?.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
return dimensions().width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
const ft = createMemo(() => filetype(filepath()))
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"→"}</text>
|
||||
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
||||
</box>
|
||||
<Show when={diff()}>
|
||||
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
|
||||
<diff
|
||||
diff={diff()}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.text}
|
||||
addedBg={theme.diffAddedBg}
|
||||
removedBg={theme.diffRemovedBg}
|
||||
contextBg={theme.diffContextBg}
|
||||
addedSignColor={theme.diffHighlightAdded}
|
||||
removedSignColor={theme.diffHighlightRemoved}
|
||||
lineNumberFg={theme.diffLineNumber}
|
||||
lineNumberBg={theme.diffContextBg}
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function TextBody(props: { title: string; description?: string; icon?: string }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<Show when={props.icon}>
|
||||
<text fg={theme.textMuted} flexShrink={0}>
|
||||
{props.icon}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>{props.title}</text>
|
||||
</box>
|
||||
<Show when={props.description}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>{props.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
always: false,
|
||||
})
|
||||
|
||||
const input = createMemo(() => {
|
||||
const tool = props.request.tool
|
||||
if (!tool) return {}
|
||||
const parts = sync.data.part[tool.messageID] ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
|
||||
return part.state.input ?? {}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.always}>
|
||||
<Prompt
|
||||
title="Always allow"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
|
||||
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
|
||||
<box>
|
||||
<For each={props.request.always}>
|
||||
{(pattern) => (
|
||||
<text fg={theme.text}>
|
||||
{"- "}
|
||||
{pattern}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
options={{ confirm: "Confirm", cancel: "Cancel" }}
|
||||
onSelect={(option) => {
|
||||
setStore("always", false)
|
||||
if (option === "cancel") return
|
||||
sdk.client.permission.reply({
|
||||
reply: "always",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!store.always}>
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "external_directory"}>
|
||||
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "doom_loop"}>
|
||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("always", true)
|
||||
return
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: option as "once" | "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Prompt<const T extends Record<string, string>>(props: {
|
||||
title: string
|
||||
body: JSX.Element
|
||||
options: T
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
selected: keys[0],
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
evt.preventDefault()
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
if (evt.name === "right" || evt.name == "l") {
|
||||
evt.preventDefault()
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx + 1) % keys.length]
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
props.onSelect(store.selected)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.warning}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
{props.body}
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={keys}>
|
||||
{(option) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||
>
|
||||
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
|
||||
{props.options[option]}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>confirm</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -99,6 +99,7 @@ function init() {
|
||||
replace(input: any, onClose?: () => void) {
|
||||
if (store.stack.length === 0) {
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
}
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
|
||||
Reference in New Issue
Block a user