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:
8 files changed
+1133
-35
No files matched your search
+117
-17
@@ -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) };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const dark = {
|
||||
planInputBg: "#1a1a1a",
|
||||
planInputText: "#e0e0e0",
|
||||
planHint: "#555555",
|
||||
queueBg: "#222222",
|
||||
} as const;
|
||||
|
||||
export type Theme = typeof dark;
|
||||
Reference in new issue
Block a user