feat: complete architecture rewrite — Bun, OpenTUI, OpenCode-style UI, X Search API (#145)

* 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 <homanp@users.noreply.github.com>

* Fix HelpPanel rendering bug: replace <br/> with Text component

- Replaced <br/> tag with <text>{" "}</text> 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 <homanp@users.noreply.github.com>

* 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 <br/> tag (must be inside text node in OpenTUI)

Co-authored-by: Ismail Pelaseyed <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <homanp@users.noreply.github.com>

* 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 <cursoragent@cursor.com>
Co-authored-by: Ismail Pelaseyed <homanp@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-03-19 12:55:55 +01:00
1 parent 17224a87bb
commit 85c2b7caf4
8 files changed
+1133 -35

No files matched your search

+117 -17
View File
@@ -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<ToolResult> {
async consumeBackgroundNotifications(): Promise<string[]> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<StreamChunk, void, unknown> {
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) };
+386
View File
@@ -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<ToolResult> {
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<DelegationRun[]> {
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<string> {
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<DelegationNotification[]> {
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<StoredDelegation | null> {
const dir = await ensureDelegationsDir(this.getCwd());
return readRecord(path.join(dir, `${id}.json`));
}
}
export async function loadDelegation(jobPath: string): Promise<StoredDelegation> {
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<void> {
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<void> {
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<string> {
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<string[]> {
try {
const files = await fs.readdir(dir);
return files.filter((file) => file.endsWith(".json"));
} catch {
return [];
}
}
async function readRecord(filePath: string): Promise<StoredDelegation | null> {
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<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(record, null, 2), "utf8");
}
async function generateUniqueId(dir: string): Promise<string> {
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,
};
}
+97 -4
View File
@@ -9,6 +9,9 @@ const SEARCH_MODEL = "grok-3-mini-fast";
interface CreateToolsOptions {
runTask?: (request: TaskRequest) => Promise<ToolResult>;
runDelegation?: (request: TaskRequest) => Promise<ToolResult>;
readDelegation?: (id: string) => Promise<ToolResult>;
listDelegations?: () => Promise<ToolResult>;
}
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;
+49
View File
@@ -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<string, string | undefined>) {
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<string, string | undefined>) {
const apiKey = options.apiKey || getApiKey();
const baseURL = options.baseUrl || getBaseURL();
@@ -112,6 +155,7 @@ program
.option("-m, --model <model>", "Model to use")
.option("-d, --directory <dir>", "Working directory", process.cwd())
.option("-p, --prompt <prompt>", "Run a single prompt headlessly")
.option("--background-task-file <path>", "Run a persisted background delegation")
.option("--max-tool-rounds <n>", "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) {
+214 -2
View File
@@ -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<number, BackgroundProcess>();
private tmpDir: string | null = null;
constructor(initialCwd = process.cwd()) {
this.cwd = initialCwd;
}
private async ensureTmpDir(): Promise<string> {
if (!this.tmpDir) {
this.tmpDir = await mkdtemp(path.join(os.tmpdir(), "grok-bg-"));
}
return this.tmpDir;
}
async execute(command: string, timeout = 30_000): Promise<ToolResult> {
try {
if (command.startsWith("cd ")) {
@@ -57,7 +85,191 @@ export class BashTool {
}
}
async startBackground(command: string): Promise<ToolResult> {
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<ToolResult> {
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<string>((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<ToolResult> {
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<void>((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<void> {
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`;
}
+19
View File
@@ -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;
+250 -12
View File
@@ -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<string[]>([]);
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
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"
? <SubagentTaskLine key={i} t={t} label={toolArgs(tc) || "Working"} pending />
: tc.function.name === "delegate"
? <DelegationTaskLine key={i} t={t} label={toolArgs(tc) || "Background research"} pending id={undefined} />
: <InlineTool key={i} t={t} pending>{toolLabel(tc)}</InlineTool>
))}
{activeSubagent && <SubagentActivity t={t} status={activeSubagent} />}
@@ -585,7 +637,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
<box flexShrink={0}>
<PromptBox t={t} inputRef={inputRef} isProcessing={isProcessing} showModelPicker={showModelPicker} showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel}
onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks}
modeInfo={modeInfo} model={model} modelInfo={modelInfo} />
modeInfo={modeInfo} model={model} modelInfo={modelInfo} queuedCount={queuedMessages.length} queuedMessages={queuedMessages} />
</box>
</box>
) : (
@@ -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<TextareaRenderable | null>;
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<typeof getModelInfo>; placeholder?: string;
modelInfo: ReturnType<typeof getModelInfo>; placeholder?: string; queuedCount?: number; queuedMessages?: string[];
}) {
const hasQueue = (queuedMessages?.length ?? 0) > 0;
return (
<box backgroundColor={t.backgroundPanel}>
<box>
{hasQueue && (
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={t.queueBg} flexShrink={0}>
{queuedMessages!.map((msg, i) => (
<text key={i} fg={t.text}>{"→ "}{msg}</text>
))}
<box height={1} />
<text>
<span style={{ fg: t.primary }}>{"enter "}</span>
<span style={{ fg: t.textMuted }}>{"send now"}</span>
<span style={{ fg: t.textDim }}>{" · "}</span>
<span style={{ fg: t.primary }}>{"↑ "}</span>
<span style={{ fg: t.textMuted }}>{"edit"}</span>
<span style={{ fg: t.textDim }}>{" · "}</span>
<span style={{ fg: t.primary }}>{"esc "}</span>
<span style={{ fg: t.textMuted }}>{"cancel"}</span>
</text>
</box>
)}
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={t.backgroundElement} flexShrink={0}>
<textarea
ref={inputRef} focused={!isProcessing && !showModelPicker && !showSlashMenu && !showPlanQuestions}
placeholder={isProcessing ? "Working... (esc to stop)" : (placeholder || "Message Grok...")}
ref={inputRef} focused={!showModelPicker && !showSlashMenu && !showPlanQuestions}
placeholder={isProcessing ? "Queue a follow-up... (esc to interrupt)" : (placeholder || "Message Grok...")}
textColor={t.text} backgroundColor={t.backgroundElement} placeholderColor={t.textMuted}
minHeight={1} maxHeight={10} wrapMode="word"
keyBindings={TEXTAREA_KEYBINDINGS}
@@ -676,7 +748,10 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, showSlashMenu,
</box>
<box flexDirection="row" gap={3} alignItems="center">
{isProcessing ? (
<text fg={t.text}>{"esc "}<span style={{ fg: t.textMuted }}>{"interrupt"}</span></text>
<box flexDirection="row" gap={3}>
<text fg={t.text}>{"enter "}<span style={{ fg: t.textMuted }}>{"queue"}</span></text>
<text fg={t.text}>{"esc "}<span style={{ fg: t.textMuted }}>{(queuedCount ?? 0) > 0 ? "clear queue" : "interrupt"}</span></text>
</box>
) : (
<>
<text fg={t.text}>{"shift+enter "}<span style={{ fg: t.textMuted }}>{"new line"}</span></text>
@@ -736,6 +811,18 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
return <TaskResultView t={t} entry={entry} />;
}
if (name === "delegate" && entry.toolResult?.delegation) {
return <DelegationResultView t={t} entry={entry} />;
}
if (name === "delegation_list") {
return <DelegationListView t={t} content={entry.content} />;
}
if (name === "delegation_read") {
return <ToolTextOutputView t={t} label={toolLabel(entry.toolCall!)} content={entry.content} />;
}
if (name === "write_file" || name === "edit_file") {
const filePath = diff?.filePath || tryParseArg(entry.toolCall, "path") || args;
const label = name === "write_file" ? `Write ${filePath}` : `Edit ${filePath}`;
@@ -747,6 +834,19 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
);
}
if (name === "bash" && entry.toolResult?.backgroundProcess) {
const bp = entry.toolResult.backgroundProcess;
return <BackgroundProcessLine t={t} id={bp.id} pid={bp.pid} command={bp.command} />;
}
if (name === "process_logs") {
return <ProcessLogsView t={t} content={entry.content} />;
}
if (name === "process_stop" || name === "process_list") {
return <InlineTool t={t} pending={false}>{entry.content}</InlineTool>;
}
if (name === "read_file") return <InlineTool t={t} pending={false}>{`Read ${trunc(tryParseArg(entry.toolCall, "path") || args, 60)}`}</InlineTool>;
if (name === "search_web" || name === "search_x") return <InlineTool t={t} pending={false}>{name === "search_web" ? "Web" : "X"}{` Search "${trunc(args, 60)}"`}</InlineTool>;
@@ -920,6 +1020,30 @@ function SubagentTaskLine({ t, label, pending }: { t: Theme; label: string; pend
);
}
function DelegationTaskLine({ t, label, pending, id }: { t: Theme; label: string; pending: boolean; id?: string }) {
const displayLabel = compactTaskLabel(label);
return (
<box paddingLeft={3}>
<text>
{pending ? (
<span style={{ fg: t.subagentAccent }}>
<LoadingSpinner />
</span>
) : (
<span style={{ fg: t.subagentAccent }}>{"◆"}</span>
)}
{" "}
<span style={{ fg: t.subagentAccent }}>
<b>{"Background"}</b>
</span>
<span style={{ fg: t.textMuted }}>{" — "}{displayLabel}</span>
{id ? <span style={{ fg: t.textDim }}>{` (${id})`}</span> : null}
</text>
</box>
);
}
function LoadingSpinner() {
const [frame, setFrame] = useState(0);
@@ -959,6 +1083,105 @@ function TaskResultView({ t, entry }: { t: Theme; entry: ChatEntry }) {
);
}
function DelegationResultView({ t, entry }: { t: Theme; entry: ChatEntry }) {
const delegation = entry.toolResult?.delegation;
if (!delegation) return null;
return (
<DelegationTaskLine t={t} label={delegation.description} pending={false} id={delegation.id} />
);
}
function DelegationListView({ t, content }: { t: Theme; content: string }) {
const items = parseDelegationList(content);
if (items.length === 0) {
return <InlineTool t={t} pending={false}>{"No background delegations"}</InlineTool>;
}
return (
<box paddingLeft={3} gap={0}>
{items.map((item, i) => {
const statusColor = item.status === "complete" ? "#8adf8a"
: item.status === "running" ? t.subagentAccent
: item.status === "error" ? "#df8a8a"
: t.textMuted;
return (
<box key={i}>
<text>
<span style={{ fg: statusColor }}>{"◆ "}</span>
<span style={{ fg: t.text }}>{item.id}</span>
<span style={{ fg: statusColor }}>{` ${item.status}`}</span>
<span style={{ fg: t.textMuted }}>{" — "}{truncateLine(item.label, 60)}</span>
</text>
</box>
);
})}
</box>
);
}
function parseDelegationList(content: string): { id: string; status: string; label: string }[] {
const items: { id: string; status: string; label: string }[] = [];
for (const line of content.split("\n")) {
const match = line.match(/`([^`]+)`\s+\[(\w+)]\s+(.*)/);
if (match) {
items.push({ id: match[1], status: match[2], label: match[3].trim() });
}
}
return items;
}
function BackgroundProcessLine({ t, id, pid, command }: { t: Theme; id: number; pid: number; command: string }) {
return (
<box paddingLeft={3}>
<text>
<span style={{ fg: t.subagentAccent }}>{"◆ "}</span>
<span style={{ fg: t.subagentAccent }}><b>{"Background process"}</b></span>
<span style={{ fg: t.textMuted }}>{` id:${id} pid:${pid}`}</span>
<span style={{ fg: t.textDim }}>{" — "}{truncateLine(command, 60)}</span>
</text>
</box>
);
}
function ProcessLogsView({ t, content }: { t: Theme; content: string }) {
const lines = content.split("\n");
const header = lines[0] || "";
const body = lines.slice(1).join("\n").trim();
return (
<box paddingLeft={3} gap={0}>
<text fg={t.textMuted}>{"→ "}{header}</text>
{body ? (
<box paddingLeft={2} marginTop={0}>
<box backgroundColor={t.mdCodeBlockBg} paddingLeft={1} paddingRight={1}>
<text fg={t.mdCodeBlockFg}>{truncateBlock(body, 15)}</text>
</box>
</box>
) : null}
</box>
);
}
function truncateBlock(text: string, maxLines: number): string {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
return [...lines.slice(0, maxLines), `… ${lines.length - maxLines} more lines`].join("\n");
}
function ToolTextOutputView({ t, label, content }: { t: Theme; label: string; content: string }) {
return (
<box gap={0}>
<InlineTool t={t} pending={false}>{label}</InlineTool>
<box paddingLeft={5} marginTop={1} flexShrink={0}>
<Markdown content={content} t={t} />
</box>
</box>
);
}
/* ── Slash Menu ──────────────────────────────────────────────── */
function SlashMenuModal({ t, selectedIndex, width, height, searchQuery, filteredItems }: {
@@ -1067,6 +1290,9 @@ function toolArgs(tc?: ToolCall): string {
if (tc.function.name === "bash") return a.command || "";
if (tc.function.name === "read_file" || tc.function.name === "write_file" || tc.function.name === "edit_file") return a.path || "";
if (tc.function.name === "task") return a.description || "";
if (tc.function.name === "delegate") return a.description || "";
if (tc.function.name === "delegation_read") return a.id || "";
if (tc.function.name === "process_logs" || tc.function.name === "process_stop") return a.id != null ? String(a.id) : "";
return a.query || "";
} catch { return ""; }
}
@@ -1076,13 +1302,25 @@ function tryParseArg(tc: ToolCall | undefined, key: string): string {
}
function toolLabel(tc: ToolCall): string {
const args = toolArgs(tc);
if (tc.function.name === "bash") return trunc(args || "Running command...", 80);
if (tc.function.name === "bash") {
try {
const parsed = JSON.parse(tc.function.arguments);
if (parsed.background) return `Background: ${trunc(args || "Starting process...", 70)}`;
} catch { /* */ }
return trunc(args || "Running command...", 80);
}
if (tc.function.name === "read_file") return `Read ${trunc(args, 60)}`;
if (tc.function.name === "write_file") return `Write ${trunc(args, 60)}`;
if (tc.function.name === "edit_file") return `Edit ${trunc(args, 60)}`;
if (tc.function.name === "search_web") return `Web Search "${trunc(args, 60)}"`;
if (tc.function.name === "search_x") return `X Search "${trunc(args, 60)}"`;
if (tc.function.name === "task") return `Task ${trunc(args, 60)}`;
if (tc.function.name === "delegate") return `Background ${trunc(args, 60)}`;
if (tc.function.name === "delegation_read") return `Read delegation ${trunc(args, 60)}`;
if (tc.function.name === "delegation_list") return "List delegations";
if (tc.function.name === "process_logs") return `Logs for process ${args}`;
if (tc.function.name === "process_stop") return `Stop process ${args}`;
if (tc.function.name === "process_list") return "List processes";
if (tc.function.name === "generate_plan") return "Generating plan...";
return trunc(`${tc.function.name} ${args}`, 80);
}
+1
View File
@@ -48,6 +48,7 @@ export const dark = {
planInputBg: "#1a1a1a",
planInputText: "#e0e0e0",
planHint: "#555555",
queueBg: "#222222",
} as const;
export type Theme = typeof dark;