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 INDEX_FILE = "index.json"
|
||||||
const OUTPUT_FILE = "output.txt"
|
const OUTPUT_FILE = "output.txt"
|
||||||
const STOP_TIMEOUT_MS = 2000
|
const STOP_TIMEOUT_MS = 2000
|
||||||
|
const EXIT_WAIT_TIMEOUT_MS = 5000
|
||||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface ManagerDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RunningProcess {
|
interface RunningProcess {
|
||||||
|
id: string
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
@@ -61,9 +63,15 @@ export class BackgroundProcessManager {
|
|||||||
const child = spawn("bash", ["-c", command], {
|
const child = spawn("bash", ["-c", command], {
|
||||||
cwd: workspace.path,
|
cwd: workspace.path,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", () => {
|
||||||
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: BackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
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
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +136,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +157,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,26 +263,64 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeWorkspaceDir(workspaceId)
|
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) {
|
private async waitForExit(running: RunningProcess) {
|
||||||
let resolved = false
|
let exited = false
|
||||||
const timeout = setTimeout(() => {
|
const exitPromise = running.exitPromise.finally(() => {
|
||||||
if (!resolved) {
|
exited = true
|
||||||
running.child.kill("SIGKILL")
|
})
|
||||||
|
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
if (!exited) {
|
||||||
|
this.killProcessTree(running.child, "SIGKILL")
|
||||||
}
|
}
|
||||||
}, STOP_TIMEOUT_MS)
|
}, STOP_TIMEOUT_MS)
|
||||||
|
|
||||||
await running.exitPromise.finally(() => {
|
try {
|
||||||
resolved = true
|
await Promise.race([
|
||||||
clearTimeout(timeout)
|
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 {
|
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||||
if (code === null) return "stopped"
|
if (code === null) return "stopped"
|
||||||
if (code === 0) return "stopped"
|
if (code === 0) return "stopped"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
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 { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||||
import type { AlertVariant, AlertDialogState } 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()
|
const current = payload ?? alertDialogState()
|
||||||
|
|
||||||
if (current?.type === "confirm") {
|
if (current?.type === "confirm") {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
current.onConfirm?.()
|
current.onConfirm?.()
|
||||||
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
|
|||||||
current.onCancel?.()
|
current.onCancel?.()
|
||||||
}
|
}
|
||||||
current.resolve?.(confirmed)
|
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?.()
|
current?.onConfirm?.()
|
||||||
}
|
}
|
||||||
dismissAlertDialog()
|
dismissAlertDialog()
|
||||||
@@ -60,9 +77,12 @@ const AlertDialog: Component = () => {
|
|||||||
const accent = variantAccent[variant]
|
const accent = variantAccent[variant]
|
||||||
const title = payload.title || accent.fallbackTitle
|
const title = payload.title || accent.fallbackTitle
|
||||||
const isConfirm = payload.type === "confirm"
|
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 cancelLabel = payload.cancelLabel || "Cancel"
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@@ -98,27 +118,47 @@ const AlertDialog: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<Show when={isPrompt}>
|
||||||
{isConfirm && (
|
<div class="mt-4">
|
||||||
<button
|
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
type="button"
|
{payload.inputLabel || "Arguments"}
|
||||||
class="button-secondary"
|
</label>
|
||||||
onClick={() => dismiss(false, payload)}
|
<input
|
||||||
>
|
class="modal-search-input mt-2"
|
||||||
{cancelLabel}
|
value={inputValue()}
|
||||||
</button>
|
placeholder={payload.inputPlaceholder || ""}
|
||||||
)}
|
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||||
<button
|
onKeyDown={(e) => {
|
||||||
type="button"
|
if (e.key === "Enter") {
|
||||||
class="button-primary"
|
e.preventDefault()
|
||||||
ref={(el) => {
|
dismiss(true, payload, inputValue())
|
||||||
primaryButtonRef = el
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => dismiss(true, payload)}
|
/>
|
||||||
>
|
</div>
|
||||||
{confirmLabel}
|
</Show>
|
||||||
</button>
|
|
||||||
</div>
|
<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>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ import {
|
|||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import { clearSessionRenderCache } from "../message-block"
|
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 { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||||
import SessionList from "../session-list"
|
import SessionList from "../session-list"
|
||||||
import KeyboardHint from "../keyboard-hint"
|
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())
|
||||||
|
|
||||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
|
||||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||||
|
|
||||||
const keyboardShortcuts = createMemo(() =>
|
const keyboardShortcuts = createMemo(() =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message"
|
|||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
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"
|
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export interface TimelineSegment {
|
|||||||
tooltip: string
|
tooltip: string
|
||||||
shortLabel?: string
|
shortLabel?: string
|
||||||
variant?: "auto" | "manual"
|
variant?: "auto" | "manual"
|
||||||
|
toolPartIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageTimelineProps {
|
interface MessageTimelineProps {
|
||||||
@@ -47,6 +48,7 @@ interface PendingSegment {
|
|||||||
toolTitles: string[]
|
toolTitles: string[]
|
||||||
toolTypeLabels: string[]
|
toolTypeLabels: string[]
|
||||||
toolIcons: string[]
|
toolIcons: string[]
|
||||||
|
toolPartIds: string[]
|
||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
label,
|
label,
|
||||||
tooltip,
|
tooltip,
|
||||||
shortLabel,
|
shortLabel,
|
||||||
|
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
pending = null
|
pending = null
|
||||||
@@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||||
if (!pending || pending.type !== type) {
|
if (!pending || pending.type !== type) {
|
||||||
flushPending()
|
flushPending()
|
||||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
|
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||||
}
|
}
|
||||||
return pending!
|
return pending!
|
||||||
}
|
}
|
||||||
@@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
target.toolTitles.push(getToolTitle(toolPart))
|
target.toolTitles.push(getToolTitle(toolPart))
|
||||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
||||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,9 +365,25 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
{(segment) => {
|
{(segment) => {
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
const isActive = () => props.activeMessageId === segment.messageId
|
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 = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
|
if (hasActivePermission()) {
|
||||||
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
}
|
}
|
||||||
if (segment.type === "compaction") {
|
if (segment.type === "compaction") {
|
||||||
@@ -378,7 +400,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
type="button"
|
type="button"
|
||||||
data-variant={segment.variant}
|
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-current={isActive() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { Attachment } from "../types/attachment"
|
|||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
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 { showAlertDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -36,6 +37,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
const [showPicker, setShowPicker] = createSignal(false)
|
const [showPicker, setShowPicker] = createSignal(false)
|
||||||
|
const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention")
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||||
const [isDragging, setIsDragging] = createSignal(false)
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
@@ -560,14 +562,28 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const currentAttachments = attachments()
|
const currentAttachments = attachments()
|
||||||
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
||||||
|
|
||||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
|
||||||
const isShellMode = mode() === "shell"
|
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 () => {
|
const refreshHistory = async () => {
|
||||||
try {
|
try {
|
||||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
await addToHistory(props.instanceFolder, historyEntry)
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const next = [resolvedPrompt, ...prev]
|
const next = [historyEntry, ...prev]
|
||||||
if (next.length > HISTORY_LIMIT) {
|
if (next.length > HISTORY_LIMIT) {
|
||||||
next.length = HISTORY_LIMIT
|
next.length = HISTORY_LIMIT
|
||||||
}
|
}
|
||||||
@@ -580,12 +596,25 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
clearAttachments(props.instanceId, props.sessionId)
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||||
setPasteCount(0)
|
if (!isKnownSlashCommand) {
|
||||||
setImageCount(0)
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
|
setPasteCount(0)
|
||||||
|
setImageCount(0)
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
} else {
|
||||||
|
syncAttachmentCounters("", currentAttachments)
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
}
|
||||||
|
|
||||||
setHistoryDraft(null)
|
setHistoryDraft(null)
|
||||||
|
|
||||||
|
if (isKnownSlashCommand) {
|
||||||
|
// Record attempted slash commands even if execution fails.
|
||||||
|
void refreshHistory()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isShellMode) {
|
if (isShellMode) {
|
||||||
if (props.onRunShell) {
|
if (props.onRunShell) {
|
||||||
@@ -593,10 +622,14 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
|
} else if (isKnownSlashCommand) {
|
||||||
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
void refreshHistory()
|
if (!isKnownSlashCommand) {
|
||||||
|
void refreshHistory()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to send message:", error)
|
log.error("Failed to send message:", error)
|
||||||
showAlertDialog("Failed to send message", {
|
showAlertDialog("Failed to send message", {
|
||||||
@@ -677,11 +710,27 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setHistoryDraft(null)
|
setHistoryDraft(null)
|
||||||
|
|
||||||
const cursorPos = target.selectionStart
|
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 textBeforeCursor = value.substring(0, cursorPos)
|
||||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||||
|
|
||||||
const previousAtPosition = atPosition()
|
const previousAtPosition = atPosition()
|
||||||
|
|
||||||
|
|
||||||
if (lastAtIndex === -1) {
|
if (lastAtIndex === -1) {
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||||
@@ -698,6 +747,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||||
if (!ignoredAtPositions().has(lastAtIndex)) {
|
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||||
|
setPickerMode("mention")
|
||||||
setAtPosition(lastAtIndex)
|
setAtPosition(lastAtIndex)
|
||||||
setSearchQuery(textAfterAt)
|
setSearchQuery(textAfterAt)
|
||||||
setShowPicker(true)
|
setShowPicker(true)
|
||||||
@@ -716,9 +766,30 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
| {
|
| {
|
||||||
type: "file"
|
type: "file"
|
||||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
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 agentName = item.agent.name
|
||||||
const existingAttachments = attachments()
|
const existingAttachments = attachments()
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -822,7 +893,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
function handlePickerClose() {
|
function handlePickerClose() {
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
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 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
|
const shouldShowOverlay = () => prompt().length === 0
|
||||||
|
|
||||||
@@ -981,9 +1053,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
<Show when={showPicker() && instance()}>
|
<Show when={showPicker() && instance()}>
|
||||||
<UnifiedPicker
|
<UnifiedPicker
|
||||||
open={showPicker()}
|
open={showPicker()}
|
||||||
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
searchQuery={searchQuery()}
|
searchQuery={searchQuery()}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
@@ -1149,6 +1223,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
<span class="prompt-overlay-text">
|
<span class="prompt-overlay-text">
|
||||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||||
</span>
|
</span>
|
||||||
|
<Show when={mode() !== "shell"}>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
<Show when={mode() === "shell"}>
|
<Show when={mode() === "shell"}>
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
</Show>
|
</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 { Agent } from "../types/session"
|
||||||
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { getLogger } from "../lib/logger"
|
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 {
|
interface UnifiedPickerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem) => void
|
onSelect: (item: PickerItem) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
|
commands?: SDKCommand[]
|
||||||
instanceClient: OpencodeClient | null
|
instanceClient: OpencodeClient | null
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
textareaRef?: HTMLTextAreaElement
|
textareaRef?: HTMLTextAreaElement
|
||||||
@@ -81,6 +87,8 @@ interface UnifiedPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||||
|
const mode = () => props.mode ?? "mention"
|
||||||
|
|
||||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
@@ -246,6 +254,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode() !== "mention") {
|
||||||
|
// Command mode doesn't use file snapshots.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||||
const queryChanged = lastQuery !== props.searchQuery
|
const queryChanged = lastQuery !== props.searchQuery
|
||||||
|
|
||||||
@@ -262,6 +275,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
|
if (mode() !== "mention") return
|
||||||
|
|
||||||
const query = props.searchQuery.toLowerCase()
|
const query = props.searchQuery.toLowerCase()
|
||||||
const filtered = query
|
const filtered = query
|
||||||
@@ -275,8 +289,25 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
setFilteredAgents(filtered)
|
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 allItems = (): PickerItem[] => {
|
||||||
const items: 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 }))
|
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||||
files().forEach((file) => items.push({ type: "file", file }))
|
files().forEach((file) => items.push({ type: "file", file }))
|
||||||
return items
|
return items
|
||||||
@@ -329,9 +360,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const commandCount = () => filteredCommands().length
|
||||||
const agentCount = () => filteredAgents().length
|
const agentCount = () => filteredAgents().length
|
||||||
const fileCount = () => files().length
|
const fileCount = () => files().length
|
||||||
const isLoading = () => loadingState() !== "idle"
|
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
|
||||||
const loadingMessage = () => {
|
const loadingMessage = () => {
|
||||||
if (loadingState() === "search") {
|
if (loadingState() === "search") {
|
||||||
return "Searching..."
|
return "Searching..."
|
||||||
@@ -351,7 +383,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="dropdown-header">
|
<div class="dropdown-header">
|
||||||
<div class="dropdown-header-title">
|
<div class="dropdown-header-title">
|
||||||
Select Agent or File
|
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
|
||||||
|
Select Command
|
||||||
|
</Show>
|
||||||
<Show when={isLoading()}>
|
<Show when={isLoading()}>
|
||||||
<span class="ml-2">{loadingMessage()}</span>
|
<span class="ml-2">{loadingMessage()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -359,11 +393,41 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
<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>
|
<div class="dropdown-empty">No results found</div>
|
||||||
</Show>
|
</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">
|
<div class="dropdown-section-header">
|
||||||
AGENTS
|
AGENTS
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +482,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={fileCount() > 0}>
|
<Show when={mode() === "mention" && fileCount() > 0}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
FILES
|
FILES
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Command } from "./commands"
|
import type { Command } from "./commands"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
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 { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
@@ -11,15 +11,29 @@ export function commandRequiresArguments(template?: string): boolean {
|
|||||||
return /\$(?:\d+|ARGUMENTS)/.test(template)
|
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)) {
|
if (!commandRequiresArguments(command.template)) {
|
||||||
return ""
|
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 null
|
||||||
}
|
}
|
||||||
return input
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCommandLabel(name: string): string {
|
function formatCommandLabel(name: string): string {
|
||||||
@@ -43,11 +57,11 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const args = promptForCommandArguments(cmd)
|
|
||||||
if (args === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
const args = await promptForCommandArguments(cmd)
|
||||||
|
if (args === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to run custom command", 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 AlertVariant = "info" | "warning" | "error"
|
||||||
|
|
||||||
export type AlertDialogState = {
|
export type AlertDialogState = {
|
||||||
type?: "alert" | "confirm"
|
type?: "alert" | "confirm" | "prompt"
|
||||||
title?: string
|
title?: string
|
||||||
message: string
|
message: string
|
||||||
detail?: string
|
detail?: string
|
||||||
@@ -12,7 +12,17 @@ export type AlertDialogState = {
|
|||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
|
|
||||||
|
// prompt-only
|
||||||
|
inputLabel?: string
|
||||||
|
inputPlaceholder?: string
|
||||||
|
inputDefaultValue?: string
|
||||||
|
|
||||||
|
// confirm-only
|
||||||
resolve?: (value: boolean) => void
|
resolve?: (value: boolean) => void
|
||||||
|
|
||||||
|
// prompt-only
|
||||||
|
resolvePrompt?: (value: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const [alertDialogState, setAlertDialogState] = createSignal<AlertDialogState | null>(null)
|
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() {
|
export function dismissAlertDialog() {
|
||||||
setAlertDialogState(null)
|
setAlertDialogState(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,30 @@
|
|||||||
background-color: var(--surface-secondary);
|
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 {
|
.message-timeline-compaction-auto {
|
||||||
border-color: var(--session-status-compacting-fg);
|
border-color: var(--session-status-compacting-fg);
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
|
|||||||
Reference in New Issue
Block a user