feat: add sub-agent task delegation and UI (#159)
This commit is contained in:
1 parent
8f8641cfcb
commit
28b4f87980
6 files changed
+388
-66
No files matched your search
+193
-10
@@ -3,7 +3,15 @@ import { createProvider, generateTitle as genTitle, type XaiProvider } from "../
|
||||
import { createTools } from "../grok/tools.js";
|
||||
import { BashTool } from "../tools/bash.js";
|
||||
import { loadCustomInstructions } from "../utils/instructions.js";
|
||||
import type { AgentMode, Plan, StreamChunk, ToolCall, ToolResult } from "../types/index.js";
|
||||
import type {
|
||||
AgentMode,
|
||||
Plan,
|
||||
StreamChunk,
|
||||
SubagentStatus,
|
||||
TaskRequest,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from "../types/index.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 400;
|
||||
|
||||
@@ -26,16 +34,31 @@ TOOLS:
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
WORKFLOW:
|
||||
1. Understand the request
|
||||
2. Use read_file and bash to explore the codebase
|
||||
3. Use edit_file for targeted changes, write_file for new files or full rewrites
|
||||
4. Verify changes by reading modified files
|
||||
5. Run tests or builds with bash to confirm correctness
|
||||
6. Use search_web or search_x when you need up-to-date information
|
||||
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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
EXAMPLES:
|
||||
- "review this change" -> delegate to explore first
|
||||
- "research how auth works" -> delegate to explore first
|
||||
- "investigate why this test fails" -> delegate to explore first, then continue with findings
|
||||
- "refactor this module" -> delegate a focused part to general when helpful
|
||||
|
||||
IMPORTANT:
|
||||
- Prefer edit_file for surgical changes to existing files — it shows a clean diff.
|
||||
@@ -93,6 +116,40 @@ function buildSystemPrompt(cwd: string, mode: AgentMode, planContext?: string |
|
||||
Current working directory: ${cwd}`;
|
||||
}
|
||||
|
||||
function buildSubagentPrompt(request: TaskRequest, cwd: string): string {
|
||||
const mode: AgentMode = request.agent === "explore" ? "ask" : "agent";
|
||||
const role =
|
||||
request.agent === "explore"
|
||||
? "You are the Explore sub-agent. You are read-only and focus on fast codebase research."
|
||||
: "You are the General sub-agent. You can investigate, edit files, and run commands to complete delegated work.";
|
||||
|
||||
const rules =
|
||||
request.agent === "explore"
|
||||
? [
|
||||
"Do not create, modify, or delete files.",
|
||||
"Prefer `read_file` and search commands over broad shell exploration.",
|
||||
"Return concise findings for the parent agent.",
|
||||
]
|
||||
: [
|
||||
"Work only on the delegated task below.",
|
||||
"Use tools directly instead of narrating your intent.",
|
||||
"Return a concise summary for the parent agent with key outcomes and any open risks.",
|
||||
];
|
||||
|
||||
return [
|
||||
role,
|
||||
"",
|
||||
"You are helping a parent agent. Do not address the end user directly.",
|
||||
"Focus tightly on the delegated scope and summarize what matters back to the parent agent.",
|
||||
"",
|
||||
...rules,
|
||||
"",
|
||||
`Delegated task: ${request.description}`,
|
||||
"",
|
||||
buildSystemPrompt(cwd, mode),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
private provider: XaiProvider;
|
||||
private bash: BashTool;
|
||||
@@ -103,6 +160,7 @@ export class Agent {
|
||||
private modelId: string;
|
||||
private maxTokens: number;
|
||||
private planContext: string | null = null;
|
||||
private subagentStatusListeners = new Set<(status: SubagentStatus | null) => void>();
|
||||
|
||||
constructor(apiKey: string, baseURL?: string, model?: string, maxToolRounds?: number) {
|
||||
this.provider = createProvider(apiKey, baseURL);
|
||||
@@ -152,8 +210,96 @@ export class Agent {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
onSubagentStatus(listener: (status: SubagentStatus | null) => void): () => void {
|
||||
this.subagentStatusListeners.add(listener);
|
||||
return () => {
|
||||
this.subagentStatusListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private emitSubagentStatus(status: SubagentStatus | null): void {
|
||||
for (const listener of this.subagentStatusListeners) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTask(request: TaskRequest): Promise<ToolResult> {
|
||||
const childMode: AgentMode = request.agent === "explore" ? "ask" : "agent";
|
||||
const childBash = new BashTool(this.bash.getCwd());
|
||||
const childTools = createTools(childBash, this.provider, childMode);
|
||||
const initialDetail = request.agent === "explore" ? "Scanning the codebase" : "Planning delegated work";
|
||||
let assistantText = "";
|
||||
let lastActivity = initialDetail;
|
||||
|
||||
this.emitSubagentStatus({
|
||||
agent: request.agent,
|
||||
description: request.description,
|
||||
detail: initialDetail,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: this.provider(request.agent === "explore" ? "grok-4-1-fast" : this.modelId),
|
||||
system: buildSubagentPrompt(request, childBash.getCwd()),
|
||||
messages: [{ role: "user", content: request.prompt }],
|
||||
tools: childTools,
|
||||
stopWhen: stepCountIs(Math.min(this.maxToolRounds, request.agent === "explore" ? 60 : 120)),
|
||||
maxRetries: 0,
|
||||
abortSignal: this.abortController?.signal,
|
||||
temperature: request.agent === "explore" ? 0.2 : 0.5,
|
||||
maxOutputTokens: Math.min(this.maxTokens, 8_192),
|
||||
});
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "text-delta") {
|
||||
assistantText += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.type === "tool-call") {
|
||||
lastActivity = formatSubagentActivity(part.toolName, part.input);
|
||||
this.emitSubagentStatus({
|
||||
agent: request.agent,
|
||||
description: request.description,
|
||||
detail: lastActivity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await result.response;
|
||||
|
||||
const output = assistantText.trim() || `Task completed. Last action: ${lastActivity}`;
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
task: {
|
||||
agent: request.agent,
|
||||
description: request.description,
|
||||
summary: firstLine(output),
|
||||
activity: lastActivity,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const output = `Task failed: ${msg}`;
|
||||
return {
|
||||
success: false,
|
||||
output,
|
||||
task: {
|
||||
agent: request.agent,
|
||||
description: request.description,
|
||||
summary: output,
|
||||
activity: lastActivity,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
this.emitSubagentStatus(null);
|
||||
}
|
||||
}
|
||||
|
||||
async *processMessage(userMessage: string): AsyncGenerator<StreamChunk, void, unknown> {
|
||||
this.abortController = new AbortController();
|
||||
this.emitSubagentStatus(null);
|
||||
|
||||
this.messages.push({ role: "user", content: userMessage });
|
||||
|
||||
@@ -161,7 +307,9 @@ export class Agent {
|
||||
let streamOk = false;
|
||||
|
||||
try {
|
||||
const tools = createTools(this.bash, this.provider, this.mode);
|
||||
const tools = createTools(this.bash, this.provider, this.mode, {
|
||||
runTask: (request) => this.runTask(request),
|
||||
});
|
||||
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext);
|
||||
this.planContext = null;
|
||||
|
||||
@@ -246,27 +394,62 @@ export class Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown }): ToolCall {
|
||||
function toToolCall(part: { toolCallId: string; toolName: string; args?: unknown; input?: unknown }): ToolCall {
|
||||
return {
|
||||
id: part.toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.toolName,
|
||||
arguments: JSON.stringify(part.args ?? {}),
|
||||
arguments: JSON.stringify(part.input ?? part.args ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toToolResult(output: unknown): ToolResult {
|
||||
if (output && typeof output === "object" && "success" in output) {
|
||||
const r = output as { success: boolean; output?: string; diff?: ToolResult["diff"]; plan?: Plan };
|
||||
const r = output as {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
diff?: ToolResult["diff"];
|
||||
plan?: Plan;
|
||||
task?: ToolResult["task"];
|
||||
};
|
||||
return {
|
||||
success: r.success,
|
||||
output: r.output,
|
||||
error: r.success ? undefined : r.output,
|
||||
diff: r.diff,
|
||||
plan: r.plan,
|
||||
task: r.task,
|
||||
};
|
||||
}
|
||||
return { success: true, output: String(output) };
|
||||
}
|
||||
|
||||
function formatSubagentActivity(toolName: string, args?: unknown): string {
|
||||
const parsed = parseToolArgs(args);
|
||||
if (toolName === "read_file") return `Read ${parsed.path || "file"}`;
|
||||
if (toolName === "write_file") return `Write ${parsed.path || "file"}`;
|
||||
if (toolName === "edit_file") return `Edit ${parsed.path || "file"}`;
|
||||
if (toolName === "search_web") return `Web search "${truncate(parsed.query || "", 50)}"`;
|
||||
if (toolName === "search_x") return `X search "${truncate(parsed.query || "", 50)}"`;
|
||||
if (toolName === "bash") return truncate(parsed.command || "Run command", 70);
|
||||
return truncate(`${toolName}`, 70);
|
||||
}
|
||||
|
||||
function parseToolArgs(args: unknown): Record<string, string> {
|
||||
if (!args || typeof args !== "object") return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
result[key] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return text.trim().split("\n").find(Boolean)?.trim() || "Task completed.";
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
||||
}
|
||||
+67
-33
@@ -3,11 +3,20 @@ import { z } from "zod";
|
||||
import type { BashTool } from "../tools/bash.js";
|
||||
import { readFile, writeFile, editFile } from "../tools/file.js";
|
||||
import type { XaiProvider } from "./client.js";
|
||||
import type { AgentMode } from "../types/index.js";
|
||||
import type { AgentMode, TaskRequest, ToolResult } from "../types/index.js";
|
||||
|
||||
const SEARCH_MODEL = "grok-3-mini-fast";
|
||||
|
||||
export function createTools(bash: BashTool, provider: XaiProvider, mode: AgentMode = "agent") {
|
||||
interface CreateToolsOptions {
|
||||
runTask?: (request: TaskRequest) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
export function createTools(
|
||||
bash: BashTool,
|
||||
provider: XaiProvider,
|
||||
mode: AgentMode = "agent",
|
||||
options: CreateToolsOptions = {},
|
||||
) {
|
||||
const cwd = () => bash.getCwd();
|
||||
|
||||
const base = {
|
||||
@@ -53,31 +62,6 @@ export function createTools(bash: BashTool, provider: XaiProvider, mode: AgentMo
|
||||
},
|
||||
}),
|
||||
|
||||
write_file: tool({
|
||||
description:
|
||||
"Create or overwrite a file with the given content. Use for creating new files or completely rewriting existing ones. Returns a diff of the changes.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("File path (relative to cwd or absolute)"),
|
||||
content: z.string().describe("The full file content to write"),
|
||||
}),
|
||||
execute: async ({ path, content }) => {
|
||||
return writeFile(path, content, cwd());
|
||||
},
|
||||
}),
|
||||
|
||||
edit_file: tool({
|
||||
description:
|
||||
"Edit a file by replacing a unique string with new content. The old_string must appear exactly once in the file. Include enough surrounding context lines in old_string to make it unique. Returns a diff of the changes.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("File path (relative to cwd or absolute)"),
|
||||
old_string: z.string().describe("The exact text to find (must be unique in the file)"),
|
||||
new_string: z.string().describe("The replacement text"),
|
||||
}),
|
||||
execute: async ({ path, old_string, new_string }) => {
|
||||
return editFile(path, old_string, new_string, cwd());
|
||||
},
|
||||
}),
|
||||
|
||||
search_web: tool({
|
||||
description:
|
||||
"Search the web for current information, documentation, APIs, tutorials, news, or any real-time data. Returns summarized results with sources.",
|
||||
@@ -140,11 +124,60 @@ export function createTools(bash: BashTool, provider: XaiProvider, mode: AgentMo
|
||||
|
||||
};
|
||||
|
||||
if (mode !== "plan") return base;
|
||||
const tools: Record<string, any> = { ...base };
|
||||
|
||||
return {
|
||||
...base,
|
||||
generate_plan: tool({
|
||||
if (mode === "agent") {
|
||||
tools.write_file = tool({
|
||||
description:
|
||||
"Create or overwrite a file with the given content. Use for creating new files or completely rewriting existing ones. Returns a diff of the changes.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("File path (relative to cwd or absolute)"),
|
||||
content: z.string().describe("The full file content to write"),
|
||||
}),
|
||||
execute: async ({ path, content }) => {
|
||||
return writeFile(path, content, cwd());
|
||||
},
|
||||
});
|
||||
|
||||
tools.edit_file = tool({
|
||||
description:
|
||||
"Edit a file by replacing a unique string with new content. The old_string must appear exactly once in the file. Include enough surrounding context lines in old_string to make it unique. Returns a diff of the changes.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("File path (relative to cwd or absolute)"),
|
||||
old_string: z.string().describe("The exact text to find (must be unique in the file)"),
|
||||
new_string: z.string().describe("The replacement text"),
|
||||
}),
|
||||
execute: async ({ path, old_string, new_string }) => {
|
||||
return editFile(path, old_string, new_string, cwd());
|
||||
},
|
||||
});
|
||||
|
||||
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.",
|
||||
inputSchema: z.object({
|
||||
agent: z
|
||||
.enum(["general", "explore"])
|
||||
.default("general")
|
||||
.describe("Which sub-agent to use"),
|
||||
description: z
|
||||
.string()
|
||||
.describe("A short label for the delegated task, such as 'Deep code quality analysis'"),
|
||||
prompt: z
|
||||
.string()
|
||||
.describe("Detailed instructions for the sub-agent to complete"),
|
||||
}),
|
||||
execute: async ({ agent, description, prompt }) => {
|
||||
return options.runTask!({ agent, description, prompt });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== "plan") return tools;
|
||||
|
||||
tools.generate_plan = tool({
|
||||
description:
|
||||
"Generate an interactive implementation plan with steps and optional questions for the user. The plan is displayed in a structured UI where the user can review steps and answer questions. Always use this tool when creating plans.",
|
||||
inputSchema: z.object({
|
||||
@@ -197,6 +230,7 @@ export function createTools(bash: BashTool, provider: XaiProvider, mode: AgentMo
|
||||
plan: { title, summary, steps, questions },
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
+13
-3
@@ -1,19 +1,29 @@
|
||||
import { exec } from "child_process";
|
||||
import { stat } from "fs/promises";
|
||||
import path from "path";
|
||||
import { promisify } from "util";
|
||||
import type { ToolResult } from "../types/index.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class BashTool {
|
||||
private cwd: string = process.cwd();
|
||||
private cwd: string;
|
||||
|
||||
constructor(initialCwd = process.cwd()) {
|
||||
this.cwd = initialCwd;
|
||||
}
|
||||
|
||||
async execute(command: string, timeout = 30_000): Promise<ToolResult> {
|
||||
try {
|
||||
if (command.startsWith("cd ")) {
|
||||
const dir = command.substring(3).trim().replace(/^["']|["']$/g, "");
|
||||
try {
|
||||
process.chdir(dir);
|
||||
this.cwd = process.cwd();
|
||||
const nextCwd = path.resolve(this.cwd, dir);
|
||||
const info = await stat(nextCwd);
|
||||
if (!info.isDirectory()) {
|
||||
return { success: false, error: `Cannot change directory: ${nextCwd} is not a directory` };
|
||||
}
|
||||
this.cwd = nextCwd;
|
||||
return { success: true, output: `Changed directory to: ${this.cwd}` };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -27,12 +27,32 @@ export interface Plan {
|
||||
questions?: PlanQuestion[];
|
||||
}
|
||||
|
||||
export interface TaskRequest {
|
||||
agent: "general" | "explore";
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface TaskRun {
|
||||
agent: "general" | "explore";
|
||||
description: string;
|
||||
summary: string;
|
||||
activity?: string;
|
||||
}
|
||||
|
||||
export interface SubagentStatus {
|
||||
agent: "general" | "explore";
|
||||
description: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
diff?: FileDiff;
|
||||
plan?: Plan;
|
||||
task?: TaskRun;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
||||
+94
-20
@@ -4,7 +4,16 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import type { ScrollBoxRenderable, TextareaRenderable, KeyBinding } from "@opentui/core";
|
||||
import { decodePasteBytes, PasteEvent } from "@opentui/core";
|
||||
import type { Agent } from "../agent/agent.js";
|
||||
import type { ChatEntry, ToolCall, AgentMode, ModelInfo, FileDiff, Plan, PlanQuestion } from "../types/index.js";
|
||||
import type {
|
||||
ChatEntry,
|
||||
ToolCall,
|
||||
AgentMode,
|
||||
ModelInfo,
|
||||
FileDiff,
|
||||
Plan,
|
||||
PlanQuestion,
|
||||
SubagentStatus,
|
||||
} from "../types/index.js";
|
||||
import { MODES } from "../types/index.js";
|
||||
import { getModelInfo, MODELS } from "../grok/models.js";
|
||||
import { saveProjectSettings } from "../utils/settings.js";
|
||||
@@ -13,6 +22,7 @@ import { Markdown } from "./markdown.js";
|
||||
import { PlanView, PlanQuestionsPanel, formatPlanAnswers, initialPlanQuestionsState, type PlanAnswers, type PlanQuestionsState } from "./plan.js";
|
||||
|
||||
const STAR_PALETTE = ["#777777", "#666666", "#4a4a4a", "#333333", "#222222"];
|
||||
const LOADING_SPINNER_FRAMES = ["⬒", "⬔", "⬓", "⬕"];
|
||||
|
||||
type Star = { col: number; ch: string };
|
||||
type Row = { stars: Star[]; grok?: number };
|
||||
@@ -126,6 +136,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
|
||||
const [slashSearchQuery, setSlashSearchQuery] = useState("");
|
||||
const [pasteBlocks, setPasteBlocks] = useState<{ id: number; content: string; lines: number; isImage?: boolean }[]>([]);
|
||||
const [activePlan, setActivePlan] = useState<Plan | null>(null);
|
||||
const [activeSubagent, setActiveSubagent] = useState<SubagentStatus | null>(null);
|
||||
const [pqs, setPqs] = useState<PlanQuestionsState>(initialPlanQuestionsState());
|
||||
const imageCounterRef = useRef(0);
|
||||
const pasteCounterRef = useRef(0);
|
||||
@@ -176,7 +187,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
|
||||
const processMessage = useCallback(async (text: string) => {
|
||||
if (!text.trim() || isProcessingRef.current) return;
|
||||
isProcessingRef.current = true;
|
||||
setIsProcessing(true); setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); contentAccRef.current = "";
|
||||
setIsProcessing(true); setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); setActiveSubagent(null); contentAccRef.current = "";
|
||||
startTimeRef.current = Date.now();
|
||||
if (!sessionTitle) agent.generateTitle(text.trim()).then(setSessionTitle).catch(() => {});
|
||||
setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date(), modeColor: modeInfo.color }]);
|
||||
@@ -229,12 +240,13 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
|
||||
setMessages((p) => [...p, { type: "assistant", content: finalContent, timestamp: new Date(), modeColor: modeInfo.color }]);
|
||||
}
|
||||
|
||||
contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]);
|
||||
contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); setActiveSubagent(null);
|
||||
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]);
|
||||
|
||||
const handleCommand = useCallback((cmd: string): boolean => {
|
||||
const c = cmd.trim().toLowerCase();
|
||||
@@ -545,8 +557,11 @@ export function App({ agent, initialMessage, onExit }: AppProps) {
|
||||
))}
|
||||
{/* Active tool calls — pending inline */}
|
||||
{activeToolCalls.map((tc, i) => (
|
||||
<InlineTool key={i} t={t} pending>{toolLabel(tc)}</InlineTool>
|
||||
tc.function.name === "task"
|
||||
? <SubagentTaskLine key={i} t={t} label={toolArgs(tc) || "Working"} pending />
|
||||
: <InlineTool key={i} t={t} pending>{toolLabel(tc)}</InlineTool>
|
||||
))}
|
||||
{activeSubagent && <SubagentActivity t={t} status={activeSubagent} />}
|
||||
{/* Streaming assistant content */}
|
||||
{streamContent && (
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
@@ -717,6 +732,10 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
|
||||
return <PlanView plan={plan} t={t} />;
|
||||
}
|
||||
|
||||
if (name === "task" && entry.toolResult?.task) {
|
||||
return <TaskResultView t={t} entry={entry} />;
|
||||
}
|
||||
|
||||
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}`;
|
||||
@@ -859,25 +878,13 @@ function DiffView({ t, diff }: { t: Theme; diff: FileDiff }) {
|
||||
}
|
||||
|
||||
function ShimmerText({ t, text }: { t: Theme; text: string }) {
|
||||
const [pos, setPos] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setPos(p => (p + 1) % (text.length + 4)), 60);
|
||||
return () => clearInterval(id);
|
||||
}, [text]);
|
||||
|
||||
const dim = "#333333";
|
||||
const mid = "#555555";
|
||||
const bright = "#888888";
|
||||
|
||||
return (
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: "#555555" }}>{"◇ "}</span>
|
||||
{text.split("").map((ch, i) => {
|
||||
const dist = Math.abs(i - pos);
|
||||
const fg = dist === 0 ? bright : dist === 1 ? mid : dim;
|
||||
return <span key={i} style={{ fg }}>{ch}</span>;
|
||||
})}
|
||||
<span style={{ fg: t.textMuted }}>
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
<span style={{ fg: t.textMuted }}>{" "}{text}</span>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
@@ -893,6 +900,65 @@ function InlineTool({ t, pending, children }: { t: Theme; pending: boolean; chil
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentTaskLine({ t, label, pending }: { t: Theme; label: string; pending: boolean }) {
|
||||
const displayLabel = compactTaskLabel(label);
|
||||
|
||||
return (
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
{pending ? (
|
||||
<span style={{ fg: t.subagentAccent }}>
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
) : null}
|
||||
{pending ? " " : ""}
|
||||
<span style={{ fg: t.subagentAccent }}>
|
||||
<b>{`Sub-agent: ${displayLabel}`}</b>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setFrame((n) => (n + 1) % LOADING_SPINNER_FRAMES.length), 120);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return <>{LOADING_SPINNER_FRAMES[frame]}</>;
|
||||
}
|
||||
|
||||
function SubagentActivity({ t, status }: { t: Theme; status: SubagentStatus }) {
|
||||
return (
|
||||
<box paddingLeft={5}>
|
||||
<text fg={t.textMuted}>
|
||||
{"→ "}{truncateLine(status.detail, 100)}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskResultView({ t, entry }: { t: Theme; entry: ChatEntry }) {
|
||||
const task = entry.toolResult?.task;
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<box gap={0}>
|
||||
<SubagentTaskLine t={t} label={task.description} pending={false} />
|
||||
<box paddingLeft={5}>
|
||||
<text fg={t.text}>
|
||||
{task.agent}
|
||||
{": "}
|
||||
{truncateLine(task.summary, 90)}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Slash Menu ──────────────────────────────────────────────── */
|
||||
|
||||
function SlashMenuModal({ t, selectedIndex, width, height, searchQuery, filteredItems }: {
|
||||
@@ -1000,6 +1066,7 @@ function toolArgs(tc?: ToolCall): string {
|
||||
const a = JSON.parse(tc.function.arguments);
|
||||
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 || "";
|
||||
return a.query || "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
@@ -1015,6 +1082,7 @@ function toolLabel(tc: ToolCall): string {
|
||||
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 === "generate_plan") return "Generating plan...";
|
||||
return trunc(`${tc.function.name} ${args}`, 80);
|
||||
}
|
||||
@@ -1023,7 +1091,13 @@ function sanitizeContent(raw: string): string {
|
||||
s = s.replace(/\{"success"\s*:\s*(true|false)\s*,\s*"output"\s*:\s*"[\s\S]*$/m, "");
|
||||
return s.trim();
|
||||
}
|
||||
function compactTaskLabel(label: string): string {
|
||||
const words = label.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length <= 3) return label.trim() || "Working";
|
||||
return `${words.slice(0, 3).join(" ")}...`;
|
||||
}
|
||||
function trunc(s: string, n: number): string { return s.length <= n ? s : s.slice(0, n) + "…"; }
|
||||
function truncateLine(s: string, n: number): string { return trunc(s.replace(/\s+/g, " ").trim(), n); }
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return "";
|
||||
const s = Math.round(ms / 1000);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const dark = {
|
||||
textDim: "#444444",
|
||||
primary: "#ffffff",
|
||||
accent: "#5c9cf5",
|
||||
subagentAccent: "#66d9c2",
|
||||
selected: "#ffffff",
|
||||
selectedBg: "#2a2a2a",
|
||||
diffAdded: "#1e3a1e",
|
||||
|
||||
Reference in new issue
Block a user