Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85dd5e1a40 | ||
|
|
78f3810776 | ||
|
|
310a276816 | ||
|
|
0d5c04d381 | ||
|
|
9fb24074c8 | ||
|
|
542c9d5346 | ||
|
|
d5f0e3fccc | ||
|
|
7d2bb5cb2b | ||
|
|
ca7a70b628 | ||
|
|
b3a2f9fb4e | ||
|
|
8be5a29870 | ||
|
|
68092f22e1 | ||
|
|
83f3c729e9 | ||
|
|
e37fd9c105 | ||
|
|
2e4fe973c9 | ||
|
|
1b82511fbd | ||
|
|
f24314438b | ||
|
|
361a962673 | ||
|
|
fa9c283fcf | ||
|
|
947b864d96 | ||
|
|
03eabb10e4 | ||
|
|
34c9d106ee |
+2
-1
@@ -1,7 +1,8 @@
|
||||
## Style Guide
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- AVOID unnecessary destructuring of variables
|
||||
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
||||
= obj` just reference it as obj.a and obj.b. this preserves context
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID `else` statements
|
||||
- AVOID using `any` type
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function () {
|
||||
<div data-page="workspace-[id]">
|
||||
<div data-slot="sections">
|
||||
<Show when={sessionInfo()?.isAdmin}>
|
||||
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
|
||||
<Show when={billingInfo()?.subscriptionID}>
|
||||
<BlackSection />
|
||||
</Show>
|
||||
<BillingSection />
|
||||
|
||||
@@ -15,6 +15,7 @@ use tauri::{
|
||||
};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::net::TcpSocket;
|
||||
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
@@ -45,6 +46,65 @@ impl ServerState {
|
||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||
|
||||
const MAX_LOG_ENTRIES: usize = 200;
|
||||
const GLOBAL_STORAGE: &str = "opencode.global.dat";
|
||||
|
||||
/// Check if a URL's origin matches any configured server in the store.
|
||||
/// Returns true if the URL should be allowed for internal navigation.
|
||||
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
|
||||
// Always allow localhost and 127.0.0.1
|
||||
if let Some(host) = url.host_str() {
|
||||
if host == "localhost" || host == "127.0.0.1" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read the server list from the store
|
||||
let Ok(store) = app.store(GLOBAL_STORAGE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(server_data) = store.get("server") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Parse the server list from the stored JSON
|
||||
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get the origin of the navigation URL (scheme + host + port)
|
||||
let url_origin = format!(
|
||||
"{}://{}{}",
|
||||
url.scheme(),
|
||||
url.host_str().unwrap_or(""),
|
||||
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
||||
);
|
||||
|
||||
// Check if any configured server matches the URL's origin
|
||||
for server in list {
|
||||
let Some(server_url) = server.as_str() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Parse the server URL to extract its origin
|
||||
let Ok(parsed) = tauri::Url::parse(server_url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let server_origin = format!(
|
||||
"{}://{}{}",
|
||||
parsed.scheme(),
|
||||
parsed.host_str().unwrap_or(""),
|
||||
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
||||
);
|
||||
|
||||
if url_origin == server_origin {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_sidecar(app: AppHandle) {
|
||||
@@ -236,6 +296,7 @@ pub fn run() {
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
|
||||
// Create window immediately with serverReady = false
|
||||
let app_for_nav = app.clone();
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
@@ -243,6 +304,22 @@ pub fn run() {
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.on_navigation(move |url| {
|
||||
// Allow internal navigation (tauri:// scheme)
|
||||
if url.scheme() == "tauri" {
|
||||
return true;
|
||||
}
|
||||
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
|
||||
if is_allowed_server(&app_for_nav, url) {
|
||||
return true;
|
||||
}
|
||||
// Open external http/https URLs in default browser
|
||||
if url.scheme() == "http" || url.scheme() == "https" {
|
||||
let _ = app_for_nav.shell().open(url.as_str(), None);
|
||||
return false; // Cancel internal navigation
|
||||
}
|
||||
true
|
||||
})
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
|
||||
@@ -51,6 +51,7 @@ export namespace Agent {
|
||||
"*": "ask",
|
||||
[Truncate.DIR]: "allow",
|
||||
},
|
||||
question: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
@@ -65,7 +66,13 @@ export namespace Agent {
|
||||
build: {
|
||||
name: "build",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(defaults, user),
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
@@ -75,6 +82,7 @@ export namespace Agent {
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
PermissionNext.fromConfig({
|
||||
question: "allow",
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".opencode/plan/*.md": "allow",
|
||||
|
||||
@@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Session.create({})
|
||||
session = await Session.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
})
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
|
||||
@@ -292,7 +292,28 @@ export const RunCommand = cmd({
|
||||
: args.title
|
||||
: undefined
|
||||
|
||||
const result = await sdk.session.create(title ? { title } : {})
|
||||
const result = await sdk.session.create(
|
||||
title
|
||||
? {
|
||||
title,
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
return result.data?.id
|
||||
})()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Todo,
|
||||
Command,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
McpResource,
|
||||
@@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_status: {
|
||||
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
permission: {},
|
||||
question: {},
|
||||
command: [],
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
@@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const requests = store.question[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"question",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.question[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("question", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("question", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"question",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
@@ -102,15 +102,16 @@ type Theme = ThemeColors & {
|
||||
thinkingOpacity: number
|
||||
}
|
||||
|
||||
export function selectedForeground(theme: Theme): RGBA {
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
if (theme._hasSelectedListItemText) {
|
||||
return theme.selectedListItemText
|
||||
}
|
||||
|
||||
// For transparent backgrounds, calculate contrast based on primary color
|
||||
// For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary)
|
||||
if (theme.background.a === 0) {
|
||||
const { r, g, b } = theme.primary
|
||||
const targetColor = bg ?? theme.primary
|
||||
const { r, g, b } = targetColor
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
@@ -33,7 +32,6 @@ export function Header() {
|
||||
const sync = useSync()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
@@ -99,24 +97,6 @@ export function Header() {
|
||||
<Title session={session} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<Show when={shareEnabled()}>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<Switch>
|
||||
<Match when={session().share?.url}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{session().share!.url}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
/share <span style={{ fg: theme.textMuted }}>copy link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -41,6 +41,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 type { QuestionTool } from "@/tool/question"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
|
||||
@@ -90,7 +92,6 @@ const context = createContext<{
|
||||
conceal: () => boolean
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
usernameVisible: () => boolean
|
||||
showDetails: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
sync: ReturnType<typeof useSync>
|
||||
@@ -118,9 +119,13 @@ export function Session() {
|
||||
})
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const permissions = createMemo(() => {
|
||||
if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||
})
|
||||
const questions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -135,7 +140,6 @@ export function Session() {
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
|
||||
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
|
||||
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
|
||||
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
|
||||
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
|
||||
@@ -459,20 +463,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: usernameVisible() ? "Hide username" : "Show username",
|
||||
value: "session.username_visible.toggle",
|
||||
keybind: "username_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setUsernameVisible((prev) => {
|
||||
const next = !prev
|
||||
kv.set("username_visible", next)
|
||||
return next
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
@@ -907,7 +897,6 @@ export function Session() {
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
usernameVisible,
|
||||
showDetails,
|
||||
diffWrapMode,
|
||||
sync,
|
||||
@@ -1037,13 +1026,20 @@ export function Session() {
|
||||
<Show when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} />
|
||||
</Show>
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0}
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
// Apply initial prompt when prompt component mounts (e.g., from fork)
|
||||
if (route.initialPrompt) {
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
}}
|
||||
disabled={permissions().length > 0}
|
||||
disabled={permissions().length > 0 || questions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
@@ -1057,7 +1053,24 @@ export function Session() {
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
<Switch>
|
||||
<Match when={wide()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Match>
|
||||
<Match when={!wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
@@ -1090,6 +1103,7 @@ function UserMessage(props: {
|
||||
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)))
|
||||
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
|
||||
|
||||
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||
|
||||
@@ -1119,7 +1133,7 @@ function UserMessage(props: {
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
@@ -1137,23 +1151,22 @@ function UserMessage(props: {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>
|
||||
{ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{ctx.usernameVisible() ? " · " : " "}
|
||||
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span> </span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||
</Show>
|
||||
</text>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1377,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
@@ -1438,7 +1454,12 @@ function InlineTool(props: {
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||
|
||||
const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
error()?.includes("rejected permission") ||
|
||||
error()?.includes("specified a rule") ||
|
||||
error()?.includes("user dismissed"),
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -1514,15 +1535,30 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
|
||||
}
|
||||
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const { theme } = useTheme()
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const lines = createMemo(() => output().split("\n"))
|
||||
const overflow = createMemo(() => lines().length > 10)
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !overflow()) return output()
|
||||
return [...lines().slice(0, 10), "…"].join("\n")
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.output !== undefined}>
|
||||
<BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}>
|
||||
<BlockTool
|
||||
title={"# " + (props.input.description ?? "Shell")}
|
||||
part={props.part}
|
||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
<text fg={theme.text}>{output()}</text>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
<Show when={overflow()}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
@@ -1812,6 +1848,34 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
const { theme } = useTheme()
|
||||
const count = createMemo(() => props.input.questions?.length ?? 0)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
<BlockTool title="# Questions" part={props.part}>
|
||||
<box>
|
||||
<For each={props.input.questions ?? []}>
|
||||
{(q, i) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{q.question}</text>
|
||||
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
|
||||
Asked {count()} question{count() !== 1 ? "s" : ""}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
if (path.isAbsolute(input)) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
@@ -395,7 +395,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
paddingRight={1}
|
||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||
>
|
||||
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
|
||||
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
||||
{props.options[option]}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const bindings = useTextareaKeybindings()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as string[],
|
||||
custom: [] as string[],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
})
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const other = createMemo(() => store.selected === options().length)
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
|
||||
function submit() {
|
||||
// Fill in empty answers with empty strings
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? "")
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
sdk.client.question.reject({
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = answer
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
if (single()) {
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers: [answer],
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// When editing "Other" textarea
|
||||
if (store.editing && !confirm()) {
|
||||
if (evt.name === "escape") {
|
||||
evt.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText?.trim()
|
||||
if (text) {
|
||||
pick(text, true)
|
||||
setStore("editing", false)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Let textarea handle all other keys
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "left" || evt.name === "h") {
|
||||
evt.preventDefault()
|
||||
const next = (store.tab - 1 + tabs()) % tabs()
|
||||
setStore("tab", next)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
if (evt.name === "right" || evt.name === "l") {
|
||||
evt.preventDefault()
|
||||
const next = (store.tab + 1) % tabs()
|
||||
setStore("tab", next)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
if (confirm()) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
} else {
|
||||
const opts = options()
|
||||
const total = opts.length + 1 // options + "Other"
|
||||
|
||||
if (evt.name === "up" || evt.name === "k") {
|
||||
evt.preventDefault()
|
||||
setStore("selected", (store.selected - 1 + total) % total)
|
||||
}
|
||||
|
||||
if (evt.name === "down" || evt.name === "j") {
|
||||
evt.preventDefault()
|
||||
setStore("selected", (store.selected + 1) % total)
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
if (other()) {
|
||||
setStore("editing", true)
|
||||
} else {
|
||||
const opt = opts[store.selected]
|
||||
if (opt) {
|
||||
pick(opt.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<Show when={!single()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const isActive = () => index() === store.tab
|
||||
const isAnswered = () => store.answers[index()] !== undefined
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
|
||||
>
|
||||
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
|
||||
{q.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
|
||||
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>{question()?.question}</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab] === opt.label
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
||||
{i() + 1}. {opt.label}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{opt.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{input() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={confirm() && !single()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const answer = () => store.answers[index()]
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{q.header}:</text>
|
||||
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={!single()}>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={theme.text}>
|
||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useDirectory } from "../../context/directory"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
|
||||
export function Sidebar(props: { sessionID: string }) {
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||
@@ -77,6 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox flexGrow={1}>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
|
||||
@@ -450,6 +450,7 @@ export namespace Config {
|
||||
external_directory: PermissionRule.optional(),
|
||||
todowrite: PermissionAction.optional(),
|
||||
todoread: PermissionAction.optional(),
|
||||
question: PermissionAction.optional(),
|
||||
webfetch: PermissionAction.optional(),
|
||||
websearch: PermissionAction.optional(),
|
||||
codesearch: PermissionAction.optional(),
|
||||
|
||||
@@ -13,6 +13,11 @@ export namespace Flag {
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export namespace Identifier {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
question: "que",
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
|
||||
@@ -497,6 +497,10 @@ export namespace ProviderTransform {
|
||||
return { reasoningEffort: "minimal" }
|
||||
}
|
||||
if (model.providerID === "google") {
|
||||
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
|
||||
if (model.api.id.includes("gemini-3")) {
|
||||
return { thinkingConfig: { thinkingLevel: "minimal" } }
|
||||
}
|
||||
return { thinkingConfig: { thinkingBudget: 0 } }
|
||||
}
|
||||
if (model.providerID === "openrouter") {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Question {
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({
|
||||
ref: "QuestionOption",
|
||||
})
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(12).describe("Very short label (max 12 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
})
|
||||
.meta({
|
||||
ref: "QuestionInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: Identifier.schema("question"),
|
||||
sessionID: Identifier.schema("session"),
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: z.string(),
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "QuestionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z.array(z.string()).describe("User answers in order of questions"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
answers: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const pending: Record<
|
||||
string,
|
||||
{
|
||||
info: Request
|
||||
resolve: (answers: string[]) => void
|
||||
reject: (e: any) => void
|
||||
}
|
||||
> = {}
|
||||
|
||||
return {
|
||||
pending,
|
||||
}
|
||||
})
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: string
|
||||
questions: Info[]
|
||||
tool?: { messageID: string; callID: string }
|
||||
}): Promise<string[]> {
|
||||
const s = await state()
|
||||
const id = Identifier.ascending("question")
|
||||
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
s.pending[id] = {
|
||||
info,
|
||||
resolve,
|
||||
reject,
|
||||
}
|
||||
Bus.publish(Event.Asked, info)
|
||||
})
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
|
||||
const s = await state()
|
||||
const existing = s.pending[input.requestID]
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
delete s.pending[input.requestID]
|
||||
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
|
||||
existing.resolve(input.answers)
|
||||
}
|
||||
|
||||
export async function reject(requestID: string): Promise<void> {
|
||||
const s = await state()
|
||||
const existing = s.pending[requestID]
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
delete s.pending[requestID]
|
||||
|
||||
log.info("rejected", { requestID })
|
||||
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
|
||||
existing.reject(new RejectedError())
|
||||
}
|
||||
|
||||
export class RejectedError extends Error {
|
||||
constructor() {
|
||||
super("The user dismissed this question")
|
||||
}
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => Object.values(x.pending).map((x) => x.info))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Question } from "../question"
|
||||
import z from "zod"
|
||||
import { errors } from "./error"
|
||||
|
||||
export const QuestionRoute = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
operationId: "question.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await Question.list()
|
||||
return c.json(questions)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
operationId: "question.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question answered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ answers: z.array(z.string()) })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await Question.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
describeRoute({
|
||||
summary: "Reject question request",
|
||||
description: "Reject a question request from the AI assistant.",
|
||||
operationId: "question.reject",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question rejected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Question.reject(params.requestID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
+2607
-2596
File diff suppressed because it is too large
Load Diff
@@ -82,16 +82,12 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
const provider = await Provider.getProvider(input.model.providerID)
|
||||
const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
|
||||
const variant =
|
||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||
const options = pipe(
|
||||
ProviderTransform.options(input.model, input.sessionID, provider.options),
|
||||
mergeDeep(small),
|
||||
mergeDeep(input.model.options),
|
||||
mergeDeep(input.agent.options),
|
||||
mergeDeep(variant),
|
||||
)
|
||||
const base = input.small
|
||||
? ProviderTransform.smallOptions(input.model)
|
||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
||||
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LLM } from "./llm"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { Question } from "@/question"
|
||||
|
||||
export namespace SessionProcessor {
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
@@ -208,7 +209,10 @@ export namespace SessionProcessor {
|
||||
},
|
||||
})
|
||||
|
||||
if (value.error instanceof PermissionNext.RejectedError) {
|
||||
if (
|
||||
value.error instanceof PermissionNext.RejectedError ||
|
||||
value.error instanceof Question.RejectedError
|
||||
) {
|
||||
blocked = shouldBreak
|
||||
}
|
||||
delete toolcalls[value.toolCallId]
|
||||
|
||||
@@ -37,7 +37,7 @@ import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { fn } from "@/util/fn"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { SessionStatus } from "./status"
|
||||
@@ -383,7 +383,7 @@ export namespace SessionPrompt {
|
||||
sessionID: sessionID,
|
||||
abort,
|
||||
callID: part.callID,
|
||||
extra: { userInvokedAgents: [task.agent] },
|
||||
extra: { bypassAgentCheck: true },
|
||||
async metadata(input) {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
@@ -545,11 +545,9 @@ export namespace SessionPrompt {
|
||||
abort,
|
||||
})
|
||||
|
||||
// Track agents explicitly invoked by user via @ autocomplete
|
||||
const userInvokedAgents = msgs
|
||||
.filter((m) => m.info.role === "user")
|
||||
.flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[])
|
||||
.map((p) => p.name)
|
||||
// Check if user explicitly invoked an agent via @ in this turn
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
|
||||
const tools = await resolveTools({
|
||||
agent,
|
||||
@@ -557,7 +555,7 @@ export namespace SessionPrompt {
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
userInvokedAgents,
|
||||
bypassAgentCheck,
|
||||
})
|
||||
|
||||
if (step === 1) {
|
||||
@@ -646,7 +644,7 @@ export namespace SessionPrompt {
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
userInvokedAgents: string[]
|
||||
bypassAgentCheck: boolean
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
@@ -656,7 +654,7 @@ export namespace SessionPrompt {
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, userInvokedAgents: input.userInvokedAgents },
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
@@ -800,28 +798,6 @@ export namespace SessionPrompt {
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
// Regenerate task tool description with filtered subagents
|
||||
if (tools.task) {
|
||||
const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
const filtered = filterSubagents(all, input.agent.permission)
|
||||
|
||||
// If no subagents are permitted, remove the task tool entirely
|
||||
if (filtered.length === 0) {
|
||||
delete tools.task
|
||||
} else {
|
||||
const description = TASK_DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
filtered
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
tools.task = {
|
||||
...tools.task,
|
||||
description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ export namespace SystemPrompt {
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [
|
||||
path.join(Global.Path.config, "AGENTS.md"),
|
||||
path.join(os.homedir(), ".claude", "CLAUDE.md"),
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Log } from "../util/log"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { exists } from "fs/promises"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
@@ -80,22 +81,24 @@ export namespace Skill {
|
||||
claudeDirs.push(globalClaude)
|
||||
}
|
||||
|
||||
for (const dir of claudeDirs) {
|
||||
const matches = await Array.fromAsync(
|
||||
CLAUDE_SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
}),
|
||||
).catch((error) => {
|
||||
log.error("failed .claude directory scan for skills", { dir, error })
|
||||
return []
|
||||
})
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
|
||||
for (const dir of claudeDirs) {
|
||||
const matches = await Array.fromAsync(
|
||||
CLAUDE_SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
}),
|
||||
).catch((error) => {
|
||||
log.error("failed .claude directory scan for skills", { dir, error })
|
||||
return []
|
||||
})
|
||||
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
|
||||
export const QuestionTool = Tool.define("question", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
questions: z.array(Question.Info).describe("Questions to ask"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const answers = await Question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||
metadata: {
|
||||
answers,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- Users will always be able to select "Other" to provide custom text input
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionTool } from "./question"
|
||||
import { BashTool } from "./bash"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
|
||||
|
||||
return [
|
||||
InvalidTool,
|
||||
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
|
||||
@@ -12,35 +12,37 @@ import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
|
||||
export { DESCRIPTION as TASK_DESCRIPTION }
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
session_id: z.string().describe("Existing Task session to continue").optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) {
|
||||
return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny")
|
||||
}
|
||||
|
||||
export const TaskTool = Tool.define("task", async () => {
|
||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
|
||||
// Filter agents by permissions if agent provided
|
||||
const caller = ctx?.agent
|
||||
const accessibleAgents = caller
|
||||
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
: agents
|
||||
|
||||
const description = DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
agents
|
||||
accessibleAgents
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
return {
|
||||
description,
|
||||
parameters: z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
session_id: z.string().describe("Existing Task session to continue").optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
const config = await Config.get()
|
||||
|
||||
const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[]
|
||||
// Skip permission check when invoked from a command subtask (user already approved by invoking the command)
|
||||
if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) {
|
||||
// Skip permission check when user explicitly invoked via @ or command subtask
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
await ctx.ask({
|
||||
permission: "task",
|
||||
patterns: [params.subagent_type],
|
||||
|
||||
@@ -1,147 +1,9 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import type { Agent } from "../src/agent/agent"
|
||||
import { filterSubagents } from "../src/tool/task"
|
||||
import { PermissionNext } from "../src/permission/next"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
describe("filterSubagents - permission.task filtering", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
permission: "task",
|
||||
pattern,
|
||||
action,
|
||||
}))
|
||||
|
||||
const mockAgents = [
|
||||
{ name: "general", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
test("returns all agents when permissions config is empty", () => {
|
||||
const result = filterSubagents(mockAgents, [])
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("excludes agents with explicit deny", () => {
|
||||
const ruleset = createRuleset({ "code-reviewer": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with explicit allow", () => {
|
||||
const ruleset = createRuleset({
|
||||
"code-reviewer": "allow",
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with ask permission (user approval is runtime behavior)", () => {
|
||||
const ruleset = createRuleset({
|
||||
"code-reviewer": "ask",
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("includes agents with undefined permission (default allow)", () => {
|
||||
const ruleset = createRuleset({
|
||||
general: "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with deny", () => {
|
||||
const ruleset = createRuleset({ "orchestrator-*": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with allow", () => {
|
||||
const ruleset = createRuleset({
|
||||
"*": "allow",
|
||||
"orchestrator-fast": "deny",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("supports wildcard patterns with ask", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "ask",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
|
||||
})
|
||||
|
||||
test("longer pattern takes precedence over shorter pattern", () => {
|
||||
const ruleset = createRuleset({
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
|
||||
})
|
||||
|
||||
test("edge case: all agents denied", () => {
|
||||
const ruleset = createRuleset({ "*": "deny" })
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("edge case: mixed patterns with multiple wildcards", () => {
|
||||
const ruleset = createRuleset({
|
||||
"*": "ask",
|
||||
"orchestrator-*": "deny",
|
||||
"orchestrator-fast": "allow",
|
||||
})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
|
||||
})
|
||||
|
||||
test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
|
||||
const agents = [
|
||||
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
|
||||
{ name: "orchestrator", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
const result = filterSubagents(agents, [])
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
|
||||
})
|
||||
|
||||
test("hidden: true agents can be filtered by permission.task deny", () => {
|
||||
const agents = [
|
||||
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
{ name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
const ruleset = createRuleset({ general: "deny" })
|
||||
const result = filterSubagents(agents, ruleset)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionNext.evaluate for permission.task", () => {
|
||||
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
|
||||
Object.entries(rules).map(([pattern, action]) => ({
|
||||
@@ -277,12 +139,6 @@ describe("PermissionNext.disabled for task tool", () => {
|
||||
|
||||
// Integration tests that load permissions from real config files
|
||||
describe("permission.task with real config files", () => {
|
||||
const mockAgents = [
|
||||
{ name: "general", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
|
||||
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
|
||||
] as Agent.Info[]
|
||||
|
||||
test("loads task permissions from opencode.json config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
@@ -300,8 +156,10 @@ describe("permission.task with real config files", () => {
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -323,8 +181,10 @@ describe("permission.task with real config files", () => {
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
|
||||
const result = filterSubagents(mockAgents, ruleset)
|
||||
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { Question } from "../../src/question"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
test("ask - returns pending promise", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask - adds to pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions,
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
expect(pending.length).toBe(1)
|
||||
expect(pending[0].questions).toEqual(questions)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// reply tests
|
||||
|
||||
test("reply - resolves the pending ask with answers", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const askPromise = Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions,
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
const requestID = pending[0].id
|
||||
|
||||
await Question.reply({
|
||||
requestID,
|
||||
answers: ["Option 1"],
|
||||
})
|
||||
|
||||
const answers = await askPromise
|
||||
expect(answers).toEqual(["Option 1"])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - removes from pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
await Question.reply({
|
||||
requestID: pending[0].id,
|
||||
answers: ["Option 1"],
|
||||
})
|
||||
|
||||
const pendingAfter = await Question.list()
|
||||
expect(pendingAfter.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - does nothing for unknown requestID", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Question.reply({
|
||||
requestID: "que_unknown",
|
||||
answers: ["Option 1"],
|
||||
})
|
||||
// Should not throw
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// reject tests
|
||||
|
||||
test("reject - throws RejectedError", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
await Question.reject(pending[0].id)
|
||||
|
||||
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reject - removes from pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const askPromise = Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
await Question.reject(pending[0].id)
|
||||
askPromise.catch(() => {}) // Ignore rejection
|
||||
|
||||
const pendingAfter = await Question.list()
|
||||
expect(pendingAfter.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reject - does nothing for unknown requestID", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Question.reject("que_unknown")
|
||||
// Should not throw
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// multiple questions tests
|
||||
|
||||
test("ask - handles multiple questions", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Build", description: "Build the project" },
|
||||
{ label: "Test", description: "Run tests" },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: "Which environment?",
|
||||
header: "Env",
|
||||
options: [
|
||||
{ label: "Dev", description: "Development" },
|
||||
{ label: "Prod", description: "Production" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const askPromise = Question.ask({
|
||||
sessionID: "ses_test",
|
||||
questions,
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
|
||||
await Question.reply({
|
||||
requestID: pending[0].id,
|
||||
answers: ["Build", "Dev"],
|
||||
})
|
||||
|
||||
const answers = await askPromise
|
||||
expect(answers).toEqual(["Build", "Dev"])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// list tests
|
||||
|
||||
test("list - returns all pending requests", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
Question.ask({
|
||||
sessionID: "ses_test1",
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Question.ask({
|
||||
sessionID: "ses_test2",
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await Question.list()
|
||||
expect(pending.length).toBe(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("list - returns empty when no pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await Question.list()
|
||||
expect(pending.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
@@ -286,4 +286,18 @@ describe("tool.read truncation", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("large image files are properly attached without error", async () => {
|
||||
await Instance.provide({
|
||||
directory: FIXTURES_DIR,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.attachments).toBeDefined()
|
||||
expect(result.attachments?.length).toBe(1)
|
||||
expect(result.attachments?.[0].type).toBe("file")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,11 @@ import type {
|
||||
PtyRemoveResponses,
|
||||
PtyUpdateErrors,
|
||||
PtyUpdateResponses,
|
||||
QuestionListResponses,
|
||||
QuestionRejectErrors,
|
||||
QuestionRejectResponses,
|
||||
QuestionReplyErrors,
|
||||
QuestionReplyResponses,
|
||||
SessionAbortErrors,
|
||||
SessionAbortResponses,
|
||||
SessionChildrenErrors,
|
||||
@@ -1781,6 +1786,94 @@ export class Permission extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Question extends HeyApiClient {
|
||||
/**
|
||||
* List pending questions
|
||||
*
|
||||
* Get all pending question requests across all sessions.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
|
||||
return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
|
||||
url: "/question",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to question request
|
||||
*
|
||||
* Provide answers to a question request from the AI assistant.
|
||||
*/
|
||||
public reply<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: string
|
||||
directory?: string
|
||||
answers?: Array<string>
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "body", key: "answers" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<QuestionReplyResponses, QuestionReplyErrors, ThrowOnError>({
|
||||
url: "/question/{requestID}/reply",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject question request
|
||||
*
|
||||
* Reject a question request from the AI assistant.
|
||||
*/
|
||||
public reject<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
requestID: string
|
||||
directory?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "requestID" },
|
||||
{ in: "query", key: "directory" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<QuestionRejectResponses, QuestionRejectErrors, ThrowOnError>({
|
||||
url: "/question/{requestID}/reject",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Command extends HeyApiClient {
|
||||
/**
|
||||
* List commands
|
||||
@@ -2912,6 +3005,8 @@ export class OpencodeClient extends HeyApiClient {
|
||||
|
||||
permission = new Permission({ client: this.client })
|
||||
|
||||
question = new Question({ client: this.client })
|
||||
|
||||
command = new Command({ client: this.client })
|
||||
|
||||
provider = new Provider({ client: this.client })
|
||||
|
||||
@@ -517,6 +517,67 @@ export type EventSessionIdle = {
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* Explanation of choice
|
||||
*/
|
||||
description: string
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
/**
|
||||
* Complete question
|
||||
*/
|
||||
question: string
|
||||
/**
|
||||
* Very short label (max 12 chars)
|
||||
*/
|
||||
header: string
|
||||
/**
|
||||
* Available choices
|
||||
*/
|
||||
options: Array<QuestionOption>
|
||||
}
|
||||
|
||||
export type QuestionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
/**
|
||||
* Questions to ask
|
||||
*/
|
||||
questions: Array<QuestionInfo>
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventQuestionAsked = {
|
||||
type: "question.asked"
|
||||
properties: QuestionRequest
|
||||
}
|
||||
|
||||
export type EventQuestionReplied = {
|
||||
type: "question.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
answers: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type EventQuestionRejected = {
|
||||
type: "question.rejected"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionCompacted = {
|
||||
type: "session.compacted"
|
||||
properties: {
|
||||
@@ -788,6 +849,9 @@ export type Event =
|
||||
| EventPermissionReplied
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventSessionCompacted
|
||||
| EventFileEdited
|
||||
| EventTodoUpdated
|
||||
@@ -1233,6 +1297,7 @@ export type PermissionConfig =
|
||||
external_directory?: PermissionRuleConfig
|
||||
todowrite?: PermissionActionConfig
|
||||
todoread?: PermissionActionConfig
|
||||
question?: PermissionActionConfig
|
||||
webfetch?: PermissionActionConfig
|
||||
websearch?: PermissionActionConfig
|
||||
codesearch?: PermissionActionConfig
|
||||
@@ -3545,6 +3610,92 @@ export type PermissionListResponses = {
|
||||
|
||||
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
|
||||
|
||||
export type QuestionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/question"
|
||||
}
|
||||
|
||||
export type QuestionListResponses = {
|
||||
/**
|
||||
* List of pending questions
|
||||
*/
|
||||
200: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
|
||||
|
||||
export type QuestionReplyData = {
|
||||
body?: {
|
||||
answers: Array<string>
|
||||
}
|
||||
path: {
|
||||
requestID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/question/{requestID}/reply"
|
||||
}
|
||||
|
||||
export type QuestionReplyErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
|
||||
|
||||
export type QuestionReplyResponses = {
|
||||
/**
|
||||
* Question answered successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]
|
||||
|
||||
export type QuestionRejectData = {
|
||||
body?: never
|
||||
path: {
|
||||
requestID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/question/{requestID}/reject"
|
||||
}
|
||||
|
||||
export type QuestionRejectErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
|
||||
|
||||
export type QuestionRejectResponses = {
|
||||
/**
|
||||
* Question rejected successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
|
||||
|
||||
export type CommandListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -3156,6 +3156,185 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/question": {
|
||||
"get": {
|
||||
"operationId": "question.list",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "List pending questions",
|
||||
"description": "Get all pending question requests across all sessions.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of pending questions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/QuestionRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/question/{requestID}/reply": {
|
||||
"post": {
|
||||
"operationId": "question.reply",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "requestID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"summary": "Reply to question request",
|
||||
"description": "Provide answers to a question request from the AI assistant.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Question answered successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["answers"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/question/{requestID}/reject": {
|
||||
"post": {
|
||||
"operationId": "question.reject",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "requestID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"summary": "Reject question request",
|
||||
"description": "Reject a question request from the AI assistant.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Question rejected successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/command": {
|
||||
"get": {
|
||||
"operationId": "command.list",
|
||||
@@ -6906,6 +7085,138 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"description": "Display text (1-5 words, concise)",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Explanation of choice",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["label", "description"]
|
||||
},
|
||||
"QuestionInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"description": "Complete question",
|
||||
"type": "string"
|
||||
},
|
||||
"header": {
|
||||
"description": "Very short label (max 12 chars)",
|
||||
"type": "string",
|
||||
"maxLength": 12
|
||||
},
|
||||
"options": {
|
||||
"description": "Available choices",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/QuestionOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["question", "header", "options"]
|
||||
},
|
||||
"QuestionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^que.*"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"questions": {
|
||||
"description": "Questions to ask",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/QuestionInfo"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
},
|
||||
"callID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "callID"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "questions"]
|
||||
},
|
||||
"Event.question.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "question.asked"
|
||||
},
|
||||
"properties": {
|
||||
"$ref": "#/components/schemas/QuestionRequest"
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.question.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "question.replied"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string"
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "answers"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.question.rejected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "question.rejected"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.session.compacted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7630,6 +7941,15 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.idle"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
@@ -8289,6 +8609,9 @@
|
||||
"todoread": {
|
||||
"$ref": "#/components/schemas/PermissionActionConfig"
|
||||
},
|
||||
"question": {
|
||||
"$ref": "#/components/schemas/PermissionActionConfig"
|
||||
},
|
||||
"webfetch": {
|
||||
"$ref": "#/components/schemas/PermissionActionConfig"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.93433 9.57807C6.93433 8.92952 7.46008 8.40376 8.10864 8.40376C8.75719 8.40376 9.28295 8.92952 9.28295 9.57807V16.6239C9.28295 17.2725 8.75719 17.7983 8.10864 17.7983C7.46008 17.7983 6.93433 17.2725 6.93433 16.6239V9.57807Z" />
|
||||
<path d="M30.7144 25.1377C30.7144 24.4891 31.2402 23.9634 31.8887 23.9634C32.5373 23.9634 33.063 24.4891 33.063 25.1377V31.0092C33.063 31.6578 32.5373 32.1836 31.8887 32.1836C31.2402 32.1836 30.7144 31.6578 30.7144 31.0093V25.1377Z" />
|
||||
<path d="M22.7875 30.422C22.7875 29.7735 23.3132 29.2477 23.9618 29.2477C24.6103 29.2477 25.1361 29.7735 25.1361 30.422V34.8257C25.1361 35.4742 24.6103 36 23.9618 36C23.3132 36 22.7875 35.4742 22.7875 34.8257V30.422Z" />
|
||||
<path d="M6.93433 28.367C6.93433 27.7185 7.46008 27.1927 8.10864 27.1927C8.75719 27.1927 9.28295 27.7185 9.28295 28.367V31.5964C9.28295 32.245 8.75719 32.7707 8.10864 32.7707C7.46008 32.7707 6.93433 32.245 6.93433 31.5964V28.367Z" />
|
||||
<path d="M14.8617 5.46787C14.8617 4.81931 15.3874 4.29355 16.036 4.29355C16.6845 4.29355 17.2103 4.81931 17.2103 5.46787V9.87153C17.2103 10.5201 16.6845 11.0458 16.036 11.0458C15.3874 11.0458 14.8617 10.5201 14.8617 9.87153V5.46787Z" />
|
||||
<path d="M30.7144 9.28437C30.7144 8.63582 31.2402 8.11006 31.8887 8.11006C32.5373 8.11006 33.063 8.63582 33.063 9.28437V13.688C33.063 14.3366 32.5373 14.8623 31.8887 14.8623C31.2402 14.8623 30.7144 14.3366 30.7144 13.688V9.28437Z" />
|
||||
<path d="M22.7875 5.17431C22.7875 4.52576 23.3132 4 23.9618 4C24.6103 4 25.1361 4.52576 25.1361 5.17431V18.3853C25.1361 19.0339 24.6103 19.5596 23.9618 19.5596C23.3132 19.5596 22.7875 19.0339 22.7875 18.3853V5.17431Z" />
|
||||
<path d="M14.8617 21.3212C14.8617 20.6726 15.3874 20.1469 16.036 20.1469C16.6845 20.1469 17.2103 20.6726 17.2103 21.3212V34.5322C17.2103 35.1807 16.6845 35.7065 16.036 35.7065C15.3874 35.7065 14.8617 35.1807 14.8617 34.5322V21.3212Z" />
|
||||
<path d="M10.4577 21.9083C10.4577 23.2055 9.40623 24.257 8.10912 24.257C6.81201 24.257 5.7605 23.2055 5.7605 21.9083C5.7605 20.6112 6.81201 19.5597 8.10912 19.5597C9.40623 19.5597 10.4577 20.6112 10.4577 21.9083Z" />
|
||||
<path d="M18.3843 14.8624C18.3843 16.1596 17.3328 17.2111 16.0357 17.2111C14.7386 17.2111 13.6871 16.1596 13.6871 14.8624C13.6871 13.5653 14.7386 12.5138 16.0357 12.5138C17.3328 12.5138 18.3843 13.5653 18.3843 14.8624Z" />
|
||||
<path d="M26.3101 24.2569C26.3101 25.554 25.2586 26.6056 23.9615 26.6056C22.6644 26.6056 21.6128 25.554 21.6128 24.2569C21.6128 22.9598 22.6644 21.9083 23.9615 21.9083C25.2586 21.9083 26.3101 22.9598 26.3101 24.2569Z" />
|
||||
<path d="M34.2378 19.5597C34.2378 20.8568 33.1863 21.9083 31.8892 21.9083C30.5921 21.9083 29.5406 20.8568 29.5406 19.5597C29.5406 18.2626 30.5921 17.2111 31.8892 17.2111C33.1863 17.2111 34.2378 18.2626 34.2378 19.5597Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.1484 16.5153V16.5238C14.5325 17.3971 14.4945 18.6923 14.5114 19.3925V21.4091C14.5114 22.7379 13.4272 23.8221 12.1067 23.8221H9.24219C7.91327 23.8221 6.8375 22.7423 6.8375 21.4091V18.5952C6.8375 17.262 7.91327 16.182 9.24219 16.182H10.9339C11.2376 16.182 11.9717 16.1694 12.2923 16.1061C12.5666 16.0513 13.4525 15.853 14.2963 15.2201C14.3891 15.1526 14.4734 15.0852 14.5451 15.0219C14.5451 15.0219 14.7392 14.8574 14.9122 14.6759C15.3889 14.1824 15.7559 13.4778 15.7559 13.4778C15.8741 13.2584 16.1441 12.7016 16.3044 11.9295C16.3803 11.5541 16.4141 11.2039 16.4394 10.917L16.528 9.24219C16.528 7.91327 17.6079 6.83329 18.9369 6.83329H21.7339C23.0629 6.83329 24.1427 7.91327 24.1427 9.24219V12.0603C24.1427 13.3892 23.0629 14.4692 21.7339 14.4692H19.4346C18.9073 14.4819 18.4686 14.5367 18.1438 14.5958C17.5489 14.7055 17.2409 14.832 17.1481 14.87M10.6428 14.8109C8.35625 14.8109 6.5 12.9505 6.5 10.6555C6.5 8.36046 8.35625 6.5 10.6428 6.5C12.9294 6.5 14.7856 8.36046 14.7856 10.6555C14.7856 12.9505 12.9294 14.8109 10.6428 14.8109ZM10.6428 25.1891C12.9294 25.1891 14.7856 27.0496 14.7856 29.3446C14.7856 31.6396 12.9294 33.5 10.6428 33.5C8.35625 33.5 6.5 31.6396 6.5 29.3446C6.5 27.0496 8.35625 25.1891 10.6428 25.1891ZM33.5 12.1405C33.5 13.4272 32.4538 14.4734 31.1671 14.4734C30.4963 14.4355 29.9352 14.4566 29.526 14.4819C28.8341 14.5283 28.429 14.5536 27.8933 14.7181C27.29 14.9037 26.8598 15.1611 26.615 15.3087C26.5096 15.372 26.0919 15.6294 25.6067 16.0597C25.3325 16.3044 25.0709 16.5364 24.8009 16.9076C24.4254 17.4265 24.261 17.899 24.1766 18.1521C23.9487 18.8356 23.9066 19.7848 23.9192 20.2448L23.8939 21.4808C23.8939 22.7717 22.8519 23.8179 21.5652 23.8179H18.6079C17.3211 23.8179 16.2791 22.7717 16.2791 21.4808V18.515C16.2791 17.2241 17.3211 16.1778 18.6079 16.1778H20.3965C21.0294 16.1778 22.2865 16.2326 23.3539 15.8403C24.2694 15.507 25.1848 14.5789 25.5434 13.5622C25.8852 12.5286 25.8767 11.1533 25.8767 11.1533L25.8852 9.16625C25.8852 7.87952 26.9314 6.83329 28.2181 6.83329H31.1671C32.4538 6.83329 33.5 7.87952 33.5 9.16625V12.1405Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.7,5.25l2.14-1.53,10.49,13.29-16.23,19.43-2.65,3L0.42,16.83l6.87-8.64,5.78,2.21,1.79-4.46L23.3,0.1l3.42,5.15ZM31.13,13.98L22.91,1.78l-7.1,4.95-5.45,13.53,20.77-6.28ZM36.74,15.57l-8.03-10.1-0.14-0.1-1.14,0.89,5.03,7.58,4.28,1.73ZM9.06,20.35l3.53-8.84-4.89-1.92-5.62,7.1,6.98,3.66ZM37.62,17.19l-5.3-2.14-9.22,19.52,14.52-17.38ZM30.95,15.24l-20.63,6.22,10.1,15.93,10.53-22.15ZM15.79,32.41l-6.86-10.82c-1.61-0.84-3.2-1.73-4.81-2.57-0.1-0.05-0.21-0.12-0.32-0.13l11.99,13.52Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -43,14 +43,15 @@
|
||||
|
||||
/* padding: 8px; */
|
||||
/* padding: 8px 8px 0 8px; */
|
||||
border: 1px solid hsl(from var(--border-base) h s l / 0.2);
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
color-mix(in oklch, var(--border-base) 30%, transparent),
|
||||
color-mix(in oklch, var(--border-base) 50%, transparent)
|
||||
);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 15px 45px 0 rgba(19, 16, 16, 0.35),
|
||||
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
|
||||
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
/* animation: contentHide 300ms ease-in forwards; */
|
||||
/**/
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
|
||||
[data-slot="user-message-text"] {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
background: var(--surface-base);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
|
||||
&:focus-within {
|
||||
|
||||
@@ -382,6 +382,12 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 40 40" id="nano-gpt">
|
||||
<path
|
||||
d="M26.7,5.25l2.14-1.53,10.49,13.29-16.23,19.43-2.65,3L0.42,16.83l6.87-8.64,5.78,2.21,1.79-4.46L23.3,0.1l3.42,5.15ZM31.13,13.98L22.91,1.78l-7.1,4.95-5.45,13.53,20.77-6.28ZM36.74,15.57l-8.03-10.1-0.14-0.1-1.14,0.89,5.03,7.58,4.28,1.73ZM9.06,20.35l3.53-8.84-4.89-1.92-5.62,7.1,6.98,3.66ZM37.62,17.19l-5.3-2.14-9.22,19.52,14.52-17.38ZM30.95,15.24l-20.63,6.22,10.1,15.93,10.53-22.15ZM15.79,32.41l-6.86-10.82c-1.61-0.84-3.2-1.73-4.81-2.57-0.1-0.05-0.21-0.12-0.32-0.13l11.99,13.52Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" fill="none" id="morph">
|
||||
<path
|
||||
shape-rendering="geometricPrecision"
|
||||
@@ -632,6 +638,12 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 40 40" fill="none" id="friendli">
|
||||
<path
|
||||
d="M15.1484 16.5153V16.5238C14.5325 17.3971 14.4945 18.6923 14.5114 19.3925V21.4091C14.5114 22.7379 13.4272 23.8221 12.1067 23.8221H9.24219C7.91327 23.8221 6.8375 22.7423 6.8375 21.4091V18.5952C6.8375 17.262 7.91327 16.182 9.24219 16.182H10.9339C11.2376 16.182 11.9717 16.1694 12.2923 16.1061C12.5666 16.0513 13.4525 15.853 14.2963 15.2201C14.3891 15.1526 14.4734 15.0852 14.5451 15.0219C14.5451 15.0219 14.7392 14.8574 14.9122 14.6759C15.3889 14.1824 15.7559 13.4778 15.7559 13.4778C15.8741 13.2584 16.1441 12.7016 16.3044 11.9295C16.3803 11.5541 16.4141 11.2039 16.4394 10.917L16.528 9.24219C16.528 7.91327 17.6079 6.83329 18.9369 6.83329H21.7339C23.0629 6.83329 24.1427 7.91327 24.1427 9.24219V12.0603C24.1427 13.3892 23.0629 14.4692 21.7339 14.4692H19.4346C18.9073 14.4819 18.4686 14.5367 18.1438 14.5958C17.5489 14.7055 17.2409 14.832 17.1481 14.87M10.6428 14.8109C8.35625 14.8109 6.5 12.9505 6.5 10.6555C6.5 8.36046 8.35625 6.5 10.6428 6.5C12.9294 6.5 14.7856 8.36046 14.7856 10.6555C14.7856 12.9505 12.9294 14.8109 10.6428 14.8109ZM10.6428 25.1891C12.9294 25.1891 14.7856 27.0496 14.7856 29.3446C14.7856 31.6396 12.9294 33.5 10.6428 33.5C8.35625 33.5 6.5 31.6396 6.5 29.3446C6.5 27.0496 8.35625 25.1891 10.6428 25.1891ZM33.5 12.1405C33.5 13.4272 32.4538 14.4734 31.1671 14.4734C30.4963 14.4355 29.9352 14.4566 29.526 14.4819C28.8341 14.5283 28.429 14.5536 27.8933 14.7181C27.29 14.9037 26.8598 15.1611 26.615 15.3087C26.5096 15.372 26.0919 15.6294 25.6067 16.0597C25.3325 16.3044 25.0709 16.5364 24.8009 16.9076C24.4254 17.4265 24.261 17.899 24.1766 18.1521C23.9487 18.8356 23.9066 19.7848 23.9192 20.2448L23.8939 21.4808C23.8939 22.7717 22.8519 23.8179 21.5652 23.8179H18.6079C17.3211 23.8179 16.2791 22.7717 16.2791 21.4808V18.515C16.2791 17.2241 17.3211 16.1778 18.6079 16.1778H20.3965C21.0294 16.1778 22.2865 16.2326 23.3539 15.8403C24.2694 15.507 25.1848 14.5789 25.5434 13.5622C25.8852 12.5286 25.8767 11.1533 25.8767 11.1533L25.8852 9.16625C25.8852 7.87952 26.9314 6.83329 28.2181 6.83329H31.1671C32.4538 6.83329 33.5 7.87952 33.5 9.16625V12.1405Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" fill="none" id="fireworks-ai">
|
||||
<path
|
||||
d="M14.45 5.74999L11.9991 11.6956L9.54562 5.74999H7.97237L10.6604 12.2495C10.7678 12.5138 10.9515 12.7402 11.1882 12.8996C11.4248 13.059 11.7036 13.1442 11.9889 13.1444C12.2742 13.1446 12.5531 13.0597 12.79 12.9006C13.0268 12.7415 13.2109 12.5154 13.3186 12.2512L16.0232 5.74999H14.45ZM15.4965 14.808L19.98 10.2195L19.3684 8.75911L14.4719 13.7807C14.272 13.9856 14.1369 14.2448 14.0835 14.5261C14.0301 14.8073 14.0608 15.098 14.1718 15.3619C14.2807 15.624 14.4648 15.848 14.7008 16.0056C14.9369 16.1632 15.2144 16.2473 15.4983 16.2474L15.5 16.25L22.5 16.2325L21.8884 14.7721L15.4983 14.808H15.4965ZM4.02 10.216L4.63162 8.75561L9.52813 13.7772C9.93763 14.1964 10.0557 14.8176 9.82825 15.3584C9.71925 15.6204 9.53511 15.8443 9.29905 16.0019C9.06299 16.1595 8.78557 16.2437 8.50175 16.2439L1.50175 16.2281L1.5 16.2299L2.11163 14.7695L8.50175 14.8062L4.02 10.216Z"
|
||||
@@ -828,5 +840,43 @@
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 40 40" fill="currentColor" id="abacus">
|
||||
<path
|
||||
d="M6.93433 9.57807C6.93433 8.92952 7.46008 8.40376 8.10864 8.40376C8.75719 8.40376 9.28295 8.92952 9.28295 9.57807V16.6239C9.28295 17.2725 8.75719 17.7983 8.10864 17.7983C7.46008 17.7983 6.93433 17.2725 6.93433 16.6239V9.57807Z"
|
||||
></path>
|
||||
<path
|
||||
d="M30.7144 25.1377C30.7144 24.4891 31.2402 23.9634 31.8887 23.9634C32.5373 23.9634 33.063 24.4891 33.063 25.1377V31.0092C33.063 31.6578 32.5373 32.1836 31.8887 32.1836C31.2402 32.1836 30.7144 31.6578 30.7144 31.0093V25.1377Z"
|
||||
></path>
|
||||
<path
|
||||
d="M22.7875 30.422C22.7875 29.7735 23.3132 29.2477 23.9618 29.2477C24.6103 29.2477 25.1361 29.7735 25.1361 30.422V34.8257C25.1361 35.4742 24.6103 36 23.9618 36C23.3132 36 22.7875 35.4742 22.7875 34.8257V30.422Z"
|
||||
></path>
|
||||
<path
|
||||
d="M6.93433 28.367C6.93433 27.7185 7.46008 27.1927 8.10864 27.1927C8.75719 27.1927 9.28295 27.7185 9.28295 28.367V31.5964C9.28295 32.245 8.75719 32.7707 8.10864 32.7707C7.46008 32.7707 6.93433 32.245 6.93433 31.5964V28.367Z"
|
||||
></path>
|
||||
<path
|
||||
d="M14.8617 5.46787C14.8617 4.81931 15.3874 4.29355 16.036 4.29355C16.6845 4.29355 17.2103 4.81931 17.2103 5.46787V9.87153C17.2103 10.5201 16.6845 11.0458 16.036 11.0458C15.3874 11.0458 14.8617 10.5201 14.8617 9.87153V5.46787Z"
|
||||
></path>
|
||||
<path
|
||||
d="M30.7144 9.28437C30.7144 8.63582 31.2402 8.11006 31.8887 8.11006C32.5373 8.11006 33.063 8.63582 33.063 9.28437V13.688C33.063 14.3366 32.5373 14.8623 31.8887 14.8623C31.2402 14.8623 30.7144 14.3366 30.7144 13.688V9.28437Z"
|
||||
></path>
|
||||
<path
|
||||
d="M22.7875 5.17431C22.7875 4.52576 23.3132 4 23.9618 4C24.6103 4 25.1361 4.52576 25.1361 5.17431V18.3853C25.1361 19.0339 24.6103 19.5596 23.9618 19.5596C23.3132 19.5596 22.7875 19.0339 22.7875 18.3853V5.17431Z"
|
||||
></path>
|
||||
<path
|
||||
d="M14.8617 21.3212C14.8617 20.6726 15.3874 20.1469 16.036 20.1469C16.6845 20.1469 17.2103 20.6726 17.2103 21.3212V34.5322C17.2103 35.1807 16.6845 35.7065 16.036 35.7065C15.3874 35.7065 14.8617 35.1807 14.8617 34.5322V21.3212Z"
|
||||
></path>
|
||||
<path
|
||||
d="M10.4577 21.9083C10.4577 23.2055 9.40623 24.257 8.10912 24.257C6.81201 24.257 5.7605 23.2055 5.7605 21.9083C5.7605 20.6112 6.81201 19.5597 8.10912 19.5597C9.40623 19.5597 10.4577 20.6112 10.4577 21.9083Z"
|
||||
></path>
|
||||
<path
|
||||
d="M18.3843 14.8624C18.3843 16.1596 17.3328 17.2111 16.0357 17.2111C14.7386 17.2111 13.6871 16.1596 13.6871 14.8624C13.6871 13.5653 14.7386 12.5138 16.0357 12.5138C17.3328 12.5138 18.3843 13.5653 18.3843 14.8624Z"
|
||||
></path>
|
||||
<path
|
||||
d="M26.3101 24.2569C26.3101 25.554 25.2586 26.6056 23.9615 26.6056C22.6644 26.6056 21.6128 25.554 21.6128 24.2569C21.6128 22.9598 22.6644 21.9083 23.9615 21.9083C25.2586 21.9083 26.3101 22.9598 26.3101 24.2569Z"
|
||||
></path>
|
||||
<path
|
||||
d="M34.2378 19.5597C34.2378 20.8568 33.1863 21.9083 31.8892 21.9083C30.5921 21.9083 29.5406 20.8568 29.5406 19.5597C29.5406 18.2626 30.5921 17.2111 31.8892 17.2111C33.1863 17.2111 34.2378 18.2626 34.2378 19.5597Z"
|
||||
></path>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 238 KiB |
@@ -31,6 +31,7 @@ export const iconNames = [
|
||||
"ollama-cloud",
|
||||
"nvidia",
|
||||
"nebius",
|
||||
"nano-gpt",
|
||||
"morph",
|
||||
"moonshotai",
|
||||
"moonshotai-cn",
|
||||
@@ -54,6 +55,7 @@ export const iconNames = [
|
||||
"google-vertex-anthropic",
|
||||
"github-models",
|
||||
"github-copilot",
|
||||
"friendli",
|
||||
"fireworks-ai",
|
||||
"fastrouter",
|
||||
"deepseek",
|
||||
@@ -73,6 +75,7 @@ export const iconNames = [
|
||||
"alibaba",
|
||||
"alibaba-cn",
|
||||
"aihubmix",
|
||||
"abacus",
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof iconNames)[number]
|
||||
|
||||
@@ -47,9 +47,17 @@
|
||||
--radius-xl: 0.625rem;
|
||||
|
||||
--shadow-xs:
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.06)),
|
||||
0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.08)),
|
||||
0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.1));
|
||||
--shadow-md:
|
||||
0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12);
|
||||
0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.1)),
|
||||
0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.15)),
|
||||
0 1px 2px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.15));
|
||||
--shadow-lg:
|
||||
0 16px 48px -6px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)),
|
||||
0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)),
|
||||
0 1px 2.5px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1));
|
||||
--shadow-xs-border:
|
||||
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
|
||||
@@ -551,23 +551,26 @@ The opencode CLI takes the following global flags.
|
||||
|
||||
OpenCode can be configured using environment variables.
|
||||
|
||||
| Variable | Type | Description |
|
||||
| ------------------------------------- | ------- | ---------------------------------------- |
|
||||
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
|
||||
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
|
||||
| `OPENCODE_CONFIG` | string | Path to config file |
|
||||
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
|
||||
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
|
||||
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
|
||||
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
|
||||
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
|
||||
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
|
||||
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
|
||||
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
|
||||
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
|
||||
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
|
||||
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
|
||||
| Variable | Type | Description |
|
||||
| ------------------------------------- | ------- | ------------------------------------------------ |
|
||||
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
|
||||
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
|
||||
| `OPENCODE_CONFIG` | string | Path to config file |
|
||||
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
|
||||
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
|
||||
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
|
||||
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
|
||||
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
|
||||
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
|
||||
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
|
||||
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
|
||||
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
|
||||
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) |
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
|
||||
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
|
||||
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user