diff --git a/README.md b/README.md index 8ce5fda1..71798d16 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,29 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it. +### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately +On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away. + +Try running with one of these environment variables: + +```bash +# Most reliable workaround (can reduce rendering performance) +WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad + +# Alternative for some Wayland setups +__NV_DISABLE_EXPLICIT_SYNC=1 codenomad +``` + +If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`: + +```bash +#!/bin/bash +export WEBKIT_DISABLE_DMABUF_RENDERER=1 +exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@" +``` + +Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702 + ## Architecture & Development CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation: diff --git a/package-lock.json b/package-lock.json index a82b5c47..3883a12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -7389,7 +7389,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -7423,7 +7423,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -7458,14 +7458,14 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.5.1", + "version": "0.6.0", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", diff --git a/package.json b/package.json index dfe6d649..dab65aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.5.1", + "version": "0.6.0", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 0aacc0a4..b66749b9 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.5.1", + "version": "0.6.0", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index 9e990320..fa8aa1ea 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.1" + "@opencode-ai/plugin": "1.1.8" } } diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 031eefab..7b2ffa8f 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@fastify/cors": "^8.5.0", "commander": "^12.1.0", diff --git a/packages/server/package.json b/packages/server/package.json index a2e44461..eeeb5389 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.5.1", + "version": "0.6.0", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", 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" diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index ff917284..c39f2a87 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.5.1", + "version": "0.6.0", "private": true, "scripts": { "dev": "tauri dev", diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c081afb..d18b89e4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "scripts": { 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/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 454ffdf0..74f6362f 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -26,21 +26,25 @@ import MenuIcon from "@suid/icons-material/Menu" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" import type { BackgroundProcess } from "../../../../server/src/api-types" +import type { Session } from "../../types/session" import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, getSessionInfo, + getSessionThreads, + sessions, + setActiveParentSession, setActiveSession, } from "../../stores/sessions" import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" import { messageStoreBus } from "../../stores/message-v2/bus" import { clearSessionRenderCache } from "../message-block" -import { buildCustomCommandEntries } from "../../lib/command-utils" -import { getCommands as getInstanceCommands } from "../../stores/commands" + import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" import SessionList from "../session-list" import KeyboardHint from "../keyboard-hint" @@ -50,6 +54,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" @@ -86,7 +92,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360 const RIGHT_DRAWER_WIDTH = 260 const MIN_RIGHT_DRAWER_WIDTH = 200 const MAX_RIGHT_DRAWER_WIDTH = 380 -const SESSION_CACHE_LIMIT = 2 +const SESSION_CACHE_LIMIT = 5 const APP_BAR_HEIGHT = 56 const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" @@ -141,6 +147,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)) @@ -266,6 +273,12 @@ const InstanceShell2: Component = (props) => { requestAnimationFrame(() => measureDrawerHost()) }) + const allInstanceSessions = createMemo>(() => { + return sessions().get(props.instance.id) ?? new Map() + }) + + const sessionThreads = createMemo(() => getSessionThreads(props.instance.id)) + const activeSessions = createMemo(() => { const parentId = activeParentSessionId().get(props.instance.id) if (!parentId) return new Map[number]>() @@ -373,9 +386,7 @@ const InstanceShell2: Component = (props) => { } } - const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) - - const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) + const instancePaletteCommands = createMemo(() => props.paletteCommands()) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) const keyboardShortcuts = createMemo(() => @@ -477,7 +488,26 @@ const InstanceShell2: Component = (props) => { }) const handleSessionSelect = (sessionId: string) => { - setActiveSession(props.instance.id, sessionId) + if (sessionId === "info") { + setActiveSession(props.instance.id, sessionId) + return + } + + const session = allInstanceSessions().get(sessionId) + if (!session) return + + if (session.parentId === null) { + setActiveParentSession(props.instance.id, sessionId) + return + } + + const parentId = session.parentId + if (!parentId) return + + batch(() => { + setActiveParentSession(props.instance.id, parentId) + setActiveSession(props.instance.id, sessionId) + }) } @@ -522,23 +552,27 @@ const InstanceShell2: Component = (props) => { }) createEffect(() => { - const sessionsMap = activeSessions() - const parentId = parentSessionIdForInstance() + const instanceSessions = allInstanceSessions() const activeId = activeSessionIdForInstance() + setCachedSessionIds((current) => { - const next: string[] = [] - const append = (id: string | null) => { + const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) + + const touch = (id: string | null) => { if (!id || id === "info") return - if (!sessionsMap.has(id)) return - if (next.includes(id)) return - next.push(id) + if (!instanceSessions.has(id)) return + + const index = next.indexOf(id) + if (index !== -1) { + next.splice(index, 1) + } + next.unshift(id) } - append(parentId) - append(activeId) + touch(activeId) + + const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next - const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT - const trimmed = next.length > limit ? next.slice(0, limit) : next const trimmedSet = new Set(trimmed) const removed = current.filter((id) => !trimmedSet.has(id)) if (removed.length) { @@ -654,7 +688,7 @@ const InstanceShell2: Component = (props) => { }) type DrawerViewState = "pinned" | "floating-open" | "floating-closed" - + const leftDrawerState = createMemo(() => { if (leftPinned()) return "pinned" @@ -695,7 +729,7 @@ const InstanceShell2: Component = (props) => { - const pinLeftDrawer = () => { + const pinLeftDrawer = () => { blurIfInside(leftDrawerContentEl()) batch(() => { setLeftPinned(true) @@ -814,33 +848,37 @@ const InstanceShell2: Component = (props) => { -
- - (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} - > - {leftPinned() ? : } - - -
+
+ handleSessionSelect("info")} + > + + + + (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + + +
{ - const result = props.onCloseSession(id) - if (result instanceof Promise) { - void result.catch((error) => log.error("Failed to close session:", error)) - } - }} onNew={() => { const result = props.onNewSession() if (result instanceof Promise) { @@ -1222,6 +1260,10 @@ const InstanceShell2: Component = (props) => {
+ setPermissionModalOpen(true)} + />
= (props) => {
} > -
- - {leftAppBarButtonIcon()} - +
+ + {leftAppBarButtonIcon()} + - -
- Used - {formattedUsedTokens()} -
-
- Avail - {formattedAvailableTokens()} -
-
-
+ +
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+
-
- - - - -
+
+ setPermissionModalOpen(true)} + /> + + + + + + +
@@ -1429,6 +1479,12 @@ const InstanceShell2: Component = (props) => { process={selectedBackgroundProcess()} onClose={closeBackgroundOutput} /> + + setPermissionModalOpen(false)} + /> ) } diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index df3f28f3..a16f4b8e 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getToolIcon } from "./tool-call/utils" -import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid" +import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" @@ -17,6 +17,7 @@ export interface TimelineSegment { tooltip: string shortLabel?: string variant?: "auto" | "manual" + toolPartIds?: string[] } interface MessageTimelineProps { @@ -47,6 +48,7 @@ interface PendingSegment { toolTitles: string[] toolTypeLabels: string[] toolIcons: string[] + toolPartIds: string[] hasPrimaryText: boolean } @@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) label, tooltip, shortLabel, + toolPartIds: isToolSegment ? pending.toolPartIds : undefined, }) segmentIndex += 1 pending = null @@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) const ensureSegment = (type: TimelineSegmentType): PendingSegment => { if (!pending || pending.type !== type) { flushPending() - pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" } + pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" } } return pending! } @@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) target.toolTitles.push(getToolTitle(toolPart)) target.toolTypeLabels.push(getToolTypeLabel(toolPart)) target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool")) + if (typeof toolPart.id === "string" && toolPart.id.length > 0) { + target.toolPartIds.push(toolPart.id) + } continue } @@ -359,9 +365,25 @@ const MessageTimeline: Component = (props) => { {(segment) => { onCleanup(() => buttonRefs.delete(segment.id)) const isActive = () => props.activeMessageId === segment.messageId - const isHidden = () => segment.type === "tool" && !(showTools() || isActive()) + + const hasActivePermission = () => { + if (segment.type !== "tool") return false + const partIds = segment.toolPartIds ?? [] + if (partIds.length === 0) return false + for (const partId of partIds) { + const permissionState = store().getPermissionState(segment.messageId, partId) + if (permissionState?.active) return true + } + return false + } + + const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission()) + const shortLabelContent = () => { if (segment.type === "tool") { + if (hasActivePermission()) { + return
+ + + ) +} + +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..17e04907 --- /dev/null +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -0,0 +1,36 @@ +import { Show, createMemo, type Component } from "solid-js" +import { ShieldAlert } from "lucide-solid" +import { getPermissionQueueLength } from "../stores/instances" + +interface PermissionNotificationBannerProps { + instanceId: string + onClick: () => void +} + +const PermissionNotificationBanner: Component = (props) => { + const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) + const hasPermissions = createMemo(() => queueLength() > 0) + const label = createMemo(() => { + const count = queueLength() + return `${count} permission${count === 1 ? "" : "s"} pending approval` + }) + + return ( + + + + ) +} + +export default PermissionNotificationBanner diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f0f621a1..6bd83631 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -7,9 +7,11 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" import type { Agent } from "../types/session" +import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" 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 +38,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 +563,28 @@ export default function PromptInput(props: PromptInputProps) { const currentAttachments = attachments() if (props.disabled || (!text && currentAttachments.length === 0)) return - const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments) const isShellMode = mode() === "shell" + // Slash command routing (match OpenCode TUI): only run if the command exists. + const isSlashCandidate = !isShellMode && text.startsWith("/") + const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1 + const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : "" + const commandName = isSlashCandidate ? commandToken.slice(1) : "" + const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : "" + + const isKnownSlashCommand = + isSlashCandidate && + commandName.length > 0 && + getCommands(props.instanceId).some((cmd) => cmd.name === commandName) + + const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments) + const historyEntry = resolvedPrompt + const refreshHistory = async () => { try { - await addToHistory(props.instanceFolder, resolvedPrompt) + await addToHistory(props.instanceFolder, historyEntry) setHistory((prev) => { - const next = [resolvedPrompt, ...prev] + const next = [historyEntry, ...prev] if (next.length > HISTORY_LIMIT) { next.length = HISTORY_LIMIT } @@ -580,12 +597,25 @@ 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) + if (isKnownSlashCommand) { + // Record attempted slash commands even if execution fails. + void refreshHistory() + } + try { if (isShellMode) { if (props.onRunShell) { @@ -593,10 +623,14 @@ export default function PromptInput(props: PromptInputProps) { } else { await props.onSend(resolvedPrompt, []) } + } else if (isKnownSlashCommand) { + await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs) } else { await props.onSend(resolvedPrompt, currentAttachments) } - void refreshHistory() + if (!isKnownSlashCommand) { + void refreshHistory() + } } catch (error) { log.error("Failed to send message:", error) showAlertDialog("Failed to send message", { @@ -677,11 +711,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 +748,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 +767,30 @@ export default function PromptInput(props: PromptInputProps) { | { type: "file" file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } - }, + } + | { type: "command"; command: SDKCommand }, ) { - 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 +894,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) @@ -958,7 +1030,8 @@ export default function PromptInput(props: PromptInputProps) { return hasText || attachments().length > 0 } - 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: "Shell mode" }) + const commandHint = () => ({ key: "/", text: "Commands" }) const shouldShowOverlay = () => prompt().length === 0 @@ -981,9 +1054,11 @@ export default function PromptInput(props: PromptInputProps) { - Enter for new line • to send • @ for files/agents • ↑↓ for history + Enter New line • Send • @ Files/agents • ↑↓ History 0}> • {attachments().length} file(s) attached @@ -1149,6 +1224,11 @@ export default function PromptInput(props: PromptInputProps) { {shellHint().key} {shellHint().text} + + + • {commandHint().key} {commandHint().text} + + Shell mode active diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index e9b25ef6..9bd7fa09 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -1,14 +1,22 @@ -import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js" +import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import type { Session, SessionStatus } from "../types/session" +import type { SessionThread } from "../stores/session-state" import { getSessionStatus } from "../stores/session-status" -import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid" +import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid" import KeyboardHint from "./keyboard-hint" -import Kbd from "./kbd" import SessionRenameDialog from "./session-rename-dialog" import { keyboardRegistry } from "../lib/keyboard-registry" -import { formatShortcut } from "../lib/keyboard-utils" import { showToastNotification } from "../lib/notifications" -import { deleteSession, loading, renameSession } from "../stores/sessions" +import { + deleteSession, + ensureSessionParentExpanded, + getVisibleSessionIds, + isSessionParentExpanded, + loading, + renameSession, + setActiveSessionFromList, + toggleSessionParentExpanded, +} from "../stores/sessions" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" const log = getLogger("session") @@ -18,9 +26,9 @@ const log = getLogger("session") interface SessionListProps { instanceId: string sessions: Map + threads: SessionThread[] activeSessionId: string | null onSelect: (sessionId: string) => void - onClose: (sessionId: string) => void onNew: () => void showHeader?: boolean showFooter?: boolean @@ -39,35 +47,23 @@ function formatSessionStatus(status: SessionStatus): string { } } -function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean { - if (!prev) { - return false - } - - if (prev.length !== next.length) { - return false - } - - for (let i = 0; i < prev.length; i++) { - if (prev[i] !== next[i]) { - return false - } - } - - return true -} - const SessionList: Component = (props) => { const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) - const infoShortcut = keyboardRegistry.get("switch-to-info") - + const isSessionDeleting = (sessionId: string) => { const deleting = loading().deletingSession.get(props.instanceId) return deleting ? deleting.has(sessionId) : false } + const selectSession = (sessionId: string) => { + const session = props.sessions.get(sessionId) + const parentId = session?.parentId ?? session?.id + if (parentId) { + ensureSessionParentExpanded(props.instanceId, parentId) + } + props.onSelect(sessionId) } @@ -90,9 +86,45 @@ const SessionList: Component = (props) => { const handleDeleteSession = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() if (isSessionDeleting(sessionId)) return - + + const shouldSelectFallback = props.activeSessionId === sessionId + let fallbackSessionId: string | undefined + + if (shouldSelectFallback) { + const visible = getVisibleSessionIds(props.instanceId) + const currentIndex = visible.indexOf(sessionId) + const remaining = visible.filter((id) => id !== sessionId) + + if (remaining.length > 0) { + if (currentIndex !== -1) { + for (let i = currentIndex; i < visible.length; i++) { + const candidate = visible[i] + if (candidate && candidate !== sessionId) { + fallbackSessionId = candidate + break + } + } + + if (!fallbackSessionId) { + for (let i = currentIndex - 1; i >= 0; i--) { + const candidate = visible[i] + if (candidate && candidate !== sessionId) { + fallbackSessionId = candidate + break + } + } + } + } + + fallbackSessionId ??= remaining[0] + } + } + try { await deleteSession(props.instanceId, sessionId) + if (fallbackSessionId) { + setActiveSessionFromList(props.instanceId, fallbackSessionId) + } } catch (error) { log.error(`Failed to delete session ${sessionId}:`, error) showToastNotification({ message: "Unable to delete session", variant: "error" }) @@ -127,7 +159,14 @@ const SessionList: Component = (props) => { } - const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => { + const SessionRow: Component<{ + sessionId: string + isChild?: boolean + isLastChild?: boolean + hasChildren?: boolean + expanded?: boolean + onToggleExpand?: () => void + }> = (rowProps) => { const session = () => props.sessions.get(rowProps.sessionId) if (!session()) { return <> @@ -144,41 +183,55 @@ const SessionList: Component = (props) => {
-
- +
listEl[1](el)}> + 0}> +
+ - 0}> -
-
- User Session -
- {(id) => } -
-
+ {(thread) => { + const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id) + return ( + <> + 0} + expanded={expanded()} + onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)} + /> - 0}> -
-
- Agent Sessions -
- {(id) => } + 0}> + + {(child, index) => ( + + )} + + + + ) + }} +
diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index d12e9899..fab200c9 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -1,5 +1,6 @@ -import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" +import { Component, createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js" import type { Agent } from "../types/session" +import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { serverApi } from "../lib/api-client" import { getLogger } from "../lib/logger" @@ -67,13 +68,18 @@ function mapEntriesToFileItems(entries: { path: string; type: "file" | "director }) } -type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem } +type PickerItem = + | { type: "agent"; agent: Agent } + | { type: "file"; file: FileItem } + | { type: "command"; command: SDKCommand } interface UnifiedPickerProps { open: boolean + mode?: "mention" | "command" onSelect: (item: PickerItem) => void onClose: () => void agents: Agent[] + commands?: SDKCommand[] instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement @@ -81,6 +87,8 @@ interface UnifiedPickerProps { } const UnifiedPicker: Component = (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) => { >