From 85c2b7caf424d955c5aca66967a45737e7977f78 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 19 Mar 2026 12:55:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20architecture=20rewrite=20?= =?UTF-8?q?=E2=80=94=20Bun,=20OpenTUI,=20OpenCode-style=20UI,=20X=20Search?= =?UTF-8?q?=20API=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: complete architecture rewrite with Bun, OpenTUI, bash-only tools, and X Search API Major rewrite of the Grok CLI agent: - Runtime: Bun-first (removed Node.js/tsx fallbacks) - UI: Replaced Ink with OpenTUI (@opentui/react) terminal framework - Tools: Simplified to bash-only (removed text-editor, search, morph, todo, confirmation tools) - Search: Integrated X Search API and Web Search via xAI Responses API - Models: Updated to all latest Grok models (grok-4, grok-4.20-beta, grok-4-1-fast, grok-4-fast, grok-3-mini) - Agent: Streamlined agent loop with async generator streaming - Client: Dual API support (Chat Completions for tool calling, Responses API for search) - Removed MCP integration, confirmation service, token counter, Morph editor - Updated tsconfig for OpenTUI React JSX - Clean 10-file architecture in src/ Co-authored-by: Ismail Pelaseyed * Fix HelpPanel rendering bug: replace
with Text component - Replaced
tag with {" "} in HelpPanel component - Ink requires all HTML-like elements to be wrapped in Text components - Fixes React reconciler error: 'Component of type br must be created inside of a Text node' Co-authored-by: Ismail Pelaseyed * fix: use ref for reliable streaming content accumulation in UI - Replace fragile setState callback with contentAccRef for tracking streamed content - Save intermediate assistant content when tool calls arrive - Remove unused useTerminalDimensions import - Fix HelpPanel
tag (must be inside text node in OpenTUI) Co-authored-by: Ismail Pelaseyed * fix: improve UI visibility with dark theme and text wrapping - Add dark background (#0f172a) to main app, model picker, input - Set explicit text/background colors on input for visibility - Use wrapMode='word' on all text content for proper wrapping - Use children instead of content prop for user messages - Add Grok label to streaming content display Co-authored-by: Ismail Pelaseyed * fix: remove wrapMode that caused garbled text rendering wrapMode='word' was corrupting character display in OpenTUI text components. Removed it from all message views and streaming display. Co-authored-by: Ismail Pelaseyed * feat: rewrite UI to match OpenCode's design language - Use OpenCode's exact dark theme tokens (#0a0a0a bg, #141414 panel, #eeeeee text, #fab283 primary) - Left-border accent (┃) on user messages with primary color, matching OpenCode's SplitBorder - Panel backgrounds for user messages and tool blocks (backgroundPanel) - Home screen with centered logo + prompt (like OpenCode's home.tsx) - Session view with header, scrollable messages, prompt, footer - Inline tool results (icon + label) for simple tools - Block tool results (panel with output) for bash with >3 lines - Footer with cwd on left, model on right (matching OpenCode's footer.tsx) - Separate theme.ts with typed dark theme object - Reasoning shown in subtle left-bordered panel (like OpenCode's ReasoningPart) Co-authored-by: Ismail Pelaseyed * feat: add Agent/Plan/Ask mode system with tab cycling and OpenCode home screen - Add AgentMode type (agent/plan/ask) with per-mode system prompts - Agent mode: full tool access, executes changes directly - Plan mode: read-only tools, creates implementation plans without modifying files - Ask mode: read-only tools, answers questions with context - Tab key cycles between modes (visible in prompt box) - OpenCode-style block-letter GROK logo on home screen (left=muted, right=bold) - Prompt box shows mode label + model name + provider (like OpenCode's prompt) - Shadow/bottom-edge effect below prompt (╹ + ▀ pattern from OpenCode) - Keyboard hints bar: 'tab modes · ctrl+p commands' (matches OpenCode layout) - Mode color changes left-border accent on prompt and user messages - Refactored into HomeView and SessionView components Co-authored-by: Ismail Pelaseyed * feat: black and white theme, Grok brand logo, modal model picker - Theme: pure black (#000000) and white (#ffffff) only, no color accents - Logo: Grok brand symbol (stylized compass/G) next to bold 'Grok' text - Model picker: floating modal dialog overlaying the app (like OpenCode's DialogSelect) - Centered panel with dark background - Title 'Select model' + esc hint - Search placeholder - Models grouped by category (Grok 4, Grok Code, Grok 3) - Current model marked with ● - Selected item highlighted with selectedBg - Pricing shown for budget models - Mode colors changed to white/gray (monochrome) - Models grouped via MODEL_GROUPS in models.ts - Added selectedBg and selected tokens to theme Co-authored-by: Ismail Pelaseyed * feat: differentiated UI, colored modes, clean logo, remove xAI labels - Remove all 'xAI' labels from model picker and prompt box - Mode colors: blue (#5c9cf5) for Agent, yellow (#e5c07b) for Plan, orange (#f5a742) for Ask - Logo: clean '✦ G R O K' text with tagline (no broken Unicode art) - Differentiated UI from OpenCode: - Home: centered logo + tagline, input with thin single-line border colored by mode, mode selector row showing all three modes with dots, model name on right - Session: top bar with model + mode instead of bordered header panel, thin separator line, user messages with colored ▸ prefix instead of left-border panels, input with colored single-line border instead of thick ┃ accent - Tool output in subtle background panels - Model picker: bordered modal instead of borderless floating panel, category headers in muted text, no 'xAI' provider label - Simplified theme: removed unused color tokens, added textDim for ultra-subtle text Co-authored-by: Ismail Pelaseyed * revert: restore ┃ left-border prompt box with mode/model line and shadow edge Bring back the previous input style that was preferred: - ┃ left-border accent colored by mode (blue/yellow/orange) - Mode label + model name inside the prompt box below input - ╹/▀ shadow edge below the prompt - Hints row (tab modes · ctrl+p commands) right-aligned under prompt - Used in both home screen and session view - Keep everything else from current version (differentiated session layout, ▸ prefix messages, top bar, clean model picker, logo, mode colors) Co-authored-by: Ismail Pelaseyed * feat: replicate OpenCode conversation layout Session view now matches OpenCode's session/index.tsx patterns: - Session header: # Model Name in ┃ left-border panel with backgroundPanel - User messages: ┃ left-border colored by mode, backgroundPanel fill, text inside - Tool results (inline): paddingLeft=3, icon + muted text ($ cmd, ◈ Search) - Tool results (block): ┃ left-border panel for bash output >3 lines, search results - Assistant text: paddingLeft=3, marginTop=1, plain text - Metadata line: ▣ Mode · model · duration after each assistant turn (like OpenCode's AssistantMessage final block with ▣ icon) - Reasoning: ┃ left-border panel with backgroundElement border, italic dim text - Pending tools: ~ prefix with text color - Track tool call count and elapsed time for metadata line - InlineTool component for consistent tool result formatting Co-authored-by: Ismail Pelaseyed * feat: redesign model picker and slash menu modals - Remove borders, add search filtering with keyboard input - Flat model list without category sections - Blue accent color for current model highlight - Fit-to-content modals with max height cap and scroll - scrollChildIntoView for arrow key navigation - Trim logo height by removing decorative lines - Add accent color to theme Made-with: Cursor * feat: migrate to Vercel AI SDK with xAI provider - Replace custom OpenAI client with AI SDK streamText + @ai-sdk/xai - Add standalone search_web and search_x tools via chat completions - Fix follow-up messages by using isProcessingRef to avoid stale closures - Fallback message history when result.response fails after stream errors - Remove post-response status line, adjust input padding Made-with: Cursor * feat: add file tools, diff view, and markdown rendering - Add read_file, write_file, edit_file tools with diff generation - Inline diff viewer with line numbers, colored add/remove, and unmodified-lines separators (OpenCode-style) - Lightweight markdown renderer for assistant messages: headings, bold, italic, inline code, code blocks, lists, blockquotes, links - Update agent system prompts with terminal environment guidance - Install diff package for unified diff computation Made-with: Cursor * feat: add plan mode (#156) * feat: add plan mode with interactive questions UI Add generate_plan tool, structured plan types, and an OpenCode-style inline questions panel with tabbed navigation, number-key shortcuts, custom text answers, and a review/confirm tab. * fix: plan-to-agent handover, mode-scoped tools, and UI polish - Carry plan context forward when switching plan→agent mode - Restrict generate_plan tool to plan mode only - Stamp modeColor on all message types for correct per-message colors - Sanitize model-echoed role labels and raw JSON from content - Truncate tool result labels to single lines - Single-word tab labels for plan questions - Always show edit/write file results with diff - Clear agent history on mode switch * feat: add Brin contributor trust check workflow and use OpenTUI native markdown (#157) * feat: add sub-agent task delegation and UI (#159) * feat: add background agents and background bash processes (#161) Add async background delegation system (delegate, delegation_read, delegation_list) for read-only research that persists to disk and notifies the session on completion. Extend bash tool with background mode for long-running processes (dev servers, watchers) with companion process_logs, process_stop, and process_list tools. Closes #146 * feat: add prompt queuing with follow-ups UI (#162) Allow users to submit messages while the agent is processing. Queued messages display in a follow-ups panel above the prompt and are processed sequentially after the current response completes. --------- Co-authored-by: Cursor Agent Co-authored-by: Ismail Pelaseyed --- src/agent/agent.ts | 134 ++++++++++++-- src/agent/delegations.ts | 386 +++++++++++++++++++++++++++++++++++++++ src/grok/tools.ts | 101 +++++++++- src/index.ts | 49 +++++ src/tools/bash.ts | 216 +++++++++++++++++++++- src/types/index.ts | 19 ++ src/ui/app.tsx | 262 ++++++++++++++++++++++++-- src/ui/theme.ts | 1 + 8 files changed, 1133 insertions(+), 35 deletions(-) create mode 100644 src/agent/delegations.ts diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 675378b..87c683e 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -1,4 +1,5 @@ import { streamText, stepCountIs, type ModelMessage } from "ai"; +import { DelegationManager } from "./delegations.js"; import { createProvider, generateTitle as genTitle, type XaiProvider } from "../grok/client.js"; import { createTools } from "../grok/tools.js"; import { BashTool } from "../tools/bash.js"; @@ -33,8 +34,14 @@ TOOLS: - read_file: Read file contents with start_line/end_line for iterative reading. Use for examining code. - write_file: Create new files or overwrite existing ones with full content. - edit_file: Replace a unique string in a file with new content. The old_string must be unique — include enough context lines. -- bash: Execute shell commands — searching (grep/rg/find), git, builds, tests, package managers, etc. -- task: Delegate a focused task to a sub-agent. Use general for multi-step execution and explore for fast read-only research. +- bash: Execute shell commands. Set background=true for long-running processes (dev servers, watchers, builds). Returns a process ID immediately. +- process_logs: View recent output from a background process by ID. +- process_stop: Stop a background process by ID. +- process_list: List all background processes with status and uptime. +- task: Delegate a focused foreground task to a sub-agent. Use general for multi-step execution and explore for fast read-only research. +- delegate: Launch a read-only background agent for longer research while you continue working. +- delegation_read: Retrieve a completed background delegation result by ID. +- delegation_list: List running and completed background delegations. Do not poll it repeatedly. - search_web: Search the web for current information, documentation, APIs, tutorials, etc. - search_x: Search X/Twitter for real-time posts, discussions, opinions, and trends. @@ -42,15 +49,20 @@ WORKFLOW: 1. Understand the request 2. Decide whether a sub-agent should handle the first investigation pass 3. Use read_file and bash to explore the codebase directly when the task is small or tightly scoped -4. Use edit_file for targeted changes, write_file for new files or full rewrites -5. Verify changes by reading modified files -6. Run tests or builds with bash to confirm correctness -7. Use search_web or search_x when you need up-to-date information +4. Use bash with background=true for dev servers, watchers, or any long-running process — then continue working +5. Use delegate for read-only work that can run in parallel, then continue productive work +6. Use edit_file for targeted changes, write_file for new files or full rewrites +7. Verify changes by reading modified files +8. Run tests or builds with bash to confirm correctness +9. Use search_web or search_x when you need up-to-date information DEFAULT DELEGATION POLICY: - Prefer the task tool by default for code review, code quality analysis, architecture research, root-cause investigation, bug triage, or any request that likely needs reading multiple files before acting. +- Prefer delegate for longer-running read-only exploration when you can keep making progress without blocking. - Use the explore sub-agent for read-only investigation, reviews, research, and "how does this work?" tasks. - Use the general sub-agent for delegated work that may need editing files, running commands, or producing a concrete implementation. +- Never use delegate for tasks that should edit files or make shell changes. +- When a background delegation is running, do not wait idly and do not spam delegation_list(). Continue useful work. - Do not wait for the user to explicitly ask for a sub-agent when delegation would clearly help. - Skip delegation only when the task is trivial, single-file, or you already have the exact answer. @@ -153,6 +165,7 @@ function buildSubagentPrompt(request: TaskRequest, cwd: string): string { export class Agent { private provider: XaiProvider; private bash: BashTool; + private delegations: DelegationManager; private messages: ModelMessage[] = []; private abortController: AbortController | null = null; private maxToolRounds: number; @@ -165,6 +178,7 @@ export class Agent { constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number) { this.provider = createProvider(apiKey, baseURL); this.bash = new BashTool(); + this.delegations = new DelegationManager(() => this.bash.getCwd()); this.modelId = model || "grok-4-1-fast"; this.maxToolRounds = maxToolRounds || MAX_TOOL_ROUNDS; const envMax = Number(process.env.GROK_MAX_TOKENS); @@ -223,7 +237,22 @@ export class Agent { } } - private async runTask(request: TaskRequest): Promise { + async consumeBackgroundNotifications(): Promise { + try { + const notifications = await this.delegations.consumeNotifications(); + for (const notification of notifications) { + this.messages.push({ role: "system", content: notification.message }); + } + return notifications.map((notification) => notification.message); + } catch { + return []; + } + } + + async runTaskRequest( + request: TaskRequest, + onActivity?: (detail: string) => void, + ): Promise { const childMode: AgentMode = request.agent === "explore" ? "ask" : "agent"; const childBash = new BashTool(this.bash.getCwd()); const childTools = createTools(childBash, this.provider, childMode); @@ -231,11 +260,7 @@ export class Agent { let assistantText = ""; let lastActivity = initialDetail; - this.emitSubagentStatus({ - agent: request.agent, - description: request.description, - detail: initialDetail, - }); + onActivity?.(initialDetail); try { const result = streamText({ @@ -258,11 +283,7 @@ export class Agent { if (part.type === "tool-call") { lastActivity = formatSubagentActivity(part.toolName, part.input); - this.emitSubagentStatus({ - agent: request.agent, - description: request.description, - detail: lastActivity, - }); + onActivity?.(lastActivity); } } @@ -292,15 +313,87 @@ export class Agent { activity: lastActivity, }, }; + } + } + + private async runTask(request: TaskRequest): Promise { + try { + return await this.runTaskRequest(request, (detail) => { + this.emitSubagentStatus({ + agent: request.agent, + description: request.description, + detail, + }); + }); } finally { this.emitSubagentStatus(null); } } + private async runDelegation(request: TaskRequest): Promise { + try { + return await this.delegations.start(request, { + model: this.modelId, + maxToolRounds: this.maxToolRounds, + maxTokens: this.maxTokens, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + success: false, + output: `Delegation failed: ${msg}`, + }; + } + } + + private async readDelegation(id: string): Promise { + try { + return { + success: true, + output: await this.delegations.read(id), + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + success: false, + output: `Failed to read delegation: ${msg}`, + }; + } + } + + private async listDelegations(): Promise { + try { + const delegations = await this.delegations.list(); + if (delegations.length === 0) { + return { + success: true, + output: "No delegations found for this project.", + }; + } + + const lines = delegations.map((delegation) => { + const title = delegation.description || delegation.id; + return `- \`${delegation.id}\` [${delegation.status}] ${title}\n ${delegation.summary}`; + }); + + return { + success: true, + output: `## Delegations\n\n${lines.join("\n")}`, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + success: false, + output: `Failed to list delegations: ${msg}`, + }; + } + } + async *processMessage(userMessage: string): AsyncGenerator { this.abortController = new AbortController(); this.emitSubagentStatus(null); + await this.consumeBackgroundNotifications(); this.messages.push({ role: "user", content: userMessage }); let assistantText = ""; @@ -309,6 +402,9 @@ export class Agent { try { const tools = createTools(this.bash, this.provider, this.mode, { runTask: (request) => this.runTask(request), + runDelegation: (request) => this.runDelegation(request), + readDelegation: (id) => this.readDelegation(id), + listDelegations: () => this.listDelegations(), }); const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext); this.planContext = null; @@ -413,6 +509,8 @@ function toToolResult(output: unknown): ToolResult { diff?: ToolResult["diff"]; plan?: Plan; task?: ToolResult["task"]; + delegation?: ToolResult["delegation"]; + backgroundProcess?: ToolResult["backgroundProcess"]; }; return { success: r.success, @@ -421,6 +519,8 @@ function toToolResult(output: unknown): ToolResult { diff: r.diff, plan: r.plan, task: r.task, + delegation: r.delegation, + backgroundProcess: r.backgroundProcess, }; } return { success: true, output: String(output) }; diff --git a/src/agent/delegations.ts b/src/agent/delegations.ts new file mode 100644 index 0000000..8fa0f4d --- /dev/null +++ b/src/agent/delegations.ts @@ -0,0 +1,386 @@ +import { spawn } from "child_process"; +import { createHash } from "crypto"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; +import type { DelegationRun, DelegationStatus, TaskRequest, ToolResult } from "../types/index.js"; + +const ID_ADJECTIVES = [ + "brisk", + "calm", + "clever", + "eager", + "gentle", + "keen", + "lively", + "nimble", + "quiet", + "steady", +]; + +const ID_COLORS = [ + "amber", + "blue", + "copper", + "emerald", + "indigo", + "ivory", + "silver", + "teal", + "violet", + "white", +]; + +const ID_ANIMALS = [ + "badger", + "falcon", + "fox", + "heron", + "lynx", + "otter", + "owl", + "panda", + "sparrow", + "wolf", +]; + +export interface StoredDelegation { + id: string; + agent: "explore"; + description: string; + prompt: string; + cwd: string; + model: string; + maxToolRounds: number; + maxTokens: number; + status: DelegationStatus; + startedAt: string; + completedAt?: string; + pid?: number; + error?: string; + title?: string; + summary?: string; + outputPath: string; + notifiedAt?: string; +} + +export interface DelegationNotification { + id: string; + message: string; +} + +interface StartDelegationOptions { + model: string; + maxToolRounds: number; + maxTokens: number; +} + +export class DelegationManager { + constructor(private readonly getCwd: () => string) {} + + async start(request: TaskRequest, options: StartDelegationOptions): Promise { + if (process.env.GROK_BACKGROUND_CHILD === "1") { + return { + success: false, + output: "Nested background delegations are disabled.", + }; + } + + if (request.agent !== "explore") { + return { + success: false, + output: + "Background delegations are read-only. Use `delegate` with the `explore` agent, or use `task` for foreground work that may edit files.", + }; + } + + const cwd = this.getCwd(); + const dir = await ensureDelegationsDir(cwd); + const id = await generateUniqueId(dir); + const outputPath = path.join(dir, `${id}.md`); + const jobPath = path.join(dir, `${id}.json`); + + const record: StoredDelegation = { + id, + agent: "explore", + description: request.description, + prompt: request.prompt, + cwd, + model: options.model, + maxToolRounds: options.maxToolRounds, + maxTokens: options.maxTokens, + status: "running", + startedAt: new Date().toISOString(), + outputPath, + }; + + await writeRecord(jobPath, record); + + const child = spawn( + process.execPath, + [...resolveCliArgs(), "--directory", cwd, "--background-task-file", jobPath], + { + cwd, + detached: true, + stdio: "ignore", + env: { ...process.env, GROK_BACKGROUND_CHILD: "1" }, + }, + ); + child.unref(); + + record.pid = child.pid; + await writeRecord(jobPath, record); + + const output = [ + `Delegation started: ${id}`, + "Agent: explore", + "You will be notified when it completes.", + `Use \`delegation_read("${id}")\` to retrieve the full result later.`, + ].join("\n"); + + return { + success: true, + output, + delegation: { + id, + agent: "explore", + description: request.description, + summary: "Running in the background.", + status: "running", + }, + }; + } + + async list(): Promise { + const dir = await ensureDelegationsDir(this.getCwd()); + const files = await readDelegationFiles(dir); + const items = await Promise.all(files.map(async (file) => readRecord(path.join(dir, file)))); + + return items + .filter((item): item is StoredDelegation => item !== null) + .sort((a, b) => b.startedAt.localeCompare(a.startedAt)) + .map(toDelegationRun); + } + + async read(id: string): Promise { + const record = await this.getById(id); + if (!record) { + return `Delegation "${id}" not found. Use \`delegation_list()\` to see available results.`; + } + + if (record.status === "running") { + return `Delegation "${id}" is still running. Continue working and wait for the completion notice.`; + } + + try { + return await fs.readFile(record.outputPath, "utf8"); + } catch { + if (record.error) { + return `Delegation "${id}" failed.\n\n${record.error}`; + } + return `Delegation "${id}" completed, but its saved output could not be read.`; + } + } + + async consumeNotifications(): Promise { + const dir = await ensureDelegationsDir(this.getCwd()); + const files = await readDelegationFiles(dir); + const notifications: DelegationNotification[] = []; + + for (const file of files) { + const jobPath = path.join(dir, file); + const record = await readRecord(jobPath); + if (!record || record.status === "running" || record.notifiedAt) continue; + + record.notifiedAt = new Date().toISOString(); + await writeRecord(jobPath, record); + notifications.push({ + id: record.id, + message: formatNotification(record), + }); + } + + return notifications.sort((a, b) => a.id.localeCompare(b.id)); + } + + private async getById(id: string): Promise { + const dir = await ensureDelegationsDir(this.getCwd()); + return readRecord(path.join(dir, `${id}.json`)); + } +} + +export async function loadDelegation(jobPath: string): Promise { + const record = await readRecord(jobPath); + if (!record) { + throw new Error(`Delegation job not found: ${jobPath}`); + } + return record; +} + +export async function completeDelegation( + jobPath: string, + output: string, + fallbackSummary?: string, +): Promise { + const record = await loadDelegation(jobPath); + record.status = "complete"; + record.completedAt = new Date().toISOString(); + record.title = record.title || createTitle(output, record.description); + record.summary = createSummary(output || fallbackSummary || record.description); + + await fs.mkdir(path.dirname(record.outputPath), { recursive: true }); + await fs.writeFile(record.outputPath, renderOutput(record, output), "utf8"); + await writeRecord(jobPath, record); +} + +export async function failDelegation(jobPath: string, error: string, output = ""): Promise { + const record = await loadDelegation(jobPath); + record.status = "error"; + record.completedAt = new Date().toISOString(); + record.error = error; + record.title = record.title || createTitle(output || error, record.description); + record.summary = createSummary(output || error); + + await fs.mkdir(path.dirname(record.outputPath), { recursive: true }); + await fs.writeFile(record.outputPath, renderOutput(record, output || `Error: ${error}`), "utf8"); + await writeRecord(jobPath, record); +} + +async function ensureDelegationsDir(cwd: string): Promise { + const projectId = getProjectId(cwd); + const dir = path.join(os.homedir(), ".grok", "delegations", projectId); + await fs.mkdir(dir, { recursive: true }); + return dir; +} + +async function readDelegationFiles(dir: string): Promise { + try { + const files = await fs.readdir(dir); + return files.filter((file) => file.endsWith(".json")); + } catch { + return []; + } +} + +async function readRecord(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as StoredDelegation; + } catch { + return null; + } +} + +async function writeRecord(filePath: string, record: StoredDelegation): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8"); +} + +async function generateUniqueId(dir: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const id = randomId(); + try { + await fs.access(path.join(dir, `${id}.json`)); + } catch { + return id; + } + } + + throw new Error("Failed to allocate a unique delegation ID."); +} + +function randomId(): string { + return `${pick(ID_ADJECTIVES)}-${pick(ID_COLORS)}-${pick(ID_ANIMALS)}`; +} + +function pick(values: readonly string[]): string { + return values[Math.floor(Math.random() * values.length)]; +} + +function resolveCliArgs(): string[] { + const entry = process.argv[1]; + if (!entry) { + throw new Error("Could not resolve the CLI entrypoint for background delegation."); + } + return [entry]; +} + +function getProjectId(cwd: string): string { + const base = path.basename(cwd).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project"; + const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 10); + return `${base}-${hash}`; +} + +function createTitle(text: string, fallback: string): string { + const firstLine = text + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + const source = firstLine || fallback.trim() || "Background delegation"; + return source.length <= 48 ? source : `${source.slice(0, 45).trimEnd()}...`; +} + +function createSummary(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (!compact) return "No summary available."; + return compact.length <= 180 ? compact : `${compact.slice(0, 177).trimEnd()}...`; +} + +function renderOutput(record: StoredDelegation, content: string): string { + const title = record.title || record.id; + const summary = record.summary || "No summary available."; + const completed = record.completedAt || "N/A"; + const error = record.error ? `\n**Error:** ${record.error}\n` : ""; + + return [ + `# ${title}`, + "", + summary, + "", + `**ID:** ${record.id}`, + `**Agent:** ${record.agent}`, + `**Status:** ${record.status}`, + `**Started:** ${record.startedAt}`, + `**Completed:** ${completed}`, + "", + `**Prompt:** ${record.description}`, + error.trimEnd(), + "", + "---", + "", + content.trim() || "(No output)", + "", + ] + .filter(Boolean) + .join("\n"); +} + +function formatNotification(record: StoredDelegation): string { + const title = record.title || record.description || record.id; + const summary = record.summary || (record.error ? createSummary(record.error) : "No summary available."); + const statusText = record.status === "complete" ? "complete" : "failed"; + const lines = [ + `Background agent ${statusText}: \`${record.id}\``, + `Title: ${title}`, + `Summary: ${summary}`, + ]; + + if (record.error) { + lines.push(`Error: ${record.error}`); + } + + lines.push(`Use \`delegation_read("${record.id}")\` to retrieve the full result.`); + return lines.join("\n"); +} + +function toDelegationRun(record: StoredDelegation): DelegationRun { + return { + id: record.id, + agent: "explore", + description: record.description, + summary: + record.summary || + (record.status === "running" ? "Running in the background." : "No summary available."), + status: record.status, + }; +} diff --git a/src/grok/tools.ts b/src/grok/tools.ts index d4daaca..585e038 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -9,6 +9,9 @@ const SEARCH_MODEL = "grok-3-mini-fast"; interface CreateToolsOptions { runTask?: (request: TaskRequest) => Promise; + runDelegation?: (request: TaskRequest) => Promise; + readDelegation?: (id: string) => Promise; + listDelegations?: () => Promise; } export function createTools( @@ -22,17 +25,26 @@ export function createTools( const base = { bash: tool({ description: - "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. For file read/write/edit, prefer the dedicated file tools instead.", + "Execute a bash command. Use for searching (grep, rg, find), git, build tools, package managers, running tests, and any other shell command. Set background=true for long-running processes like dev servers, watchers, or anything that should keep running while you continue working. For file read/write/edit, prefer the dedicated file tools instead.", inputSchema: z.object({ command: z.string().describe("The bash command to execute"), timeout: z .number() .optional() .describe( - "Timeout in milliseconds (default: 30000). Use higher values for long-running commands.", + "Timeout in milliseconds (default: 30000). Use higher values for long-running commands. Ignored when background=true.", + ), + background: z + .boolean() + .optional() + .describe( + "Run the command as a background process. Returns immediately with a process ID. Use process_logs/process_stop/process_list to manage it.", ), }), - execute: async ({ command, timeout }) => { + execute: async ({ command, timeout, background }) => { + if (background) { + return bash.startBackground(command); + } const result = await bash.execute(command, timeout); return { success: result.success, @@ -43,6 +55,41 @@ export function createTools( }, }), + process_logs: tool({ + description: + "View recent output (stdout + stderr) from a background process by its ID. Returns the last N lines of the log.", + inputSchema: z.object({ + id: z.number().describe("The background process ID"), + tail: z + .number() + .optional() + .describe("Number of lines to return from the end (default: 50)"), + }), + execute: async ({ id, tail }) => { + return bash.getProcessLogs(id, tail); + }, + }), + + process_stop: tool({ + description: + "Stop a running background process by its ID. Sends SIGTERM, then SIGKILL after 3 seconds if still alive.", + inputSchema: z.object({ + id: z.number().describe("The background process ID to stop"), + }), + execute: async ({ id }) => { + return bash.stopProcess(id); + }, + }), + + process_list: tool({ + description: + "List all background processes with their ID, status, PID, age, and command.", + inputSchema: z.object({}), + execute: async () => { + return bash.listProcesses(); + }, + }), + read_file: tool({ description: "Read the contents of a file. Returns numbered lines with a header showing the range and total line count. Use start_line/end_line to read specific sections of large files iteratively.", @@ -155,7 +202,7 @@ export function createTools( if (options.runTask) { tools.task = tool({ description: - "Delegate a focused task to a sub-agent. Prefer this proactively for review, research, investigation, and code quality work instead of waiting for the user to request a sub-agent. Use `general` for multi-step execution and `explore` for fast read-only investigation. Provide a short description plus a detailed prompt for the child agent.", + "Delegate a focused foreground task to a sub-agent. Prefer this proactively for review, research, investigation, and code quality work instead of waiting for the user to request a sub-agent. Use `general` for multi-step execution and `explore` for fast read-only investigation. Provide a short description plus a detailed prompt for the child agent.", inputSchema: z.object({ agent: z .enum(["general", "explore"]) @@ -173,6 +220,52 @@ export function createTools( }, }); } + + if (options.runDelegation) { + tools.delegate = tool({ + description: + "Launch a read-only background agent that can keep researching while you continue working. Use this only for `explore` tasks that do not edit files or make shell changes. You will be notified when it completes.", + inputSchema: z.object({ + agent: z + .enum(["explore"]) + .default("explore") + .describe("Background delegations currently support only the read-only explore agent"), + description: z + .string() + .describe("A short label for the delegation, such as 'OAuth callback research'"), + prompt: z + .string() + .describe("Detailed instructions for the background agent to complete"), + }), + execute: async ({ agent, description, prompt }) => { + return options.runDelegation!({ agent, description, prompt }); + }, + }); + } + + if (options.readDelegation) { + tools.delegation_read = tool({ + description: + "Read the saved output of a background delegation by ID. Use this after a completion notice or when revisiting prior research.", + inputSchema: z.object({ + id: z.string().describe("The delegation ID, such as 'calm-blue-fox'"), + }), + execute: async ({ id }) => { + return options.readDelegation!(id); + }, + }); + } + + if (options.listDelegations) { + tools.delegation_list = tool({ + description: + "List recent background delegations for the current project. Use sparingly to discover IDs or review prior results, not for repeated polling.", + inputSchema: z.object({}), + execute: async () => { + return options.listDelegations!(); + }, + }); + } } if (mode !== "plan") return tools; diff --git a/src/index.ts b/src/index.ts index 5c34c33..6f615af 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { createElement } from "react"; import { Agent } from "./agent/agent.js"; +import { completeDelegation, failDelegation, loadDelegation } from "./agent/delegations.js"; import { App } from "./ui/app.js"; import { getApiKey, getBaseURL, getCurrentModel, saveUserSettings } from "./utils/settings.js"; import { MODELS } from "./grok/models.js"; @@ -83,6 +84,48 @@ async function runHeadless( } } +async function runBackgroundDelegation(jobPath: string, options: Record) { + let output = ""; + + try { + const delegation = await loadDelegation(jobPath); + const apiKey = options.apiKey || getApiKey(); + if (!apiKey) { + throw new Error( + "API key required. Set GROK_API_KEY, use --api-key, or save it to ~/.grok/user-settings.json.", + ); + } + + const baseURL = options.baseUrl || getBaseURL(); + const model = options.model || delegation.model || getCurrentModel(); + const maxToolRounds = parseInt(options.maxToolRounds || String(delegation.maxToolRounds), 10) + || delegation.maxToolRounds; + const agent = new Agent(apiKey, baseURL, model, maxToolRounds); + const result = await agent.runTaskRequest({ + agent: delegation.agent, + description: delegation.description, + prompt: delegation.prompt, + }); + + output = (result.output || "").trim(); + + if (!result.success) { + await failDelegation(jobPath, result.output || result.error || "Background delegation failed.", output); + return; + } + + await completeDelegation(jobPath, output, result.task?.summary); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + try { + await failDelegation(jobPath, msg, output); + } catch { + // Best effort — background tasks should fail silently if persistence is unavailable. + } + process.exit(1); + } +} + function resolveConfig(options: Record) { const apiKey = options.apiKey || getApiKey(); const baseURL = options.baseUrl || getBaseURL(); @@ -112,6 +155,7 @@ program .option("-m, --model ", "Model to use") .option("-d, --directory ", "Working directory", process.cwd()) .option("-p, --prompt ", "Run a single prompt headlessly") + .option("--background-task-file ", "Run a persisted background delegation") .option("--max-tool-rounds ", "Max tool execution rounds", "400") .action(async (message: string[], options) => { if (options.directory) { @@ -124,6 +168,11 @@ program } } + if (options.backgroundTaskFile) { + await runBackgroundDelegation(options.backgroundTaskFile, options); + return; + } + const config = resolveConfig(options); if (options.prompt) { diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 5331cb7..fba4657 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -1,18 +1,46 @@ -import { exec } from "child_process"; -import { stat } from "fs/promises"; +import { exec, spawn, type ChildProcess } from "child_process"; +import { createWriteStream, createReadStream } from "fs"; +import { stat, mkdtemp, readFile, unlink, rm } from "fs/promises"; +import os from "os"; import path from "path"; import { promisify } from "util"; import type { ToolResult } from "../types/index.js"; const execAsync = promisify(exec); +const MAX_TAIL_BYTES = 8_192; +const MAX_BACKGROUND_PROCESSES = 8; + +export interface BackgroundProcess { + id: number; + command: string; + pid: number; + cwd: string; + startedAt: Date; + child: ChildProcess; + logPath: string; + alive: boolean; + exitCode: number | null; +} + +let nextBgId = 1; + export class BashTool { private cwd: string; + private bgProcesses = new Map(); + private tmpDir: string | null = null; constructor(initialCwd = process.cwd()) { this.cwd = initialCwd; } + private async ensureTmpDir(): Promise { + if (!this.tmpDir) { + this.tmpDir = await mkdtemp(path.join(os.tmpdir(), "grok-bg-")); + } + return this.tmpDir; + } + async execute(command: string, timeout = 30_000): Promise { try { if (command.startsWith("cd ")) { @@ -57,7 +85,191 @@ export class BashTool { } } + async startBackground(command: string): Promise { + const alive = [...this.bgProcesses.values()].filter((p) => p.alive); + if (alive.length >= MAX_BACKGROUND_PROCESSES) { + return { + success: false, + output: `Too many background processes (${alive.length}/${MAX_BACKGROUND_PROCESSES}). Stop one first with process_stop.`, + }; + } + + try { + const tmpDir = await this.ensureTmpDir(); + const id = nextBgId++; + const logPath = path.join(tmpDir, `bg-${id}.log`); + const logStream = createWriteStream(logPath, { flags: "a" }); + + const child = spawn("sh", ["-c", command], { + cwd: this.cwd, + detached: false, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, FORCE_COLOR: "0" }, + }); + + child.stdout?.pipe(logStream); + child.stderr?.pipe(logStream); + + const entry: BackgroundProcess = { + id, + command, + pid: child.pid ?? 0, + cwd: this.cwd, + startedAt: new Date(), + child, + logPath, + alive: true, + exitCode: null, + }; + + child.on("exit", (code) => { + entry.alive = false; + entry.exitCode = code; + logStream.end(); + }); + + child.on("error", () => { + entry.alive = false; + logStream.end(); + }); + + this.bgProcesses.set(id, entry); + + return { + success: true, + output: [ + `Background process started (id: ${id}, pid: ${entry.pid})`, + `Command: ${truncCmd(command, 80)}`, + `Use process_logs(${id}) to view output, process_stop(${id}) to terminate.`, + ].join("\n"), + backgroundProcess: { id, pid: entry.pid, command }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, output: `Failed to start background process: ${msg}` }; + } + } + + async getProcessLogs(id: number, tail = 50): Promise { + const entry = this.bgProcesses.get(id); + if (!entry) { + return { success: false, output: `No background process with id ${id}.` }; + } + + try { + const stats = await stat(entry.logPath); + const start = Math.max(0, stats.size - MAX_TAIL_BYTES); + + const content = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const stream = createReadStream(entry.logPath, { start }); + stream.on("data", (chunk: Buffer | string) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + stream.on("error", reject); + }); + + const lines = content.split("\n"); + const tailed = lines.slice(-tail).join("\n").trimEnd(); + const status = entry.alive ? "running" : `exited (code ${entry.exitCode ?? "unknown"})`; + + return { + success: true, + output: [ + `[Process ${id} — ${status} — pid ${entry.pid}]`, + `[${truncCmd(entry.command, 70)}]`, + "", + tailed || "(no output yet)", + ].join("\n"), + }; + } catch { + return { + success: true, + output: `[Process ${id} — ${entry.alive ? "running" : "exited"}] (no output yet)`, + }; + } + } + + async stopProcess(id: number): Promise { + const entry = this.bgProcesses.get(id); + if (!entry) { + return { success: false, output: `No background process with id ${id}.` }; + } + + if (!entry.alive) { + return { + success: true, + output: `Process ${id} already exited (code ${entry.exitCode ?? "unknown"}).`, + }; + } + + try { + entry.child.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + try { entry.child.kill("SIGKILL"); } catch { /* already dead */ } + resolve(); + }, 3_000); + entry.child.on("exit", () => { clearTimeout(timeout); resolve(); }); + }); + + return { + success: true, + output: `Process ${id} (pid ${entry.pid}) stopped.`, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, output: `Failed to stop process ${id}: ${msg}` }; + } + } + + listProcesses(): ToolResult { + const entries = [...this.bgProcesses.values()]; + if (entries.length === 0) { + return { success: true, output: "No background processes." }; + } + + const lines = entries.map((entry) => { + const status = entry.alive ? "running" : `exited(${entry.exitCode ?? "?"})`; + const age = formatAge(entry.startedAt); + return `${entry.id} ${status} pid:${entry.pid} ${age} ${truncCmd(entry.command, 50)}`; + }); + + return { + success: true, + output: ["ID STATUS PID AGE COMMAND", ...lines].join("\n"), + }; + } + + async cleanup(): Promise { + for (const entry of this.bgProcesses.values()) { + if (entry.alive) { + try { entry.child.kill("SIGTERM"); } catch { /* */ } + } + try { await unlink(entry.logPath); } catch { /* */ } + } + this.bgProcesses.clear(); + if (this.tmpDir) { + try { await rm(this.tmpDir, { recursive: true, force: true }); } catch { /* */ } + } + } + getCwd(): string { return this.cwd; } } + +function truncCmd(cmd: string, max: number): string { + const oneLine = cmd.replace(/\n/g, " ").trim(); + return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`; +} + +function formatAge(start: Date): string { + const sec = Math.round((Date.now() - start.getTime()) / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m${sec % 60}s`; + const hr = Math.floor(min / 60); + return `${hr}h${min % 60}m`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 2309f39..ce31d50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -40,12 +40,28 @@ export interface TaskRun { activity?: string; } +export type DelegationStatus = "running" | "complete" | "error"; + +export interface DelegationRun { + id: string; + agent: "explore"; + description: string; + summary: string; + status: DelegationStatus; +} + export interface SubagentStatus { agent: "general" | "explore"; description: string; detail: string; } +export interface BackgroundProcessInfo { + id: number; + pid: number; + command: string; +} + export interface ToolResult { success: boolean; output?: string; @@ -53,6 +69,8 @@ export interface ToolResult { diff?: FileDiff; plan?: Plan; task?: TaskRun; + delegation?: DelegationRun; + backgroundProcess?: BackgroundProcessInfo; } export interface ToolCall { @@ -69,6 +87,7 @@ export interface ChatEntry { content: string; timestamp: Date; modeColor?: string; + queued?: boolean; toolCalls?: ToolCall[]; toolCall?: ToolCall; toolResult?: ToolResult; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 1b7acd2..42a7874 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -147,6 +147,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const contentAccRef = useRef(""); const startTimeRef = useRef(0); const isProcessingRef = useRef(false); + const queuedMessagesRef = useRef([]); + const [queuedMessages, setQueuedMessages] = useState([]); const setMode = useCallback((m: AgentMode) => { if (m === "agent" && mode === "plan" && activePlan) { @@ -241,16 +243,49 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); setActiveSubagent(null); - isProcessingRef.current = false; - setIsProcessing(false); setTimeout(scrollToBottom, 50); + const nextQueued = queuedMessagesRef.current.shift(); + if (nextQueued) { + setQueuedMessages([...queuedMessagesRef.current]); + isProcessingRef.current = false; + processMessage(nextQueued); + } else { + isProcessingRef.current = false; + setIsProcessing(false); + } + setTimeout(scrollToBottom, 50); }, [agent, scrollToBottom, modeInfo, model]); useEffect(() => { if (initialMessage && !processedInitial.current) { processedInitial.current = true; processMessage(initialMessage); } }, [initialMessage, processMessage]); useEffect(() => agent.onSubagentStatus(setActiveSubagent), [agent]); + useEffect(() => { + let active = true; + const id = setInterval(() => { + agent + .consumeBackgroundNotifications() + .then((notifications) => { + if (!active || notifications.length === 0) return; + setMessages((prev) => [ + ...prev, + ...notifications.map((message) => ({ + type: "assistant" as const, + content: message, + timestamp: new Date(), + })), + ]); + setTimeout(scrollToBottom, 10); + }) + .catch(() => {}); + }, 2000); + + return () => { + active = false; + clearInterval(id); + }; + }, [agent, scrollToBottom]); const handleCommand = useCallback((cmd: string): boolean => { const c = cmd.trim().toLowerCase(); - if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); setActivePlan(null); setPqs(initialPlanQuestionsState()); imageCounterRef.current = 0; return true; } + if (c === "/clear") { agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); setActivePlan(null); setPqs(initialPlanQuestionsState()); queuedMessagesRef.current = []; setQueuedMessages([]); imageCounterRef.current = 0; return true; } if (c === "/model" || c === "/models") { setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery(""); return true; } if (c === "/quit" || c === "/exit" || c === "/q") { onExit ? onExit() : process.exit(0); } return false; @@ -262,6 +297,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { switch (item.id) { case "new": agent.clearHistory(); setMessages([]); setStreamContent(""); setSessionTitle(null); setPasteBlocks([]); setActivePlan(null); setPqs(initialPlanQuestionsState()); + queuedMessagesRef.current = []; setQueuedMessages([]); break; case "models": setShowModelPicker(true); setModelPickerIndex(0); setModelSearchQuery(""); @@ -488,7 +524,15 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } return; } - if (isProcessing && key.name === "escape") { agent.abort(); return; } + if (isProcessing && key.name === "escape") { + if (queuedMessagesRef.current.length > 0) { + queuedMessagesRef.current = []; + setQueuedMessages([]); + } else { + agent.abort(); + } + return; + } if (key.sequence === "/" && !isProcessing) { const text = inputRef.current?.plainText || ""; if (!text.trim()) { setShowSlashMenu(true); setSlashMenuIndex(0); setSlashSearchQuery(""); return; } @@ -539,8 +583,14 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } if (!message.trim()) return; if (handleCommand(message)) return; + if (isProcessingRef.current) { + queuedMessagesRef.current.push(message.trim()); + setQueuedMessages([...queuedMessagesRef.current]); + setTimeout(scrollToBottom, 10); + return; + } processMessage(message); - }, [handleCommand, processMessage, pasteBlocks]); + }, [handleCommand, processMessage, pasteBlocks, scrollToBottom]); const hasMessages = messages.length > 0 || streamContent || isProcessing; @@ -559,6 +609,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { {activeToolCalls.map((tc, i) => ( tc.function.name === "task" ? + : tc.function.name === "delegate" + ? : {toolLabel(tc)} ))} {activeSubagent && } @@ -585,7 +637,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { + modeInfo={modeInfo} model={model} modelInfo={modelInfo} queuedCount={queuedMessages.length} queuedMessages={queuedMessages} /> ) : ( @@ -647,20 +699,40 @@ const TEXTAREA_KEYBINDINGS: KeyBinding[] = [ { name: "return", shift: true, action: "newline" }, ]; -function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu, showPlanQuestions, onSubmit, onPaste, pasteBlocks, modeInfo, model, modelInfo, placeholder }: { +function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu, showPlanQuestions, onSubmit, onPaste, pasteBlocks, modeInfo, model, modelInfo, placeholder, queuedCount, queuedMessages }: { t: Theme; inputRef: React.RefObject; isProcessing: boolean; showModelPicker: boolean; showSlashMenu: boolean; showPlanQuestions: boolean; onSubmit: () => void; onPaste: (event: PasteEvent) => void; pasteBlocks: { id: number; content: string; lines: number }[]; modeInfo: typeof MODES[number]; model: string; - modelInfo: ReturnType; placeholder?: string; + modelInfo: ReturnType; placeholder?: string; queuedCount?: number; queuedMessages?: string[]; }) { + const hasQueue = (queuedMessages?.length ?? 0) > 0; + return ( + {hasQueue && ( + + {queuedMessages!.map((msg, i) => ( + {"→ "}{msg} + ))} + + + {"enter "} + {"send now"} + {" · "} + {"↑ "} + {"edit"} + {" · "} + {"esc "} + {"cancel"} + + + )}