From f01a06d85b7f479fcdbdf4f99141fd74a978a211 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Wed, 7 Jan 2026 21:44:43 +0800 Subject: [PATCH 01/17] feat: add centralized permission notification system for agent/subagent requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a unified permission notification UI that adapts to different runtime environments (Electron desktop vs web browser) with distinct visual presentations. ## What Changed ### New Components - `permission-notification-banner.tsx`: Adaptive notification component * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge * Web browser (portrait): Circular indicator badge showing pending count - `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions * Displays permission type, detailed message, and diff viewer for file changes * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close) * Queue management with "X of Y" counter for multiple pending permissions - `permission-notification.css`: Comprehensive styling with pulsing animations ### Integration - Updated `instance-shell2.tsx`: * Added banner to desktop center toolbar (next to Command Palette) * Added banner to mobile/phone layout center section * Added modal component for permission approval workflow - Updated `controls.css`: Imported new permission notification styles ## Why This Change **Before**: Permission requests had no visual indicator in the UI, making it difficult for users to know when agent/subagent actions required approval. **After**: Users receive clear, persistent visual notifications with: - Pulsing animation to draw attention - Environment-appropriate UI (full banner on desktop, compact badge on web) - Click-to-review workflow with full permission details ## Benefits 1. **Better UX**: Users immediately see when permissions need approval 2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts 3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management 4. **Queue Management**: Handles multiple pending permissions gracefully 5. **Contextual Information**: Shows diffs for file changes, permission types, etc. ## Impact - **No Breaking Changes**: Purely additive feature - **Build**: ✅ Verified successful build - **Testing**: ✅ Tested in Electron app and web browser --- .../components/instance/instance-shell2.tsx | 123 +++--- .../components/permission-approval-modal.tsx | 219 ++++++++++ .../permission-notification-banner.tsx | 57 +++ .../components/permission-notification.css | 395 ++++++++++++++++++ packages/ui/src/styles/controls.css | 1 + 5 files changed, 745 insertions(+), 50 deletions(-) create mode 100644 packages/ui/src/components/permission-approval-modal.tsx create mode 100644 packages/ui/src/components/permission-notification-banner.tsx create mode 100644 packages/ui/src/styles/components/permission-notification.css diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 454ffdf0..6931ef8f 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -50,6 +50,8 @@ import InstanceServiceStatus from "../instance-service-status" import AgentSelector from "../agent-selector" import ModelSelector from "../model-selector" import CommandPalette from "../command-palette" +import PermissionNotificationBanner from "../permission-notification-banner" +import PermissionApprovalModal from "../permission-approval-modal" import Kbd from "../kbd" import { TodoListView } from "../tool-call/renderers/todo" import ContextUsagePanel from "../session/context-usage-panel" @@ -141,6 +143,7 @@ const InstanceShell2: Component = (props) => { ]) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) + const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -654,7 +657,7 @@ const InstanceShell2: Component = (props) => { }) type DrawerViewState = "pinned" | "floating-open" | "floating-closed" - + const leftDrawerState = createMemo(() => { if (leftPinned()) return "pinned" @@ -695,7 +698,7 @@ const InstanceShell2: Component = (props) => { - const pinLeftDrawer = () => { + const pinLeftDrawer = () => { blurIfInside(leftDrawerContentEl()) batch(() => { setLeftPinned(true) @@ -814,18 +817,18 @@ const InstanceShell2: Component = (props) => { -
- - (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} - > - {leftPinned() ? : } - - -
+
+ + (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + + +
@@ -1240,6 +1243,13 @@ const InstanceShell2: Component = (props) => { > + +
+ setPermissionModalOpen(true)} + /> +
= (props) => { } > -
- - {leftAppBarButtonIcon()} - +
+ + {leftAppBarButtonIcon()} + - -
- Used - {formattedUsedTokens()} -
-
- Avail - {formattedAvailableTokens()} -
-
-
+ +
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+
-
- - - - +
+ + + + + +
+ setPermissionModalOpen(true)} + />
+
@@ -1429,6 +1446,12 @@ const InstanceShell2: Component = (props) => { process={selectedBackgroundProcess()} onClose={closeBackgroundOutput} /> + + setPermissionModalOpen(false)} + /> ) } diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx new file mode 100644 index 00000000..24e701dd --- /dev/null +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -0,0 +1,219 @@ +import { Show, createSignal, createMemo, createEffect, onCleanup, type Component } from "solid-js" +import type { PermissionRequestLike } from "../types/permission" +import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle } from "../types/permission" +import { getPermissionQueue, activePermissionId, sendPermissionResponse } from "../stores/instances" +import { ToolCallDiffViewer } from "./diff-viewer" +import { useTheme } from "../lib/theme" +import { getRelativePath } from "./tool-call/utils" +import { getLogger } from "../lib/logger" + +const log = getLogger("session") + +interface PermissionApprovalModalProps { + instanceId: string + isOpen: boolean + onClose: () => void +} + +const PermissionApprovalModal: Component = (props) => { + const { isDark } = useTheme() + const [submitting, setSubmitting] = createSignal(false) + const [error, setError] = createSignal(null) + + const queue = createMemo(() => getPermissionQueue(props.instanceId)) + const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) + + const activePermission = createMemo((): PermissionRequestLike | null => { + const id = activePermId() + if (!id) return null + return queue().find((p) => p.id === id) ?? null + }) + + const hasActivePermission = createMemo(() => activePermission() !== null) + + createEffect(() => { + const permission = activePermission() + if (!permission) { + setSubmitting(false) + setError(null) + } + }) + + // Keyboard shortcuts + createEffect(() => { + if (!props.isOpen || !hasActivePermission()) return + + const handler = (event: KeyboardEvent) => { + if (submitting()) return + + if (event.key === "Enter") { + event.preventDefault() + handleResponse("once") + } else if (event.key === "a" || event.key === "A") { + event.preventDefault() + handleResponse("always") + } else if (event.key === "d" || event.key === "D") { + event.preventDefault() + handleResponse("reject") + } else if (event.key === "Escape") { + event.preventDefault() + props.onClose() + } + } + + document.addEventListener("keydown", handler) + onCleanup(() => document.removeEventListener("keydown", handler)) + }) + + async function handleResponse(response: "once" | "always" | "reject") { + const permission = activePermission() + if (!permission) return + + setSubmitting(true) + setError(null) + + try { + const sessionId = getPermissionSessionId(permission) || "" + await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) + + // Wait a moment for queue to update before closing + setTimeout(() => { + const remaining = getPermissionQueue(props.instanceId) + if (remaining.length === 0) { + props.onClose() + } + }, 100) + } catch (err) { + log.error("Failed to send permission response", err) + setError(err instanceof Error ? err.message : "Failed to send response") + } finally { + setSubmitting(false) + } + } + + function handleBackdropClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + props.onClose() + } + } + + const diffPayload = createMemo(() => { + const permission = activePermission() + if (!permission) return null + + const metadata = ((permission as any).metadata || {}) as Record + const diffValue = typeof metadata.diff === "string" ? metadata.diff : null + if (!diffValue || diffValue.trim().length === 0) return null + + const diffPath = + typeof metadata.filePath === "string" ? metadata.filePath : + typeof metadata.path === "string" ? metadata.path : + undefined + + return { diffText: diffValue, filePath: diffPath } + }) + + return ( + +
+ + }> +
+

