Compare commits

...

10 Commits

Author SHA1 Message Date
codenomadbot[bot]
9800afb785 feat(ui): toggle tool call input YAML (#182)
* feat(ui): toggle tool call input yaml

* ui: rename tool input toggle and add IO headers

* ui: add input/output accordions in tool calls

* ui: refine tool IO accordion styling

* ui: remove extra padding around IO sections

* ui: remove semibold from IO headers

* feat(ui): add tool input visibility preference

* fix(ui): scope tool input toggle to current tool call

* ui: left-align tool IO header text

* fix(ui): let palette tool input visibility override per-call

* ui: default tool input visibility to collapsed

* fix(ui): expand read tool calls on error

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-02-19 22:08:41 +00:00
Shantur Rathore
e84adebe61 fix(server): detect OpenCode version via spawn spec 2026-02-19 07:24:14 +00:00
Shantur Rathore
d45a1ff078 Bump to v0.11.3 2026-02-18 19:59:54 +00:00
Shantur Rathore
b4121696bb fix(ui): track worktree context for question replies
Store the originating worktree slug when questions are enqueued and use
the stored worktree client when replying/rejecting from the global
permission center. This ensures question responses are sent through the
correct worktree, matching the behavior already implemented for
permissions.
2026-02-18 19:56:42 +00:00
Shantur Rathore
f75c942162 fix(ui): exclude hidden agents from pickers 2026-02-18 16:00:58 +00:00
Shantur Rathore
127a1f628d feat(server,ui): allow OpenCode directory override via proxy path 2026-02-18 09:43:30 +00:00
Shantur Rathore
859312ba3b feat(ui): add dispose instance and rehydrate
Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
2026-02-18 01:07:52 +00:00
Shantur Rathore
4eaa711f01 fix(ui): make alert dialog scrollable for long errors 2026-02-18 00:27:26 +00:00
Shantur Rathore
c8ff858565 fix(ui): render user message text as markdown
User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors.
2026-02-17 22:44:30 +00:00
Shantur Rathore
6de6ef5a4a Bump to v0.11.2 2026-02-17 18:47:21 +00:00
53 changed files with 1090 additions and 181 deletions

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -11985,7 +11985,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.11.1",
"version": "0.11.3",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12070,7 +12070,7 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.11.1",
"version": "0.11.3",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -12092,7 +12092,8 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1",
"version": "0.11.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.4"
"@opencode-ai/plugin": "1.2.6"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: {
request: FastifyRequest
reply: FastifyReply
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
try {
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
if (extracted.overrideDirectory) {
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
})
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"

View File

@@ -1,7 +1,6 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { spawnSync } from "child_process"
import { buildSpawnSpec } from "../../workspaces/runtime"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
@@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({
})
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.error }
}
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
@@ -283,28 +283,22 @@ export class WorkspaceManager {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
const result = probeBinaryVersion(resolvedPath)
if (result.valid) {
if (result.version) {
this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
return result.version
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
if (result.reported) {
this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
return result.reported
}
return undefined
}
if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
}
return undefined
}

View File

