Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user