+ Permission Required +

+ 1}> + + {queue().indexOf(activePermission()!) + 1} of {queue().length} + + +
+ +
+
+ {getPermissionKind(activePermission())} +
+
+ {getPermissionDisplayTitle(activePermission())} +
+ + + {(payload) => ( +
+
+ Requested changes · {payload().filePath ? getRelativePath(payload().filePath!) : ""} +
+
+ {}} + /> +
+
+ )} +
+ + + + +
+ + + +
+
+ + ) +} + +export default PermissionApprovalModal diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx new file mode 100644 index 00000000..412de5f9 --- /dev/null +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -0,0 +1,57 @@ +import { Show, createMemo, type Component } from "solid-js" +import { getPermissionQueueLength } from "../stores/instances" +import { isElectronHost } from "../lib/runtime-env" + +interface PermissionNotificationBannerProps { + instanceId: string + onClick: () => void +} + +const PermissionNotificationBanner: Component = (props) => { + const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) + const hasPermissions = createMemo(() => queueLength() > 0) + const isElectron = isElectronHost() + + return ( + + {/* Electron: Full banner with text */} + + + + + {/* Web: Compact indicator button */} + + + + + ) +} + +export default PermissionNotificationBanner diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css new file mode 100644 index 00000000..912c3398 --- /dev/null +++ b/packages/ui/src/styles/components/permission-notification.css @@ -0,0 +1,395 @@ +/* Permission Notification Banner */ +.permission-notification-banner { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0.375rem 0.75rem; + background: linear-gradient(135deg, var(--status-warning) 0%, color-mix(in srgb, var(--status-warning) 85%, #000) 100%); + color: var(--text-inverted); + border: 1px solid color-mix(in srgb, var(--status-warning) 70%, #000); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); + animation: permission-pulse 2s ease-in-out infinite; +} + +.permission-notification-banner:hover { + background: linear-gradient(135deg, color-mix(in srgb, var(--status-warning) 85%, #000) 0%, color-mix(in srgb, var(--status-warning) 70%, #000) 100%); + box-shadow: 0 4px 8px color-mix(in srgb, var(--status-warning) 40%, transparent); + transform: translateY(-1px); +} + +.permission-notification-banner:active { + transform: translateY(0); + box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); +} + +.permission-notification-icon { + font-size: 1rem; + line-height: 1; + flex-shrink: 0; +} + +.permission-notification-text { + white-space: nowrap; + color: inherit; + font-size: inherit; +} + +.permission-notification-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + background: color-mix(in srgb, #000 30%, transparent); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: inherit; + flex-shrink: 0; +} + +@keyframes permission-pulse { + 0%, 100% { + box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); + } + 50% { + box-shadow: 0 2px 8px color-mix(in srgb, var(--status-warning) 60%, transparent), 0 0 16px color-mix(in srgb, var(--status-warning) 30%, transparent); + } +} + +/* Web Permission Indicator Button (for browser-based streaming) */ +.permission-indicator-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: color-mix(in srgb, var(--status-warning) 15%, transparent); + color: var(--status-warning); + border: 2px solid var(--status-warning); + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + cursor: pointer; + transition: all 0.2s ease; + animation: web-permission-pulse 2s ease-in-out infinite; + flex-shrink: 0; + z-index: 10; + min-width: 2rem; + min-height: 2rem; + max-width: 2rem; + max-height: 2rem; +} + +.permission-indicator-button:hover { + background: color-mix(in srgb, var(--status-warning) 25%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--status-warning) 50%, transparent); + transform: scale(1.05); +} + +.permission-indicator-button:active { + transform: scale(0.98); +} + +.permission-indicator-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; +} + +.permission-indicator-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + color: inherit; +} + +@keyframes web-permission-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-warning) 40%, transparent); + } + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-warning) 20%, transparent); + } +} + +/* Ensure button visibility in web toolbars */ +@supports (display: flex) { + .permission-indicator-button { + visibility: visible; + opacity: 1; + pointer-events: auto; + } +} + +/* Permission Approval Modal */ +.permission-approval-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: color-mix(in srgb, var(--text-inverted) 60%, transparent); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: var(--space-lg); + animation: modal-backdrop-fadein 0.2s ease; +} + +@keyframes modal-backdrop-fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.permission-approval-modal { + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-xl); + box-shadow: 0 20px 25px -5px color-mix(in srgb, #000 10%, transparent), 0 10px 10px -5px color-mix(in srgb, #000 4%, transparent); + max-width: 48rem; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: modal-slidein 0.3s ease; +} + +@keyframes modal-slidein { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.permission-modal-empty { + padding: var(--space-xl); + color: var(--text-secondary); + text-align: center; +} + +.permission-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--border-base); + gap: var(--space-lg); +} + +.permission-modal-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.permission-modal-count { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs) var(--space-sm); + background: var(--surface-secondary); + border-radius: var(--radius-sm); + border: 1px solid var(--border-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.permission-modal-body { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.permission-modal-type { + display: inline-flex; + align-items: center; + padding: var(--space-xs) var(--space-sm); + background: color-mix(in srgb, var(--status-warning) 20%, transparent); + color: var(--status-warning); + border: 1px solid color-mix(in srgb, var(--status-warning) 50%, transparent); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + align-self: flex-start; +} + +.permission-modal-message { + padding: var(--space-lg); + background: var(--surface-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + font-family: var(--font-family-mono); + word-break: break-word; +} + +.permission-modal-message code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--text-primary); +} + +.permission-modal-diff { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.permission-modal-diff-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.permission-modal-diff-viewer { + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + overflow: hidden; + max-height: 24rem; + background: var(--surface-secondary); +} + +.permission-modal-error { + padding: var(--space-sm) var(--space-lg); + background: color-mix(in srgb, var(--status-error) 20%, transparent); + color: var(--status-error); + border: 1px solid color-mix(in srgb, var(--status-error) 50%, transparent); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); +} + +.permission-modal-footer { + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-lg); + border-top: 1px solid var(--border-base); + background: var(--surface-secondary); +} + +.permission-modal-buttons { + display: flex; + gap: var(--space-sm); + justify-content: stretch; +} + +.permission-modal-button { + flex: 1; + padding: var(--button-padding-y) var(--button-padding-x); + border: 1px solid transparent; + border-radius: var(--button-radius); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--font-family-sans); +} + +.permission-modal-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-modal-button:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; +} + +.permission-modal-button-once { + background: var(--accent-primary); + color: var(--text-inverted); + border-color: var(--accent-primary); +} + +.permission-modal-button-once:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.permission-modal-button-always { + background: var(--status-success); + color: var(--text-inverted); + border-color: var(--status-success); +} + +.permission-modal-button-always:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-success) 85%, #000); + border-color: color-mix(in srgb, var(--status-success) 85%, #000); +} + +.permission-modal-button-deny { + background: var(--status-error); + color: var(--text-inverted); + border-color: var(--status-error); +} + +.permission-modal-button-deny:hover:not(:disabled) { + background: color-mix(in srgb, var(--status-error) 85%, #000); + border-color: color-mix(in srgb, var(--status-error) 85%, #000); +} + +.permission-modal-shortcuts { + display: flex; + gap: var(--space-lg); + justify-content: center; + flex-wrap: wrap; +} + +.permission-modal-shortcut { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.permission-modal-shortcut kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.375rem; + background: var(--kbd-bg); + border: 1px solid var(--kbd-border); + color: var(--kbd-text); + border-radius: var(--radius-xs); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + box-shadow: inset 0 -2px 0 color-mix(in srgb, #000 10%, transparent); +} + +/* Dark theme adjustments - already handled by CSS tokens above */ +/* The token system automatically applies correct colors for dark mode via + :root @media (prefers-color-scheme: dark) definitions */ diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index e7b71479..44f02851 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -6,3 +6,4 @@ @import "./components/env-vars.css"; @import "./components/directory-browser.css"; @import "./components/remote-access.css"; +@import "./components/permission-notification.css"; From e9241a1b93d081563468e310991f82fb36b0864b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 7 Jan 2026 19:35:33 +0000 Subject: [PATCH 02/17] Ensure child processes are stopped --- .../src/background-processes/manager.ts | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 18d79e7f..6864f180 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -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 @@ -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((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" From 888e365d72b48100d3a0e21f6ac51c952d68a4dc Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Thu, 8 Jan 2026 06:38:59 +0800 Subject: [PATCH 03/17] feat: enhance permission modal with tool details, queue nav, session nav, and responsive design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal Enhancements: - Add accurate tool name extraction from message store (same method as inline chat) - Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write') - Add 'Go to Session ↗' button to navigate to originating session - Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→) - Add queue counter showing current position Responsive Web Design: - Portrait phones: 90vh max-height with safe margins (avoids browser URL bar) - Landscape phones: 95vw with 50vh body scroll - Tablets: adaptive layout - Touch devices: 44-48px touch targets Technical Changes: - Import messageStoreBus for tool part lookup - Query linked part.tool via permission messageId/callId - Export setActivePermissionIdForInstance for queue navigation - Add tool badge CSS styling --- .../components/permission-approval-modal.tsx | 241 ++++++++++++-- packages/ui/src/stores/instances.ts | 9 + .../components/permission-notification.css | 314 +++++++++++++++++- 3 files changed, 538 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 24e701dd..d514cfca 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,10 +1,12 @@ import { Show, createSignal, createMemo, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" -import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle } from "../types/permission" -import { getPermissionQueue, activePermissionId, sendPermissionResponse } from "../stores/instances" +import { getPermissionSessionId, getPermissionKind, getPermissionDisplayTitle, getPermissionMessageId, getPermissionCallId } from "../types/permission" +import { getPermissionQueue, activePermissionId, sendPermissionResponse, setActivePermissionIdForInstance } from "../stores/instances" +import { setActiveSession } from "../stores/session-state" +import { messageStoreBus } from "../stores/message-v2/bus" import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" -import { getRelativePath } from "./tool-call/utils" +import { getRelativePath, getToolIcon, getToolName } from "./tool-call/utils" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -22,7 +24,7 @@ const PermissionApprovalModal: Component = (props) const queue = createMemo(() => getPermissionQueue(props.instanceId)) const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) - + const activePermission = createMemo((): PermissionRequestLike | null => { const id = activePermId() if (!id) return null @@ -31,6 +33,92 @@ const PermissionApprovalModal: Component = (props) const hasActivePermission = createMemo(() => activePermission() !== null) + // Current position in queue + const currentIndex = createMemo(() => { + const perm = activePermission() + if (!perm) return -1 + return queue().findIndex((p) => p.id === perm.id) + }) + + const hasPrev = createMemo(() => currentIndex() > 0) + const hasNext = createMemo(() => currentIndex() < queue().length - 1) + + // Extract tool details - try to get actual tool name from message store first + const toolInfo = createMemo(() => { + const permission = activePermission() + if (!permission) return null + + const metadata = ((permission as any).metadata || {}) as Record + let toolName = "unknown" + + // BEST METHOD: Try to get the actual tool from the linked message part + // This is how the inline chat gets it (via toolPart.tool) + const messageId = getPermissionMessageId(permission) + const callId = getPermissionCallId(permission) + + if (messageId) { + const store = messageStoreBus.getInstance(props.instanceId) + if (store) { + const record = store.getMessage(messageId) + if (record) { + // Search through parts for the tool call matching this permission + for (const partId of record.partIds) { + const partRecord = record.parts[partId] + if (!partRecord?.data || partRecord.data.type !== "tool") continue + + const part = partRecord.data as any + // Match by callId if available + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId + if (callId && partCallId === callId && part.tool) { + toolName = part.tool + break + } + // If no callId match, just use the first tool part's name + if (!callId && part.tool) { + toolName = part.tool + break + } + } + } + } + } + + // Fallback: Check metadata fields + if (toolName === "unknown") { + const metaToolName = (metadata.toolName as string) || (metadata.tool as string) || (metadata.action as string) + if (metaToolName) { + toolName = metaToolName.replace(/^opencode_/, "").toLowerCase() + } + } + + // Fallback: Check permission kind for embedded action words + if (toolName === "unknown") { + const kind = getPermissionKind(permission).toLowerCase() + if (kind.includes("read")) toolName = "read" + else if (kind.includes("write")) toolName = "write" + else if (kind.includes("edit")) toolName = "edit" + else if (kind.includes("shell") || kind.includes("bash") || kind.includes("command")) toolName = "bash" + else if (kind.includes("patch")) toolName = "patch" + } + + const command = metadata.command as string | undefined + const filePath = (metadata.filePath as string) || (metadata.path as string) || undefined + const input = metadata.input as Record | undefined + + return { + toolName, + icon: getToolIcon(toolName), + displayName: getToolName(toolName), + command, + filePath, + input + } + }) + + // Check if we can navigate to session + const sessionId = createMemo(() => getPermissionSessionId(activePermission())) + const canGoToSession = createMemo(() => !!sessionId()) + createEffect(() => { const permission = activePermission() if (!permission) { @@ -45,7 +133,7 @@ const PermissionApprovalModal: Component = (props) const handler = (event: KeyboardEvent) => { if (submitting()) return - + if (event.key === "Enter") { event.preventDefault() handleResponse("once") @@ -58,6 +146,12 @@ const PermissionApprovalModal: Component = (props) } else if (event.key === "Escape") { event.preventDefault() props.onClose() + } else if (event.key === "ArrowLeft" && hasPrev()) { + event.preventDefault() + navigatePrev() + } else if (event.key === "ArrowRight" && hasNext()) { + event.preventDefault() + navigateNext() } } @@ -65,6 +159,34 @@ const PermissionApprovalModal: Component = (props) onCleanup(() => document.removeEventListener("keydown", handler)) }) + function navigatePrev() { + const idx = currentIndex() + if (idx > 0) { + const prevPerm = queue()[idx - 1] + if (prevPerm) { + setActivePermissionIdForInstance(props.instanceId, prevPerm.id) + } + } + } + + function navigateNext() { + const idx = currentIndex() + if (idx < queue().length - 1) { + const nextPerm = queue()[idx + 1] + if (nextPerm) { + setActivePermissionIdForInstance(props.instanceId, nextPerm.id) + } + } + } + + function handleGoToSession() { + const sid = sessionId() + if (sid) { + setActiveSession(props.instanceId, sid) + props.onClose() + } + } + async function handleResponse(response: "once" | "always" | "reject") { const permission = activePermission() if (!permission) return @@ -73,9 +195,9 @@ const PermissionApprovalModal: Component = (props) setError(null) try { - const sessionId = getPermissionSessionId(permission) || "" - await sendPermissionResponse(props.instanceId, sessionId, permission.id, response) - + const sid = getPermissionSessionId(permission) || "" + await sendPermissionResponse(props.instanceId, sid, permission.id, response) + // Wait a moment for queue to update before closing setTimeout(() => { const remaining = getPermissionQueue(props.instanceId) @@ -105,10 +227,10 @@ const PermissionApprovalModal: Component = (props) const diffValue = typeof metadata.diff === "string" ? metadata.diff : null if (!diffValue || diffValue.trim().length === 0) return null - const diffPath = + const diffPath = typeof metadata.filePath === "string" ? metadata.filePath : - typeof metadata.path === "string" ? metadata.path : - undefined + typeof metadata.path === "string" ? metadata.path : + undefined return { diffText: diffValue, filePath: diffPath } }) @@ -122,25 +244,74 @@ const PermissionApprovalModal: Component = (props)

No pending permissions

}> + {/* Header */}
-

- Permission Required -

- 1}> - - {queue().indexOf(activePermission()!) + 1} of {queue().length} - - +
+

+ Permission Required +

+ 1}> + + {currentIndex() + 1} of {queue().length} + + +
+
+ + + + +
+ {/* Body - scrollable */}
+ {/* Permission type badge */}
{getPermissionKind(activePermission())}
+ + {/* Tool details section */} + + {(info) => ( +
+
+ 🔧 + Tool Call + {info().toolName} + + {getRelativePath(info().filePath!)} + +
+ +
+ {info().command} +
+
+
+ )} +
+ + {/* Permission message */}
{getPermissionDisplayTitle(activePermission())}
+ {/* Diff viewer */} {(payload) => (
@@ -153,7 +324,7 @@ const PermissionApprovalModal: Component = (props) filePath={payload().filePath} theme={isDark() ? "dark" : "light"} mode="split" - onRendered={() => {}} + onRendered={() => { }} />
@@ -167,7 +338,33 @@ const PermissionApprovalModal: Component = (props) + {/* Footer - sticky */} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 282b945b..adaa4a8e 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -562,6 +562,14 @@ function clearPermissionQueue(instanceId: string): void { +function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { + setActivePermissionId((prev) => { + const next = new Map(prev) + next.set(instanceId, permissionId) + return next + }) +} + async function sendPermissionResponse( instanceId: string, sessionId: string, @@ -656,6 +664,7 @@ export { removePermissionFromQueue, clearPermissionQueue, sendPermissionResponse, + setActivePermissionIdForInstance, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, diff --git a/packages/ui/src/styles/components/permission-notification.css b/packages/ui/src/styles/components/permission-notification.css index 912c3398..44da6a5b 100644 --- a/packages/ui/src/styles/components/permission-notification.css +++ b/packages/ui/src/styles/components/permission-notification.css @@ -55,9 +55,12 @@ } @keyframes permission-pulse { - 0%, 100% { + + 0%, + 100% { box-shadow: 0 2px 4px color-mix(in srgb, var(--status-warning) 30%, transparent); } + 50% { box-shadow: 0 2px 8px color-mix(in srgb, var(--status-warning) 60%, transparent), 0 0 16px color-mix(in srgb, var(--status-warning) 30%, transparent); } @@ -113,9 +116,12 @@ } @keyframes web-permission-pulse { - 0%, 100% { + + 0%, + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-warning) 40%, transparent); } + 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-warning) 20%, transparent); } @@ -151,6 +157,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -174,6 +181,7 @@ opacity: 0; transform: translateY(-20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); @@ -390,6 +398,302 @@ box-shadow: inset 0 -2px 0 color-mix(in srgb, #000 10%, transparent); } -/* Dark theme adjustments - already handled by CSS tokens above */ -/* The token system automatically applies correct colors for dark mode via - :root @media (prefers-color-scheme: dark) definitions */ +/* Enhanced Header Layout */ +.permission-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg); + border-bottom: 1px solid var(--border-base); + gap: var(--space-md); + flex-wrap: wrap; +} + +.permission-modal-header-left { + display: flex; + align-items: center; + gap: var(--space-md); + flex: 1; + min-width: 0; +} + +.permission-modal-header-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + +.permission-modal-go-to-session { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: var(--surface-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.permission-modal-go-to-session:hover { + background: var(--surface-base); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.permission-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-md); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-modal-close:hover { + background: var(--surface-secondary); + color: var(--text-primary); +} + +/* Tool Details Section */ +.permission-modal-tool-details { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); + background: var(--surface-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); +} + +.permission-modal-tool-header { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.permission-modal-tool-icon { + font-size: 1.25rem; + line-height: 1; +} + +.permission-modal-tool-name { + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.permission-modal-tool-path { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--text-secondary); + background: var(--surface-base); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-xs); +} + +.permission-modal-tool-command { + padding: var(--space-sm); + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + overflow-x: auto; +} + +.permission-modal-tool-command code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; +} + +/* Queue Navigation */ +.permission-modal-nav { + display: flex; + justify-content: center; + gap: var(--space-md); + padding-bottom: var(--space-md); +} + +.permission-modal-nav-button { + padding: var(--space-xs) var(--space-md); + background: var(--surface-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-modal-nav-button:hover:not(:disabled) { + background: var(--surface-base); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.permission-modal-nav-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.permission-modal-tool-badge { + display: inline-flex; + align-items: center; + padding: var(--space-xs) var(--space-sm); + background: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-primary); +} + +/* ===== RESPONSIVE STYLES ===== */ + +/* Portrait phone - near full screen with safe margins */ +@media (max-width: 480px) and (orientation: portrait) { + .permission-approval-modal { + width: 95vw; + max-height: 90vh; + max-height: calc(90vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + border-radius: var(--radius-md); + margin: 5vh auto; + } + + .permission-approval-modal-backdrop { + padding: var(--space-md); + } + + .permission-modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + max-height: 50vh; + } + + .permission-modal-buttons { + flex-direction: column; + gap: var(--space-sm); + } + + .permission-modal-button { + min-height: 48px; + font-size: var(--font-size-md); + } + + .permission-modal-shortcuts { + display: none; + } + + .permission-modal-footer { + flex-shrink: 0; + position: sticky; + bottom: 0; + background: var(--surface-secondary); + border-top: 1px solid var(--border-base); + } + + .permission-modal-go-to-session { + font-size: var(--font-size-xs); + padding: var(--space-sm) var(--space-md); + } + + .permission-modal-diff-viewer { + max-height: 40vh; + } +} + +/* Landscape phone */ +@media (max-width: 812px) and (orientation: landscape) { + .permission-approval-modal { + width: 95vw; + max-height: 95vh; + margin: auto; + } + + .permission-modal-body { + overflow-y: auto; + max-height: 50vh; + -webkit-overflow-scrolling: touch; + } + + .permission-modal-buttons { + flex-direction: row; + flex-wrap: nowrap; + } + + .permission-modal-button { + min-height: 44px; + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); + } + + .permission-modal-shortcuts { + display: none; + } + + .permission-modal-diff-viewer { + max-height: 30vh; + } + + .permission-modal-footer { + flex-shrink: 0; + } +} + +/* Small tablets portrait */ +@media (min-width: 481px) and (max-width: 768px) and (orientation: portrait) { + .permission-approval-modal { + width: 95vw; + max-height: 90vh; + } + + .permission-modal-body { + overflow-y: auto; + max-height: 60vh; + } + + .permission-modal-button { + min-height: 44px; + } +} + +/* Touch device optimizations */ +@media (hover: none) and (pointer: coarse) { + .permission-modal-button { + min-height: 44px; + } + + .permission-modal-nav-button { + min-height: 40px; + padding: var(--space-sm) var(--space-lg); + } + + .permission-modal-go-to-session { + min-height: 36px; + padding: var(--space-sm) var(--space-md); + } + + .permission-modal-close { + width: 2.5rem; + height: 2.5rem; + font-size: var(--font-size-lg); + } +} \ No newline at end of file From cb2966fb08323d7905edd19fbcedc4c115c68164 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 8 Jan 2026 17:41:29 +0000 Subject: [PATCH 04/17] Add slash command prompt support --- packages/ui/src/components/alert-dialog.tsx | 90 +++++++++++++------ packages/ui/src/components/prompt-input.tsx | 88 +++++++++++++++--- packages/ui/src/components/unified-picker.tsx | 78 ++++++++++++++-- packages/ui/src/lib/command-utils.ts | 32 +++++-- packages/ui/src/stores/alerts.ts | 29 +++++- 5 files changed, 264 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 7dc7b73f..fce38bad 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -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 { 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 ( { -
- {isConfirm && ( - - )} - -
+ +
+ + setInputValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + dismiss(true, payload, inputValue()) + } + }} + /> +
+
+ +
+ {(isConfirm || isPrompt) && ( + + )} + +
diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f0f621a1..22fc1f7c 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -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(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(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,10 +596,18 @@ export default function PromptInput(props: PromptInputProps) { } clearPrompt() - clearAttachments(props.instanceId, props.sessionId) - setIgnoredAtPositions(new Set()) - 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()) + } else { + syncAttachmentCounters("", currentAttachments) + setIgnoredAtPositions(new Set()) + } + setHistoryDraft(null) try { @@ -593,6 +617,8 @@ 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) } @@ -677,11 +703,27 @@ export default function PromptInput(props: PromptInputProps) { setHistoryDraft(null) const cursorPos = target.selectionStart + + // Slash command picker (only when editing the command token: "/") + 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()) } else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) { @@ -698,6 +740,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 +759,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 +886,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) @@ -981,9 +1045,11 @@ export default function PromptInput(props: PromptInputProps) { void onClose: () => void agents: Agent[] + commands?: SDKCommand[] instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement @@ -81,6 +87,8 @@ interface UnifiedPickerProps { } const UnifiedPicker: Component = (props) => { + const mode = () => props.mode ?? "mention" + const [files, setFiles] = createSignal([]) const [filteredAgents, setFilteredAgents] = createSignal([]) const [selectedIndex, setSelectedIndex] = createSignal(0) @@ -246,6 +254,11 @@ const UnifiedPicker: Component = (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 = (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 = (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 = (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 = (props) => { >