Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications

This commit is contained in:
Shantur Rathore
2026-01-08 20:09:42 +00:00
9 changed files with 388 additions and 75 deletions

View File

@@ -11,6 +11,7 @@ const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const EXIT_WAIT_TIMEOUT_MS = 5000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
@@ -21,6 +22,7 @@ interface ManagerDeps {
}
interface RunningProcess {
id: string
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
@@ -61,9 +63,15 @@ export class BackgroundProcessManager {
const child = spawn("bash", ["-c", command], {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
child.on("exit", () => {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
@@ -91,7 +99,7 @@ export class BackgroundProcessManager {
})
})
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -128,7 +136,7 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -149,7 +157,7 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -255,26 +263,64 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
const pid = child.pid
if (!pid) return
if (process.platform !== "win32") {
try {
process.kill(-pid, signal)
return
} catch {
// Fall back to killing the direct child.
}
}
try {
child.kill(signal)
} catch {
// ignore
}
}
private async waitForExit(running: RunningProcess) {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
running.child.kill("SIGKILL")
let exited = false
const exitPromise = running.exitPromise.finally(() => {
exited = true
})
const killTimeout = setTimeout(() => {
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
}
}, STOP_TIMEOUT_MS)
await running.exitPromise.finally(() => {
resolved = true
clearTimeout(timeout)
})
try {
await Promise.race([
exitPromise,
new Promise<void>((resolve) => {
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
}),
])
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
this.running.delete(running.id)
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
}
} finally {
clearTimeout(killTimeout)
}
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
@@ -27,8 +27,9 @@ const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
@@ -60,9 +77,12 @@ const AlertDialog: Component = () => {
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
open
@@ -98,27 +118,47 @@ const AlertDialog: Component = () => {
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -39,8 +39,7 @@ import {
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
@@ -376,9 +375,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
}
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const instancePaletteCommands = createMemo(() => props.paletteCommands())
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>

View File

@@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -17,6 +17,7 @@ export interface TimelineSegment {
tooltip: string
shortLabel?: string
variant?: "auto" | "manual"
toolPartIds?: string[]
}
interface MessageTimelineProps {
@@ -47,6 +48,7 @@ interface PendingSegment {
toolTitles: string[]
toolTypeLabels: string[]
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean
}
@@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
label,
tooltip,
shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
})
segmentIndex += 1
pending = null
@@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) {
flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
}
return pending!
}
@@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
target.toolTitles.push(getToolTitle(toolPart))
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id)
}
continue
}
@@ -359,9 +365,25 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
@@ -378,7 +400,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}

View File