@@ -8,6 +8,8 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const }
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.11.1",
"version": "0.11.3",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.11.1",
"version": "0.11.3",
"private": true,
"license": "MIT",
"type": "module",
@@ -30,7 +30,8 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -72,6 +72,7 @@ const App: Component = () => {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
@@ -402,6 +403,7 @@ const App: Component = () => {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,

View File

@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
const availableAgents = createMemo(() => {
const allAgents = instanceAgents()
if (isChildSession()) {
return allAgents
return allAgents.filter((agent) => !agent.hidden)
}
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
>
<div class="flex-1 min-w-0">
<Select.Value<Agent>>
{(state) => (
{() => (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
</span>
</div>
)}

View File

@@ -115,28 +115,36 @@ const AlertDialog: Component = () => {
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content
class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
tabIndex={-1}
>
<div class="flex items-start gap-3 min-h-0">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0 min-h-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
<div
class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
style={{ "overflow-wrap": "anywhere" }}
>
{payload.message}
{payload.detail && <div class="mt-3">{payload.detail}</div>}
</div>
</Dialog.Description>
</div>
</div>
<Show when={isPrompt}>
<div class="mt-4">
@@ -185,14 +193,14 @@ const AlertDialog: Component = () => {
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
</div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,14 +1,21 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
showDisposeButton?: boolean
}
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : []
})
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return (
<div class="panel">
<div class="panel-header">
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div>
</div>
)

View File

@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -17,16 +16,18 @@ interface MessagePartProps {
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => {
const part = props.part
@@ -57,6 +58,11 @@ interface MessagePartProps {
return ""
}
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
@@ -91,20 +97,28 @@ interface MessagePartProps {
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
if (part.type === "text" && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version
text: (part as any).text,
synthetic: false,
version: (part as { version?: number }).version,
renderCache: (part as any).renderCache,
}
}
return {
id: part.id,
type: "text",
type: "text",
text: "",
synthetic: false
synthetic: false,
}
}
@@ -117,22 +131,18 @@ interface MessagePartProps {
<Switch>
<Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

View File

@@ -1,5 +1,6 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Copy } from "lucide-solid"
import { ArrowRightSquare, Copy } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
@@ -27,7 +28,17 @@ import type {
ToolRendererContext,
ToolScrollHelpers,
} from "./tool-call/types"
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import {
ensureMarkdownContent,
getRelativePath,
getToolIcon,
getToolName,
isToolStateCompleted,
isToolStateError,
isToolStateRunning,
getDefaultToolAction,
readToolStatePayload,
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
@@ -155,12 +166,33 @@ export default function ToolCall(props: ToolCallProps) {
const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") {
const state = toolState()
if (state?.status === "error") {
return true
}
return false
}
return prefExpanded
})
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const toolInputsVisibility = createMemo(() => preferences().toolInputsVisibility || "collapsed")
const [toolInputVisibilityOverride, setToolInputVisibilityOverride] = createSignal<"hidden" | "expanded" | null>(null)
const effectiveToolInputsVisibility = createMemo(() => toolInputVisibilityOverride() ?? toolInputsVisibility())
const isToolInputVisible = createMemo(() => effectiveToolInputsVisibility() !== "hidden")
const inputDefaultExpanded = createMemo(() => effectiveToolInputsVisibility() === "expanded")
const [inputSectionOverride, setInputSectionOverride] = createSignal<boolean | null>(null)
const [outputSectionOverride, setOutputSectionOverride] = createSignal<boolean | null>(null)
const inputSectionExpanded = () => {
const override = inputSectionOverride()
if (override !== null) return override
return inputDefaultExpanded()
}
const outputSectionExpanded = () => {
const override = outputSectionOverride()
if (override !== null) return override
return true
}
const isPermissionActive = createMemo(() => {
const pending = pendingPermission()
@@ -183,6 +215,35 @@ export default function ToolCall(props: ToolCallProps) {
return defaultExpandedForTool()
}
const toolInput = createMemo(() => {
const state = toolState()
return readToolStatePayload(state).input
})
const hasToolInput = createMemo(() => {
const input = toolInput()
return input && Object.keys(input).length > 0
})
const toolInputMarkdown = createMemo(() => {
const input = toolInput()
if (!input || Object.keys(input).length === 0) return null
try {
const yamlText = stringifyYaml(input)
return ensureMarkdownContent(yamlText, "yaml", true)
} catch (error) {
log.error("Failed to convert tool call input to YAML", error)
try {
const jsonText = JSON.stringify(input, null, 2)
return ensureMarkdownContent(jsonText, "json", true)
} catch (nestedError) {
log.error("Failed to stringify tool call input", nestedError)
return null
}
}
})
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const questionDetails = createMemo(() => pendingQuestion()?.request)
@@ -548,6 +609,25 @@ export default function ToolCall(props: ToolCallProps) {
})
}
createEffect(() => {
// When global preference changes, reset per-tool-call overrides so palette changes apply.
toolInputsVisibility()
setToolInputVisibilityOverride(null)
setInputSectionOverride(null)
setOutputSectionOverride(null)
})
const handleToggleInputVisibility = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!expanded()) {
toggle()
}
const currentlyVisible = isToolInputVisible()
setToolInputVisibilityOverride(currentlyVisible ? "hidden" : "expanded")
}
const renderer = createMemo(() => resolveToolRenderer(toolName()))
const { renderAnsiContent } = createAnsiContentRenderer({
@@ -789,6 +869,23 @@ export default function ToolCall(props: ToolCallProps) {
</span>
</button>
<Show when={hasToolInput()}>
<button
type="button"
class="tool-call-header-input"
onClick={handleToggleInputVisibility}
aria-pressed={isToolInputVisible()}
aria-label={
isToolInputVisible()
? t("toolCall.header.hideInputAriaLabel")
: t("toolCall.header.showInputAriaLabel")
}
title={isToolInputVisible() ? t("toolCall.header.hideInputTitle") : t("toolCall.header.showInputTitle")}
>
<ArrowRightSquare class="w-3.5 h-3.5" />
</button>
</Show>
<button
type="button"
class="tool-call-header-copy"
@@ -806,19 +903,79 @@ export default function ToolCall(props: ToolCallProps) {
{expanded() && (
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
{renderPermissionBlock()}
{renderQuestionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
<Show
when={isToolInputVisible() && hasToolInput()}
fallback={
<>
{renderToolBody()}
{renderError()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</>
}
>
<div class="tool-call-io-sections">
<div class="tool-call-io-section">
<button
type="button"
class="tool-call-io-toggle"
aria-expanded={inputSectionExpanded()}
onClick={() => setInputSectionOverride((prev) => {
const current = prev === null ? inputSectionExpanded() : prev
return !current
})}
>
<span class="tool-call-io-title">{t("toolCall.io.input")}</span>
</button>
<Show when={inputSectionExpanded()}>
<div class="tool-call-io-body">
{(() => {
const content = toolInputMarkdown()
if (!content) return null
return renderMarkdownContent({ content, cacheKey: "input" })
})()}
</div>
</Show>
</div>
<div class="tool-call-io-section">
<button
type="button"
class="tool-call-io-toggle"
aria-expanded={outputSectionExpanded()}
onClick={() => setOutputSectionOverride((prev) => {
const current = prev === null ? outputSectionExpanded() : prev
return !current
})}
>
<span class="tool-call-io-title">{t("toolCall.io.output")}</span>
</button>
<Show when={outputSectionExpanded()}>
<div class="tool-call-io-body">
{renderToolBody()}
{renderError()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</div>
</Show>
</div>
</div>
</Show>
{renderPermissionBlock()}
{renderQuestionBlock()}
</div>
)}

View File

@@ -287,13 +287,14 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query
? props.agents.filter(
? visibleAgents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)),
)
: props.agents
: visibleAgents
setFilteredAgents(filtered)
})

View File

@@ -1,6 +1,6 @@
import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
@@ -38,6 +38,7 @@ export interface UseCommandsOptions {
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
@@ -551,6 +552,29 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}",
"commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments",
"commands.toolInputsVisibility.keywords": "tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token Usage Display · {state}",
"commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom",
"infoView.dispose.actions.dispose": "Dispose instance",
"infoView.dispose.actions.disposing": "Disposing...",
"infoView.dispose.confirm.title": "Dispose instance?",
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
"infoView.dispose.confirm.confirmLabel": "Dispose",
"infoView.dispose.confirm.cancelLabel": "Cancel",
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
"infoView.dispose.toast.error": "Failed to dispose instance.",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos",
"commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar",
"commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}",
"commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta",
"commands.toolInputsVisibility.keywords": "herramienta, entradas, argumentos, visibilidad, ocultar, mostrar, expandir, colapsar",
"commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}",
"commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente",
"commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final",
"infoView.dispose.actions.dispose": "Desechar instancia",
"infoView.dispose.actions.disposing": "Desechando...",
"infoView.dispose.confirm.title": "¿Desechar instancia?",
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
"infoView.dispose.confirm.confirmLabel": "Desechar",
"infoView.dispose.confirm.cancelLabel": "Cancelar",
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics",
"commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire",
"commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}",
"commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil",
"commands.toolInputsVisibility.keywords": "outil, entrées, arguments, visibilité, masquer, afficher, développer, réduire",
"commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}",
"commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant",
"commands.tokenUsageDisplay.keywords": "token, usage, coût, stats",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
"infoView.logs.scrollToBottom": "Aller en bas",
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
"infoView.dispose.actions.disposing": "Réinitialisation...",
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
"infoView.dispose.confirm.cancelLabel": "Annuler",
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え",
"commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "ツール入力の表示 · {state}",
"commands.toolInputsVisibility.description": "ツール呼び出しの入力引数の既定の表示状態を設定します",
"commands.toolInputsVisibility.keywords": "ツール, 入力, 引数, 表示, 非表示, 展開, 折りたたみ, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}",
"commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示",
"commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
"infoView.logs.empty.waiting": "サーバー出力を待機中...",
"infoView.logs.scrollToBottom": "最下部へスクロール",
"infoView.dispose.actions.dispose": "インスタンスを破棄",
"infoView.dispose.actions.disposing": "破棄しています...",
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
"infoView.dispose.confirm.confirmLabel": "破棄",
"infoView.dispose.confirm.cancelLabel": "キャンセル",
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию",
"commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть",
"commands.toolInputsVisibility.label": "Видимость входных данных инструмента · {state}",
"commands.toolInputsVisibility.description": "Установить видимость аргументов входа вызовов инструментов по умолчанию",
"commands.toolInputsVisibility.keywords": "инструмент, вход, аргументы, видимость, скрыть, показать, раскрыть, свернуть, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}",
"commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
"infoView.logs.empty.waiting": "Ожидание вывода сервера…",
"infoView.logs.scrollToBottom": "Прокрутить вниз",
"infoView.dispose.actions.dispose": "Сбросить инстанс",
"infoView.dispose.actions.disposing": "Сброс...",
"infoView.dispose.confirm.title": "Сбросить инстанс?",
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
"infoView.dispose.confirm.confirmLabel": "Сбросить",
"infoView.dispose.confirm.cancelLabel": "Отмена",
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "切换诊断输出是否默认展开",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠",
"commands.toolInputsVisibility.label": "工具输入可见性 · {state}",
"commands.toolInputsVisibility.description": "设置工具调用输入参数的默认可见性",
"commands.toolInputsVisibility.keywords": "工具, 输入, 参数, 可见性, 隐藏, 显示, 展开, 折叠, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token 使用显示 · {state}",
"commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
"infoView.logs.empty.waiting": "正在等待服务器输出...",
"infoView.logs.scrollToBottom": "滚动到底部",
"infoView.dispose.actions.dispose": "释放实例",
"infoView.dispose.actions.disposing": "正在释放...",
"infoView.dispose.confirm.title": "要释放实例吗?",
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
"infoView.dispose.confirm.confirmLabel": "释放",
"infoView.dispose.confirm.cancelLabel": "取消",
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
"infoView.dispose.toast.error": "释放实例失败。",
} as const

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",

View File

@@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager {
private clients = new Map<string, OpencodeClient>()
private key(instanceId: string, worktreeSlug: string): string {
return `${instanceId}:${worktreeSlug || "root"}`
private key(instanceId: string, proxyPath: string): string {
return `${instanceId}:${normalizeProxyPath(proxyPath)}`
}
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug)
createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, proxyPath)
const existing = this.clients.get(key)
if (existing) {
return existing
@@ -23,12 +23,12 @@ class SDKManager {
return client
}
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
getClient(instanceId: string, proxyPath: string): OpencodeClient | null {
return this.clients.get(this.key(instanceId, proxyPath)) ?? null
}
destroyClient(instanceId: string, worktreeSlug = "root"): void {
this.clients.delete(this.key(instanceId, worktreeSlug))
destroyClient(instanceId: string, proxyPath: string): void {
this.clients.delete(this.key(instanceId, proxyPath))
}
destroyClientsForInstance(instanceId: string): void {
@@ -46,7 +46,7 @@ class SDKManager {
export type { OpencodeClient }
function buildInstanceBaseUrl(proxyPath: string): string {
export function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/`

View File

@@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent {
}
}
interface ServerInstanceDisposedEvent {
type: "server.instance.disposed"
properties: {
directory: string
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
@@ -74,6 +81,7 @@ type SSEEvent =
| TuiToastEvent
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| ServerInstanceDisposedEvent
| { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus
@@ -173,6 +181,9 @@ class SSEManager {
case "background.process.removed":
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
break
case "server.instance.disposed":
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
break
default:
log.warn("Unknown SSE event type", { type: event.type })
}
@@ -205,6 +216,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): ConnectionStatus | null {

View File

@@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { getQuestionSessionId } from "../types/question"
import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager"
import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
import { serverEvents } from "../lib/server-events"
@@ -18,7 +18,14 @@ import {
fetchProviders,
clearInstanceDraftPrompts,
} from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import {
ensureWorktreesLoaded,
ensureWorktreeMapLoaded,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
reloadWorktreeMap,
reloadWorktrees,
} from "./worktrees"
import { fetchCommands, clearCommands } from "./commands"
import { serverSettings } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
@@ -45,6 +52,8 @@ const permissionSessionCounts = new Map<string, Map<string, number>>()
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
// Track which worktree a question was enqueued under (by question request id).
const questionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
const questionSessionCounts = new Map<string, Map<string, number>>()
const questionEnqueuedAt = new Map<string, number>()
@@ -76,6 +85,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
const MAX_LOG_ENTRIES = 1000
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
const pendingRehydrations = new Map<string, Promise<void>>()
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
const existing = instances().get(descriptor.id)
return {
@@ -228,10 +240,15 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
}
}
async function hydrateInstanceData(instanceId: string) {
async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) {
try {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
if (options?.force) {
await reloadWorktrees(instanceId)
await reloadWorktreeMap(instanceId)
} else {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
}
await fetchSessions(instanceId)
await fetchAgents(instanceId)
await fetchProviders(instanceId)
@@ -246,6 +263,91 @@ async function hydrateInstanceData(instanceId: string) {
}
}
async function postInstanceDispose(instanceId: string): Promise<boolean> {
const instance = instances().get(instanceId)
if (!instance?.proxyPath) {
throw new Error("Instance not ready")
}
const baseUrl = buildInstanceBaseUrl(instance.proxyPath)
const url = new URL("instance/dispose", baseUrl)
const response = await fetch(url.toString(), {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Dispose request failed with ${response.status}`)
}
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = await response.json().catch(() => undefined)
if (typeof data === "boolean") return data
if (data && typeof data === "object" && "data" in (data as any)) {
return Boolean((data as any).data)
}
return Boolean(data)
}
const text = await response.text().catch(() => "")
if (text.trim() === "true") return true
if (text.trim() === "false") return false
return Boolean(text)
}
async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise<void> {
if (pendingRehydrations.has(instanceId)) {
return pendingRehydrations.get(instanceId)
}
const promise = (async () => {
const instance = instances().get(instanceId)
if (!instance?.client) {
return
}
log.info("Rehydrating instance", { instanceId, reason: options?.reason })
clearCacheForInstance(instanceId)
clearCommands(instanceId)
clearInstanceMetadata(instanceId)
clearInstanceDraftPrompts(instanceId)
clearPermissionQueue(instanceId)
clearQuestionQueue(instanceId)
await hydrateInstanceData(instanceId, { force: true })
})().finally(() => {
pendingRehydrations.delete(instanceId)
})
pendingRehydrations.set(instanceId, promise)
return promise
}
async function disposeInstance(instanceId: string): Promise<boolean> {
if (pendingDisposeRequests.has(instanceId)) {
return pendingDisposeRequests.get(instanceId)!
}
const promise = (async () => {
const ok = await postInstanceDispose(instanceId)
if (ok) {
await rehydrateInstance(instanceId, { reason: "disposed" })
}
return ok
})().finally(() => {
pendingDisposeRequests.delete(instanceId)
})
pendingDisposeRequests.set(instanceId, promise)
return promise
}
void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
@@ -777,6 +879,16 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void
if (sessionId) {
incrementQuestionSessionPendingCount(instanceId, sessionId)
setSessionPendingQuestion(instanceId, sessionId, true)
// Record the worktree slug at the time the question is enqueued.
// This is used to respond in the same worktree context even from the global permission center.
const slug = getWorktreeSlugForSession(instanceId, sessionId)
let byQuestionId = questionWorktreeSlugByInstance.get(instanceId)
if (!byQuestionId) {
byQuestionId = new Map()
questionWorktreeSlugByInstance.set(instanceId, byQuestionId)
}
byQuestionId.set(request.id, slug)
}
}
@@ -797,6 +909,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void {
})
questionEnqueuedAt.delete(requestId)
questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId)
recomputeActiveInterruption(instanceId)
if (removedSessionId) {
@@ -809,6 +922,7 @@ function clearQuestionQueue(instanceId: string): void {
for (const request of getQuestionQueue(instanceId)) {
questionEnqueuedAt.delete(request.id)
}
questionWorktreeSlugByInstance.delete(instanceId)
setQuestionQueues((prev) => {
const next = new Map(prev)
@@ -834,7 +948,7 @@ function setActiveQuestionIdForInstance(instanceId: string, requestId: string):
async function sendQuestionReply(
instanceId: string,
_sessionId: string,
sessionId: string,
requestId: string,
answers: string[][],
): Promise<void> {
@@ -844,8 +958,13 @@ async function sendQuestionReply(
}
try {
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
const worktreeSlug = stored ?? fallback
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData(
instance.client.question.reply({
client.question.reply({
requestID: requestId,
answers,
}),
@@ -859,15 +978,20 @@ async function sendQuestionReply(
}
}
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
async function sendQuestionReject(instanceId: string, sessionId: string, requestId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
throw new Error("Instance not ready")
}
try {
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
const worktreeSlug = stored ?? fallback
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData(
instance.client.question.reject({
client.question.reject({
requestID: requestId,
}),
"question.reject",
@@ -939,6 +1063,30 @@ sseManager.onLspUpdated = async (instanceId) => {
}
}
sseManager.onInstanceDisposed = (sourceInstanceId, event) => {
const directory = event?.properties?.directory
if (!directory) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
const matchingInstanceIds: string[] = []
for (const instance of instances().values()) {
if (instance.folder === directory) {
matchingInstanceIds.push(instance.id)
}
}
if (matchingInstanceIds.length === 0) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
for (const instanceId of matchingInstanceIds) {
void rehydrateInstance(instanceId, { reason: "disposed" })
}
}
async function acknowledgeDisconnectedInstance(): Promise<void> {
const pending = disconnectedInstance()
if (!pending) {
@@ -995,4 +1143,5 @@ export {
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,
disposeInstance,
}

View File

@@ -25,6 +25,7 @@ export interface ModelPreference {
export type DiffViewMode = "split" | "unified"
export type ExpansionPreference = "expanded" | "collapsed"
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
export type ListeningMode = "local" | "all"
export interface UiSettings {
@@ -37,6 +38,7 @@ export interface UiSettings {
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
toolInputsVisibility: ToolInputsVisibilityPreference
showUsageMetrics: boolean
autoCleanupBlankSessions: boolean
@@ -108,6 +110,7 @@ const defaultUiSettings: UiSettings = {
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
toolInputsVisibility: "collapsed",
showUsageMetrics: true,
autoCleanupBlankSessions: true,
@@ -130,6 +133,10 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultUiSettings.diagnosticsExpansion,
toolInputsVisibility:
sanitized.toolInputsVisibility === "hidden" || sanitized.toolInputsVisibility === "collapsed" || sanitized.toolInputsVisibility === "expanded"
? sanitized.toolInputsVisibility
: defaultUiSettings.toolInputsVisibility,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultUiSettings.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled,
@@ -439,6 +446,11 @@ function setDiagnosticsExpansion(mode: ExpansionPreference): void {
updateUiSettings({ diagnosticsExpansion: mode })
}
function setToolInputsVisibility(mode: ToolInputsVisibilityPreference): void {
if (preferences().toolInputsVisibility === mode) return
updateUiSettings({ toolInputsVisibility: mode })
}
function setThinkingBlocksExpansion(mode: ExpansionPreference): void {
if (preferences().thinkingBlocksExpansion === mode) return
updateUiSettings({ thinkingBlocksExpansion: mode })
@@ -536,6 +548,7 @@ interface ConfigContextValue {
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
setToolInputsVisibility: typeof setToolInputsVisibility
// instance scoped
setAgentModelPreference: typeof setAgentModelPreference
@@ -579,6 +592,7 @@ const configContextValue: ConfigContextValue = {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
setAgentModelPreference,
getAgentModelPreference,
}

View File

@@ -526,6 +526,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
name: agent.name,
description: agent.description || "",
mode: agent.mode,
hidden: agent.hidden,
model: agent.model?.modelID
? {
providerId: agent.model.providerID || "",

View File

@@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string {
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
}
function encodeBase64UrlUtf8(input: string): string {
const bytes = new TextEncoder().encode(input)
// Convert bytes -> base64 (btoa expects a binary string)
let binary = ""
const chunkSize = 0x8000
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
const base64 = btoa(binary)
// base64 -> base64url (strip padding)
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
const base = buildWorktreeProxyPath(instanceId, slug)
const encoded = encodeBase64UrlUtf8(directory)
return `${base}/__dir/${encoded}`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root")
}
@@ -359,7 +385,9 @@ export {
removeParentSessionMapping,
getWorktreeSlugForDirectory,
buildWorktreeProxyPath,
buildWorktreeProxyPathWithDirectoryOverride,
getOrCreateWorktreeClient,
getOrCreateWorktreeClientWithDirectoryOverride,
getRootClient,
createWorktree,
deleteWorktree,

View File

@@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) {
button.button-tertiary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
.button-danger,
button.button-danger {
@apply px-6 py-3 text-base rounded-lg;
background-color: var(--button-danger-bg);
color: var(--button-danger-text);
border-color: var(--button-danger-bg);
}
.button-danger:hover:not(:disabled),
button.button-danger:hover:not(:disabled) {
background-color: var(--button-danger-hover-bg);
border-color: var(--button-danger-hover-bg);
}
.button-danger:focus-visible,
button.button-danger:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
/* Smaller sizing variant for destructive actions in tight spaces. */
.button-danger.button-small,
button.button-danger.button-small {
@apply px-4 py-2 text-sm;
}

View File

@@ -9,6 +9,9 @@
line-height: var(--line-height-normal);
font-weight: var(--font-weight-regular);
color: var(--text-primary);
/* Message containers may use `whitespace-pre-wrap` for plain text.
Markdown should always match assistant rendering (normal whitespace). */
white-space: normal;
}
.markdown-body p,
@@ -28,7 +31,7 @@
.markdown-body h5,
.markdown-body h6 {
font-family: inherit;
color: inherit;
color: var(--markdown-heading-color, inherit);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
margin-top: 0.9em;
@@ -71,7 +74,7 @@
.markdown-body strong {
font-weight: var(--font-weight-regular);
color: var(--message-assistant-border);
color: var(--markdown-accent, var(--message-assistant-border));
}
.markdown-body em {

View File

@@ -1,6 +1,10 @@
/* Message item base styles */
.message-item-base {
@apply flex flex-col gap-2 p-3 w-full;
/* Markdown rendering uses these to theme emphasis + headings per message role. */
--markdown-accent: var(--message-user-border);
--markdown-heading-color: var(--message-user-border);
}
.message-item-header {
@@ -71,6 +75,9 @@
padding: 0.6rem 0.65rem;
margin-top: 0;
margin-bottom: 0;
--markdown-accent: var(--message-assistant-border);
--markdown-heading-color: var(--text-primary);
}
.message-item-base:not(.assistant-message) {

View File

@@ -87,6 +87,7 @@
@apply flex items-stretch w-full;
background-color: transparent;
color: var(--text-primary);
border-bottom: 1px solid var(--tool-call-border-color);
}
.tool-call-header:hover {
@@ -127,11 +128,30 @@
cursor: pointer;
}
.tool-call-header-input {
@apply inline-flex items-center justify-center;
background-color: transparent;
border: none;
color: var(--text-secondary);
padding: 0 0.5rem;
border-radius: 0;
cursor: pointer;
}
.tool-call-header-copy:hover {
background-color: transparent;
color: var(--text-primary);
}
.tool-call-header-input:hover {
background-color: transparent;
color: var(--text-primary);
}
.tool-call-header-input[aria-pressed="true"] {
color: var(--text-primary);
}
.tool-call-header-status {
@apply inline-flex items-center justify-center;
font-size: 0.95rem;
@@ -213,6 +233,63 @@
font-size: var(--font-size-xs);
}
.tool-call-io-sections {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: 0;
}
.tool-call-io-section {
border: 1px solid var(--tool-call-border-color);
overflow: hidden;
background-color: transparent;
border-radius: 0;
}
.tool-call-io-toggle {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.5rem;
background-color: var(--surface-secondary);
border: none;
border-bottom: 1px solid var(--tool-call-border-color);
width: 100%;
text-align: left;
font-size: 0.875rem;
font-weight: normal;
color: var(--text-primary);
cursor: pointer;
}
.tool-call-io-toggle::before {
content: "▶";
font-size: 11px;
margin-right: 0.35rem;
color: var(--text-secondary);
}
.tool-call-io-toggle[aria-expanded="true"]::before {
content: "▼";
}
.tool-call-io-title {
font-weight: inherit;
color: inherit;
}
.tool-call-io-body {
background-color: var(--surface-code);
}
/* IO sections provide the outer frame; avoid double borders on markdown frames. */
.tool-call-io-body .tool-call-markdown {
border: none;
}
.tool-call-markdown {
background-color: var(--surface-code);
/* Keep a visible frame around the scroll viewport (not the content). */

View File

@@ -64,6 +64,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary) {
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
@@ -74,6 +76,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):focus-visible {
outline: none;
@@ -84,6 +88,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):disabled {
@apply cursor-not-allowed opacity-50;

View File

@@ -68,6 +68,7 @@ export interface Agent {
name: string
description: string
mode: string
hidden?: boolean
model?: {
providerId: string
modelId: string

Binary file not shown.

51
temp/package/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "./script/build.ts"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
},
"./server": {
"import": "./dist/server.js",
"types": "./dist/server.d.ts"
},
"./v2": {
"import": "./dist/v2/index.js",
"types": "./dist/v2/index.d.ts"
},
"./v2/client": {
"import": "./dist/v2/client.js",
"types": "./dist/v2/client.d.ts"
},
"./v2/server": {
"import": "./dist/v2/server.js",
"types": "./dist/v2/server.d.ts"
}
},
"files": [
"dist"
],
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "22.0.2",
"@types/node": "22.13.9",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1"
},
"dependencies": {},
"publishConfig": {
"directory": "dist"
}
}