diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf..0af88b69308 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogTasks } from "@tui/component/dialog-tasks" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -447,6 +448,15 @@ function App() { }, category: "System", }, + { + title: "Background tasks", + value: "task.list", + // keybind: "task_list", // TODO: Enable once tsgo picks up the new keybind + category: "System", + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "Switch theme", value: "theme.switch", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx new file mode 100644 index 00000000000..a36f7f6f5a8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tasks.tsx @@ -0,0 +1,271 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { createMemo, createSignal, createResource, createEffect, onMount, Show, onCleanup } from "solid-js" +import { useTheme } from "../context/theme" +import { useSDK } from "../context/sdk" +import { useSync } from "../context/sync" +import { useToast } from "../ui/toast" +import "opentui-spinner/solid" +import { useKV } from "../context/kv" + +interface TaskInfo { + id: string + pid: number + command: string + startTime: number + status: "running" | "completed" | "failed" + exitCode?: number + workdir: string + description?: string + logFile: string + reconnectable: boolean +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) +} + +export function DialogTasks() { + const dialog = useDialog() + const { theme } = useTheme() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() + const kv = useKV() + + const directory = () => sync.data.path.directory || process.cwd() + + const [toKill, setToKill] = createSignal() + // Start with a hardcoded test task to verify rendering works + const [tasks, setTasks] = createSignal([ + { + id: "test_task_1", + pid: 12345, + command: "TEST - If you see this, rendering works!", + startTime: Date.now(), + status: "running", + workdir: "/test", + description: "Test task to verify rendering", + logFile: "/test/log", + reconnectable: true, + }, + ]) + + // Fetch tasks from the API + const fetchTasks = async () => { + const dir = directory() + const url = `${sdk.url}/task?directory=${encodeURIComponent(dir)}` + const response = await sdk.fetch(url) + if (!response.ok) return + const text = await response.text() + const data = JSON.parse(text) as TaskInfo[] + setTasks(data) + } + + // Initial fetch and auto-refresh + onMount(() => { + fetchTasks() + }) + + const refreshInterval = setInterval(() => { + fetchTasks() + }, 2000) + + onCleanup(() => { + clearInterval(refreshInterval) + }) + + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + const options = createMemo(() => { + const taskList = tasks() ?? [] + if (taskList.length === 0) { + return [] + } + + return taskList + .toSorted((a, b) => b.startTime - a.startTime) + .map((task) => { + const isKilling = toKill() === task.id + const duration = formatDuration(Date.now() - task.startTime) + const statusText = + task.status === "running" + ? `running ${duration}` + : task.status === "completed" + ? `completed (exit: ${task.exitCode ?? 0})` + : `failed (exit: ${task.exitCode ?? "?"})` + + const category = + task.status === "running" ? "Running" : task.status === "completed" ? "Completed" : "Failed" + + return { + title: isKilling + ? `Press again to confirm kill` + : task.description || task.command.substring(0, 60), + bg: isKilling ? theme.error : undefined, + value: task.id, + category, + footer: `${formatTime(task.startTime)} | ${statusText}`, + gutter: + task.status === "running" ? ( + [⋯]}> + + + ) : task.status === "completed" ? ( + + ) : ( + + ), + } as DialogSelectOption + }) + }) + + const killTask = async (taskId: string) => { + try { + const response = await sdk.fetch(`${sdk.url}/task/${taskId}/kill?directory=${encodeURIComponent(directory())}`, { + method: "POST", + }) + if (response.ok) { + toast.show({ message: "Task killed", variant: "info" }) + fetchTasks() + } else { + toast.show({ message: "Failed to kill task", variant: "error" }) + } + } catch { + toast.show({ message: "Failed to kill task", variant: "error" }) + } + setToKill(undefined) + } + + const removeTask = async (taskId: string) => { + try { + const response = await sdk.fetch(`${sdk.url}/task/${taskId}?directory=${encodeURIComponent(directory())}`, { + method: "DELETE", + }) + if (response.ok) { + toast.show({ message: "Task removed", variant: "info" }) + fetchTasks() + } else { + toast.show({ message: "Failed to remove task", variant: "error" }) + } + } catch { + toast.show({ message: "Failed to remove task", variant: "error" }) + } + } + + const viewOutput = async (taskId: string) => { + try { + const response = await sdk.fetch( + `${sdk.url}/task/${taskId}/tail?directory=${encodeURIComponent(directory())}&lines=100`, + ) + if (response.ok) { + const data = await response.json() + dialog.replace(() => ) + } + } catch { + toast.show({ message: "Failed to fetch output", variant: "error" }) + } + } + + onMount(() => { + dialog.setSize("large") + }) + + return ( + 0} + fallback={ + + + + Background Tasks + + esc + + No background tasks found. + + Use the [bg] button on running bash processes to send them to background. + + + } + > + { + setToKill(undefined) + }} + onSelect={(option) => { + viewOutput(option.value) + }} + keybind={[ + { + keybind: { name: "k", ctrl: true, meta: false, shift: false, leader: false }, + title: "kill", + onTrigger: async (option) => { + const task = tasks()?.find((t) => t.id === option.value) + if (!task || task.status !== "running") { + toast.show({ message: "Task is not running", variant: "warning" }) + return + } + if (toKill() === option.value) { + await killTask(option.value) + return + } + setToKill(option.value) + }, + }, + { + keybind: { name: "d", ctrl: true, meta: false, shift: false, leader: false }, + title: "remove", + onTrigger: async (option) => { + const task = tasks()?.find((t) => t.id === option.value) + if (task?.status === "running") { + toast.show({ message: "Cannot remove running task", variant: "warning" }) + return + } + await removeTask(option.value) + }, + }, + { + keybind: { name: "o", ctrl: false, meta: false, shift: false, leader: false }, + title: "output", + onTrigger: async (option) => { + await viewOutput(option.value) + }, + }, + ]} + /> + + ) +} + +function DialogTaskOutput(props: { taskId: string; output: string }) { + const { theme } = useTheme() + + return ( + + + + Task Output: {props.taskId} + + esc + + + + {props.output || "(No output)"} + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index e19c8b70982..a178bfd1599 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -959,6 +959,12 @@ export function Prompt(props: PromptProps) { {local.model.variant.current()} + + · + + will queue + + diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3339e7b00d2..0839ab653b0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -89,6 +89,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (timer) clearTimeout(timer) }) - return { client: sdk, event: emitter, url: props.url } + // Expose the custom fetch (or native fetch as fallback) for components that need raw HTTP calls + const sdkFetch = props.fetch ?? globalThis.fetch + + return { client: sdk, event: emitter, url: props.url, fetch: sdkFetch } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e9..2106cb3a38c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, Show, Switch, useContext, @@ -1388,7 +1389,11 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) + // Always return metadata if available (needed for running bash processes) + // ToolStatePending doesn't have metadata, only ToolStateRunning and ToolStateCompleted do + const state = props.part.state + if (state.status === "pending") return {} + return state.metadata ?? {} }, get input() { return props.part.state.input ?? {} @@ -1599,8 +1604,11 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = function Bash(props: ToolProps) { const { theme } = useTheme() const sync = useSync() + const sdk = useSDK() + const toast = useToast() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) + const [now, setNow] = createSignal(Date.now()) const lines = createMemo(() => output().split("\n")) const overflow = createMemo(() => lines().length > 10) const limited = createMemo(() => { @@ -1608,6 +1616,83 @@ function Bash(props: ToolProps) { return [...lines().slice(0, 10), "…"].join("\n") }) + // Hover states for buttons (to avoid TextBuffer destroyed errors) + const [bgHover, setBgHover] = createSignal(false) + const [killHover, setKillHover] = createSignal(false) + + // Check if process is running (has metadata with running flag or status is running) + const isRunning = createMemo(() => { + const status = props.part.state.status + // Process is running if status is "running" or "pending" with running metadata + return props.metadata.running === true && (status === "pending" || status === "running") + }) + const callID = createMemo(() => props.metadata.callID as string | undefined) + const startTime = createMemo(() => props.metadata.startTime as number | undefined) + const directory = () => sync.data.path.directory || process.cwd() + + // Format elapsed time + const elapsed = createMemo(() => { + const st = startTime() + if (!st) return "" + const ms = now() - st + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` + }) + + // Update timer when running + createEffect(() => { + if (isRunning()) { + const interval = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(interval)) + } + }) + + // Send to background handler + const sendToBackground = async () => { + const id = callID() + if (!id) return + + try { + const response = await sdk.fetch( + `${sdk.url}/task/foreground/${id}/background?directory=${encodeURIComponent(directory())}`, + { method: "POST" }, + ) + const data = await response.json() + if (data.success) { + toast.show({ message: `Sent to background: ${data.taskId}`, variant: "info" }) + } else { + toast.show({ message: data.error || "Failed to send to background", variant: "error" }) + } + } catch (err) { + toast.show({ message: "Failed to send to background", variant: "error" }) + } + } + + // Kill process handler + const killProcess = async () => { + const id = callID() + if (!id) return + + try { + const response = await sdk.fetch( + `${sdk.url}/task/foreground/${id}/kill?directory=${encodeURIComponent(directory())}`, + { method: "POST" }, + ) + const data = await response.json() + if (data.success) { + toast.show({ message: "Process killed", variant: "info" }) + } else { + toast.show({ message: "Failed to kill process", variant: "error" }) + } + } catch (err) { + toast.show({ message: "Failed to kill process", variant: "error" }) + } + } + const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined @@ -1635,7 +1720,47 @@ function Bash(props: ToolProps) { return ( - + {/* Running process with controls */} + + setExpanded((prev) => !prev) : undefined} + > + + + $ {props.input.command} + + {elapsed()} + setBgHover(true)} + onMouseOut={() => setBgHover(false)} + > + [bg] + + setKillHover(true)} + onMouseOut={() => setKillHover(false)} + > + [kill] + + + + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + {/* Completed process with output */} + ) { + {/* Pending (no output yet) */} {props.input.command} diff --git a/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx b/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx new file mode 100644 index 00000000000..59ee0a0608c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/process-viewer.tsx @@ -0,0 +1,505 @@ +import { TextareaRenderable, TextAttributes, ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { createSignal, createMemo, createEffect, onMount, onCleanup, Show, on } from "solid-js" +import { useTheme } from "../context/theme" +import { useSDK } from "../context/sdk" +import { useSync } from "../context/sync" +import { useDialog, type DialogContext } from "./dialog" +import { useToast } from "./toast" +import { useKV } from "../context/kv" +import "opentui-spinner/solid" + +/** + * Task info structure matching the task manager + */ +interface TaskInfo { + id: string + pid: number + command: string + startTime: number + status: "running" | "completed" | "failed" + exitCode?: number + workdir: string + description?: string + logFile: string + reconnectable: boolean +} + +/** + * Format duration in human readable format + */ +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} + +/** + * Format time for display + */ +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) +} + +export type ProcessViewerProps = { + /** + * The task ID to view + */ + taskId: string + /** + * Initial output to display (optional, will be fetched if not provided) + */ + initialOutput?: string + /** + * Called when the viewer is closed + */ + onClose?: () => void +} + +/** + * ProcessViewer component - An interactive terminal viewer for background tasks + * + * Features: + * - Shows scrollable output from a task + * - Updates in real-time as new output arrives + * - Allows sending input to the process via TaskManager.input() + * - Has keybinds for: kill (Ctrl+C), scroll, close (Esc) + * + * Usage: + * ```tsx + * + * ``` + */ +export function ProcessViewer(props: ProcessViewerProps) { + const dialog = useDialog() + const { theme } = useTheme() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() + const kv = useKV() + const dimensions = useTerminalDimensions() + + const directory = () => sync.data.path.directory || process.cwd() + + // Task state + const [task, setTask] = createSignal(null) + const [output, setOutput] = createSignal(props.initialOutput ?? "") + const [inputValue, setInputValue] = createSignal("") + const [now, setNow] = createSignal(Date.now()) + const [confirmKill, setConfirmKill] = createSignal(false) + const [autoScroll, setAutoScroll] = createSignal(true) + + let scrollbox: ScrollBoxRenderable | undefined + let textarea: TextareaRenderable | undefined + + // Fetch task info + const fetchTask = async () => { + try { + const response = await sdk.fetch( + `${sdk.url}/task/${props.taskId}?directory=${encodeURIComponent(directory())}`, + ) + if (response.ok) { + const data: TaskInfo = await response.json() + setTask(data) + } + } catch { + // Silently fail + } + } + + // Fetch output + const fetchOutput = async () => { + try { + const response = await sdk.fetch( + `${sdk.url}/task/${props.taskId}/read?directory=${encodeURIComponent(directory())}`, + ) + if (response.ok) { + const data = await response.json() + const newOutput = data.output ?? "" + const prevOutput = output() + setOutput(newOutput) + + // Auto-scroll to bottom if enabled and output changed + if (autoScroll() && newOutput !== prevOutput && scrollbox) { + setTimeout(() => { + if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight) + }, 10) + } + } + } catch { + // Fall back to tail + try { + const response = await sdk.fetch( + `${sdk.url}/task/${props.taskId}/tail?directory=${encodeURIComponent(directory())}&lines=500`, + ) + if (response.ok) { + const data = await response.json() + const newOutput = data.output ?? "" + const prevOutput = output() + setOutput(newOutput) + + if (autoScroll() && newOutput !== prevOutput && scrollbox) { + setTimeout(() => { + if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight) + }, 10) + } + } + } catch { + // Silently fail + } + } + } + + // Send input to task + const sendInput = async (data: string) => { + const t = task() + if (!t || t.status !== "running") { + toast.show({ message: "Task is not running", variant: "warning" }) + return false + } + + try { + const response = await sdk.fetch( + `${sdk.url}/task/${props.taskId}/input?directory=${encodeURIComponent(directory())}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: data + "\n" }), + }, + ) + if (response.ok) { + setInputValue("") + // Refresh output after sending input + await fetchOutput() + return true + } else { + toast.show({ message: "Failed to send input", variant: "error" }) + return false + } + } catch { + toast.show({ message: "Failed to send input", variant: "error" }) + return false + } + } + + // Kill task + const killTask = async () => { + try { + const response = await sdk.fetch( + `${sdk.url}/task/${props.taskId}/kill?directory=${encodeURIComponent(directory())}`, + { method: "POST" }, + ) + if (response.ok) { + toast.show({ message: "Task killed", variant: "info" }) + setConfirmKill(false) + await fetchTask() + await fetchOutput() + } else { + toast.show({ message: "Failed to kill task", variant: "error" }) + } + } catch { + toast.show({ message: "Failed to kill task", variant: "error" }) + } + setConfirmKill(false) + } + + // Computed values + const elapsed = createMemo(() => { + const t = task() + if (!t) return "" + return formatDuration(now() - t.startTime) + }) + + const statusText = createMemo(() => { + const t = task() + if (!t) return "Loading..." + switch (t.status) { + case "running": + return `Running (${elapsed()})` + case "completed": + return `Completed (exit: ${t.exitCode ?? 0})` + case "failed": + return `Failed (exit: ${t.exitCode ?? "?"})` + default: + return t.status + } + }) + + const statusColor = createMemo(() => { + const t = task() + if (!t) return theme.textMuted + switch (t.status) { + case "running": + return theme.primary + case "completed": + return theme.success + case "failed": + return theme.error + default: + return theme.textMuted + } + }) + + const isRunning = createMemo(() => task()?.status === "running") + + const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"] + const animationsEnabled = () => kv.get("animations_enabled", true) + + // Height for scrollbox + const scrollHeight = createMemo(() => Math.floor(dimensions().height / 2) - 8) + + // Setup polling and keyboard handlers + onMount(() => { + dialog.setSize("large") + + // Initial fetch + fetchTask() + if (!props.initialOutput) { + fetchOutput() + } + + // Poll for updates + const taskInterval = setInterval(fetchTask, 2000) + const outputInterval = setInterval(() => { + if (task()?.status === "running") { + fetchOutput() + } + }, 1000) + + // Update elapsed time + const timeInterval = setInterval(() => setNow(Date.now()), 1000) + + // Focus textarea if running + setTimeout(() => { + if (isRunning() && textarea) { + textarea.focus() + } + }, 100) + + onCleanup(() => { + clearInterval(taskInterval) + clearInterval(outputInterval) + clearInterval(timeInterval) + }) + }) + + // Auto-scroll to bottom on initial load + createEffect( + on( + () => output(), + () => { + if (autoScroll() && scrollbox) { + setTimeout(() => { + if (scrollbox) scrollbox.scrollTo(scrollbox.scrollHeight) + }, 10) + } + }, + ), + ) + + // Keyboard handler + useKeyboard((evt) => { + // Ctrl+C to kill (with confirmation) + if (evt.ctrl && evt.name === "c") { + if (isRunning()) { + if (confirmKill()) { + killTask() + } else { + setConfirmKill(true) + // Reset confirmation after 3 seconds + setTimeout(() => setConfirmKill(false), 3000) + } + evt.preventDefault() + evt.stopPropagation() + return + } + } + + // Page Up/Down for scrolling + if (evt.name === "pageup") { + setAutoScroll(false) + if (scrollbox) { + scrollbox.scrollBy(-10) + } + evt.preventDefault() + return + } + + if (evt.name === "pagedown") { + if (scrollbox) { + scrollbox.scrollBy(10) + // Re-enable auto-scroll if at bottom + const scrolled = scrollbox.scrollTop + const max = scrollbox.scrollHeight - scrollbox.height + if (scrolled >= max - 1) { + setAutoScroll(true) + } + } + evt.preventDefault() + return + } + + // Ctrl+End to scroll to bottom and enable auto-scroll + if (evt.ctrl && evt.name === "end") { + setAutoScroll(true) + if (scrollbox) { + scrollbox.scrollTo(scrollbox.scrollHeight) + } + evt.preventDefault() + return + } + + // Ctrl+Home to scroll to top + if (evt.ctrl && evt.name === "home") { + setAutoScroll(false) + if (scrollbox) { + scrollbox.scrollTo(0) + } + evt.preventDefault() + return + } + }) + + return ( + + {/* Header */} + + + {/* Status indicator */} + + {task()?.status === "completed" ? "\u2713" : "\u2717"} + + } + > + [\u22EF]} + > + + + + + {task()?.description || task()?.command?.substring(0, 40) || props.taskId} + + + esc + + + {/* Status bar */} + + + PID: {task()?.pid ?? "?"} + + + Status: {statusText()} + + + Started: {task() ? formatTime(task()!.startTime) : "?"} + + + [Scroll paused] + + + + {/* Output area */} + + (scrollbox = r)} + maxHeight={scrollHeight()} + paddingLeft={1} + paddingRight={1} + scrollbarOptions={{ visible: true }} + > + + {output() || "(No output yet)"} + + + + + {/* Input area (only for running tasks) */} + + + > + +