@@ -9,7 +9,8 @@ import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -36,6 +37,7 @@ export default function PromptInput(props: PromptInputProps) {
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false)
const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention")
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
@@ -560,14 +562,28 @@ export default function PromptInput(props: PromptInputProps) {
const currentAttachments = attachments()
if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell"
// Slash command routing (match OpenCode TUI): only run if the command exists.
const isSlashCandidate = !isShellMode && text.startsWith("/")
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : ""
const commandName = isSlashCandidate ? commandToken.slice(1) : ""
const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : ""
const isKnownSlashCommand =
isSlashCandidate &&
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const refreshHistory = async () => {
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
await addToHistory(props.instanceFolder, historyEntry)
setHistory((prev) => {
const next = [resolvedPrompt, ...prev]
const next = [historyEntry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
@@ -580,12 +596,25 @@ export default function PromptInput(props: PromptInputProps) {
}
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
setPasteCount(0)
setImageCount(0)
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
setPasteCount(0)
setImageCount(0)
setIgnoredAtPositions(new Set<number>())
} else {
syncAttachmentCounters("", currentAttachments)
setIgnoredAtPositions(new Set<number>())
}
setHistoryDraft(null)
if (isKnownSlashCommand) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
@@ -593,10 +622,14 @@ export default function PromptInput(props: PromptInputProps) {
} else {
await props.onSend(resolvedPrompt, [])
}
} else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
} else {
await props.onSend(resolvedPrompt, currentAttachments)
}
void refreshHistory()
if (!isKnownSlashCommand) {
void refreshHistory()
}
} catch (error) {
log.error("Failed to send message:", error)
showAlertDialog("Failed to send message", {
@@ -677,11 +710,27 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null)
const cursorPos = target.selectionStart
// Slash command picker (only when editing the command token: "/<query>")
if (value.startsWith("/") && cursorPos >= 1) {
const firstWhitespaceIndex = value.slice(1).search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
if (cursorPos <= tokenEnd) {
setPickerMode("command")
setAtPosition(0)
setSearchQuery(value.substring(1, cursorPos))
setShowPicker(true)
return
}
}
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
const previousAtPosition = atPosition()
if (lastAtIndex === -1) {
setIgnoredAtPositions(new Set<number>())
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
@@ -698,6 +747,7 @@ export default function PromptInput(props: PromptInputProps) {
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setPickerMode("mention")
setAtPosition(lastAtIndex)
setSearchQuery(textAfterAt)
setShowPicker(true)
@@ -716,9 +766,30 @@ export default function PromptInput(props: PromptInputProps) {
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
},
}
| { type: "command"; command: { name: string; description?: string } },
) {
if (item.type === "agent") {
if (item.type === "command") {
const name = item.command.name
const currentPrompt = prompt()
const afterSlash = currentPrompt.slice(1)
const firstWhitespaceIndex = afterSlash.search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
const before = ""
const after = currentPrompt.substring(tokenEnd)
const newPrompt = before + `/${name} ` + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = `/${name} `.length
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.focus()
}
}, 0)
} else if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some(
@@ -822,7 +893,7 @@ export default function PromptInput(props: PromptInputProps) {
function handlePickerClose() {
const pos = atPosition()
if (pos !== null) {
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowPicker(false)
@@ -959,6 +1030,7 @@ export default function PromptInput(props: PromptInputProps) {
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const commandHint = () => ({ key: "/", text: "for commands" })
const shouldShowOverlay = () => prompt().length === 0
@@ -981,9 +1053,11 @@ export default function PromptInput(props: PromptInputProps) {
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
@@ -1149,6 +1223,11 @@ export default function PromptInput(props: PromptInputProps) {
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>

View File

@@ -1,5 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import { Component, createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
@@ -67,13 +68,18 @@ function mapEntriesToFileItems(entries: { path: string; type: "file" | "director
})
}
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
type PickerItem =
| { type: "agent"; agent: Agent }
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
instanceClient: OpencodeClient | null
searchQuery: string
textareaRef?: HTMLTextAreaElement
@@ -81,6 +87,8 @@ interface UnifiedPickerProps {
}
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const mode = () => props.mode ?? "mention"
const [files, setFiles] = createSignal<FileItem[]>([])
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
@@ -246,6 +254,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return
}
if (mode() !== "mention") {
// Command mode doesn't use file snapshots.
return
}
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
@@ -262,6 +275,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
createEffect(() => {
if (!props.open) return
if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase()
const filtered = query
@@ -275,8 +289,25 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
setFilteredAgents(filtered)
})
const filteredCommands = createMemo(() => {
if (mode() !== "command") return []
const q = props.searchQuery.trim().toLowerCase()
const source = props.commands ?? []
if (!q) return source
return source.filter((cmd) => {
const nameMatch = cmd.name.toLowerCase().includes(q)
const descMatch = (cmd.description ?? "").toLowerCase().includes(q)
return nameMatch || descMatch
})
})
const allItems = (): PickerItem[] => {
const items: PickerItem[] = []
if (mode() === "command") {
filteredCommands().forEach((command) => items.push({ type: "command", command }))
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
files().forEach((file) => items.push({ type: "file", file }))
return items
@@ -329,9 +360,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
})
const commandCount = () => filteredCommands().length
const agentCount = () => filteredAgents().length
const fileCount = () => files().length
const isLoading = () => loadingState() !== "idle"
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
const loadingMessage = () => {
if (loadingState() === "search") {
return "Searching..."
@@ -351,7 +383,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
>
<div class="dropdown-header">
<div class="dropdown-header-title">
Select Agent or File
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
Select Command
</Show>
<Show when={isLoading()}>
<span class="ml-2">{loadingMessage()}</span>
</Show>
@@ -359,11 +393,41 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</div>
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
<Show when={agentCount() === 0 && fileCount() === 0}>
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
<div class="dropdown-empty">No results found</div>
</Show>
<Show when={agentCount() > 0}>
<Show when={mode() === "command" && commandCount() > 0}>
<div class="dropdown-section-header">COMMANDS</div>
<For each={filteredCommands()}>
{(command) => {
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
return (
<div
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "command", command })}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<div class="flex-1">
<div class="text-sm font-medium">/{command.name}</div>
<Show when={command.description}>
<div class="mt-0.5 text-xs" style="color: var(--text-muted)">
{(command.description ?? "").length > 80 ? (command.description ?? "").slice(0, 80) + "..." : command.description}
</div>
</Show>
</div>
</div>
</div>
)
}}
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header">
AGENTS
</div>
@@ -418,7 +482,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={fileCount() > 0}>
<Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header">
FILES
</div>

View File

@@ -1,6 +1,6 @@
import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog } from "../stores/alerts"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
import { getLogger } from "./logger"
@@ -11,15 +11,29 @@ export function commandRequiresArguments(template?: string): boolean {
return /\$(?:\d+|ARGUMENTS)/.test(template)
}
export function promptForCommandArguments(command: SDKCommand): string | null {
export async function promptForCommandArguments(command: SDKCommand): Promise<string | null> {
if (!commandRequiresArguments(command.template)) {
return ""
}
const input = window.prompt(`Arguments for /${command.name}`, "")
if (input === null) {
try {
return await showPromptDialog(`Arguments for /${command.name}`, {
title: "Custom command",
variant: "info",
inputLabel: "Arguments",
inputPlaceholder: "e.g. foo bar",
inputDefaultValue: "",
confirmLabel: "Run",
cancelLabel: "Cancel",
})
} catch (error) {
log.error("Failed to prompt for command arguments", error)
showAlertDialog("Failed to open arguments prompt.", {
title: "Command arguments",
variant: "error",
})
return null
}
return input
}
function formatCommandLabel(name: string): string {
@@ -43,11 +57,11 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
})
return
}
const args = promptForCommandArguments(cmd)
if (args === null) {
return
}
try {
const args = await promptForCommandArguments(cmd)
if (args === null) {
return
}
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
log.error("Failed to run custom command", error)

View File

@@ -3,7 +3,7 @@ import { createSignal } from "solid-js"
export type AlertVariant = "info" | "warning" | "error"
export type AlertDialogState = {
type?: "alert" | "confirm"
type?: "alert" | "confirm" | "prompt"
title?: string
message: string
detail?: string
@@ -12,7 +12,17 @@ export type AlertDialogState = {
cancelLabel?: string
onConfirm?: () => void
onCancel?: () => void
// prompt-only
inputLabel?: string
inputPlaceholder?: string
inputDefaultValue?: string
// confirm-only
resolve?: (value: boolean) => void
// prompt-only
resolvePrompt?: (value: string | null) => void
}
const [alertDialogState, setAlertDialogState] = createSignal<AlertDialogState | null>(null)
@@ -39,6 +49,23 @@ export function showConfirmDialog(message: string, options?: Omit<AlertDialogSta
})
}
export function showPromptDialog(
message: string,
options?: Omit<AlertDialogState, "message" | "type" | "resolve" | "resolvePrompt">,
): Promise<string | null> {
const activeElement = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null
activeElement?.blur()
return new Promise<string | null>((resolvePrompt) => {
setAlertDialogState({
type: "prompt",
message,
...options,
resolvePrompt,
})
})
}
export function dismissAlertDialog() {
setAlertDialogState(null)
}

View File

@@ -146,6 +146,30 @@
background-color: var(--surface-secondary);
}
.message-timeline-segment.message-timeline-segment-permission {
border-color: var(--session-status-permission-fg);
background-color: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
}
.message-timeline-segment.message-timeline-segment-permission:hover,
.message-timeline-segment.message-timeline-segment-permission:focus-visible {
background-color: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
border-color: var(--session-status-permission-fg);
transform: translateY(-1px);
}
.message-timeline-segment-active.message-timeline-segment-permission,
.message-timeline-segment-active.message-timeline-segment-permission:hover,
.message-timeline-segment-active.message-timeline-segment-permission:focus-visible {
background-color: var(--session-status-permission-bg) !important;
border-color: var(--session-status-permission-fg) !important;
color: var(--session-status-permission-fg) !important;
transform: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.message-timeline-compaction-auto {
border-color: var(--session-status-compacting-fg);
background-color: var(--surface-secondary);