feat: add custom sub-agents with /agents TUI and reliable interrupt (#192)

* feat: add custom sub-agents with /agents TUI and reliable interrupt

- Custom sub-agents stored in ~/.grok/user-settings.json (name, model, instruction)
- Validation against known model IDs, reserved name protection
- Injected into system prompt and available via task tool in all modes
- /agents TUI modal for browsing, adding, editing, and removing sub-agents
- Custom agent name shown in task UI instead of generic "Sub-agent"
- Foreground bash/search tools now accept abort signals for real cancellation
- Esc interrupt aligned with queue-force path for consistent cancel behavior

* fix issues found in code review
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-23 08:50:35 +01:00
1 parent e98e36ddc6
commit fc37434a2a
10 files changed
+946 -110

No files matched your search

+17
View File
@@ -89,6 +89,7 @@ grok fix the flaky test in src/foo.test.ts
| **Grok-native** | Defaults tuned for Grok; models like **`grok-code-fast-1`**, **`grok-4-1-fast`**, flagship and fast variants—run `grok models` for the full menu. |
| **X + web search** | **`search_x`** and **`search_web`** tools—live posts and docs without pretending the internet stopped in 2023. |
| **Sub-agents (default behavior)** | Foreground **`task`** delegation (e.g. explore vs general) plus background **`delegate`** for read-only deep dives—parallelize like you mean it. |
| **Custom sub-agents** | Define named agents with **`subAgents`** in **`~/.grok/user-settings.json`** and manage them from the TUI with **`/agents`**. |
| **Remote control** | Pair **Telegram** from the TUI (`/remote-control` → Telegram): DM your bot, **`/pair`**, approve the code in-terminal. Keep the CLI running while you ping it from your phone. |
| **No “mystery meat” UI** | OpenTUI React terminal UI—fast, keyboard-driven, not whatever glitchy thing you’re thinking of. |
| **Skills** | Agent Skills under **`.agents/skills/<name>/SKILL.md`** (project) or **`~/.agents/skills/`** (user). Use **`/skills`** in the TUI to list what’s installed. |
@@ -129,6 +130,22 @@ grok -k your_key_here
{ "apiKey": "your_key_here" }
```
Optional **`subAgents`** — custom foreground sub-agents. Each entry needs **`name`**, **`model`**, and **`instruction`**:
```json
{
"subAgents": [
{
"name": "security-review",
"model": "grok-code-fast-1",
"instruction": "Prioritize security implications and suggest concrete fixes."
}
]
}
```
Names cannot be `general` or `explore` because those are reserved for the built-in sub-agents.
Optional: **`GROK_BASE_URL`** (default `https://api.x.ai/v1`), **`GROK_MODEL`**, **`GROK_MAX_TOKENS`**.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "grok-dev",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"description": "An open-source AI coding agent powered by Grok, built with Bun and OpenTUI.",
"type": "module",
"main": "dist/index.js",
+91 -29
View File
@@ -31,7 +31,7 @@ import type {
WorkspaceInfo,
} from "../types/index";
import { loadCustomInstructions } from "../utils/instructions";
import { loadMcpServers } from "../utils/settings";
import { type CustomSubagentConfig, loadMcpServers, loadValidSubAgents } from "../utils/settings";
import { discoverSkills, formatSkillsForPrompt } from "../utils/skills";
import {
type CompactionSettings,
@@ -119,7 +119,7 @@ TOOLS:
- 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.
- task: Delegate a focused foreground task to a sub-agent. Use general for multi-step execution, explore for fast read-only research, or a configured custom sub-agent name when listed under CUSTOM SUB-AGENTS.
- 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.
@@ -143,6 +143,7 @@ DEFAULT DELEGATION POLICY:
- 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.
- Use a matching custom sub-agent when the task fits one of the configured specializations.
- 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.
@@ -153,6 +154,7 @@ EXAMPLES:
- "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
- Recurring specialized workflows -> use the matching custom sub-agent via task
IMPORTANT:
- Prefer edit_file for surgical changes to existing files — it shows a clean diff.
@@ -168,6 +170,7 @@ ${ENVIRONMENT}
TOOLS:
- read_file: Read file contents for analysis.
- bash: ONLY for searching (find, grep, ls) — NEVER modify files.
- task: Delegate a focused task to a sub-agent when deeper research or specialized analysis would help.
- generate_plan: ALWAYS use this to present your plan. Creates an interactive UI with steps and questions.
BEHAVIOR:
@@ -186,6 +189,7 @@ ${ENVIRONMENT}
TOOLS:
- read_file: Read file contents for context.
- bash: ONLY for searching (find, grep, ls) — NEVER modify.
- task: Delegate a focused task to a sub-agent when specialized analysis or deeper investigation would help.
BEHAVIOR:
- Answer the user's question directly and thoroughly
@@ -195,7 +199,33 @@ BEHAVIOR:
- Focus on explanation, not execution`,
};
function buildSystemPrompt(cwd: string, mode: AgentMode, planContext?: string | null): string {
function findCustomSubagent(
agent: string,
subagents: CustomSubagentConfig[] = loadValidSubAgents(),
): CustomSubagentConfig | undefined {
return (
subagents.find((item) => item.name === agent) ??
subagents.find((item) => item.name.toLowerCase() === agent.toLowerCase())
);
}
function formatCustomSubagentsPromptSection(subagents: CustomSubagentConfig[]): string {
if (subagents.length === 0) return "";
const lines = subagents.map((agent) => {
const instruction = agent.instruction.trim() || "(none)";
return `### ${agent.name}\n- model: ${agent.model}\n- instruction:\n${instruction}`;
});
return `\n\nCUSTOM SUB-AGENTS:\nUser-defined foreground sub-agents from ~/.grok/user-settings.json. When one matches the task, call the task tool with agent set to the exact name.\n\n${lines.join("\n\n")}\n`;
}
function buildSystemPrompt(
cwd: string,
mode: AgentMode,
planContext?: string | null,
subagents?: CustomSubagentConfig[],
): string {
const custom = loadCustomInstructions(cwd);
const customSection = custom
? `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above alongside standard instructions.\n`
@@ -203,38 +233,48 @@ function buildSystemPrompt(cwd: string, mode: AgentMode, planContext?: string |
const skillsText = formatSkillsForPrompt(discoverSkills(cwd));
const skillsSection = skillsText ? `\n\n${skillsText}\n` : "";
const subagentsSection = formatCustomSubagentsPromptSection(subagents ?? loadValidSubAgents());
const planSection = planContext
? `\n\nAPPROVED PLAN:\nThe following plan has been approved by the user. Execute it now.\n${planContext}\n`
: "";
return `${MODE_PROMPTS[mode]}${customSection}${skillsSection}${planSection}
return `${MODE_PROMPTS[mode]}${customSection}${skillsSection}${subagentsSection}${planSection}
Current working directory: ${cwd}`;
}
function buildSubagentPrompt(request: TaskRequest, cwd: string): string {
const mode: AgentMode = request.agent === "explore" ? "ask" : "agent";
const role =
request.agent === "explore"
function buildSubagentPrompt(
request: TaskRequest,
cwd: string,
custom: CustomSubagentConfig | null,
subagents?: CustomSubagentConfig[],
): string {
const isExplore = request.agent === "explore";
const mode: AgentMode = isExplore ? "ask" : "agent";
const role = custom
? `You are the custom sub-agent "${custom.name}". You can investigate, edit files, and run commands unless the delegated task says otherwise.`
: 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.",
];
const rules = isExplore
? [
"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.",
];
const instructionLines = custom?.instruction.trim() ? ["", "SUB-AGENT INSTRUCTIONS:", custom.instruction.trim()] : [];
return [
role,
...instructionLines,
"",
"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.",
@@ -243,7 +283,7 @@ function buildSubagentPrompt(request: TaskRequest, cwd: string): string {
"",
`Delegated task: ${request.description}`,
"",
buildSystemPrompt(cwd, mode),
buildSystemPrompt(cwd, mode, undefined, subagents),
].join("\n");
}
@@ -486,14 +526,34 @@ export class Agent {
): Promise<ToolResult> {
const provider = this.requireProvider();
const signal = abortSignal;
const childMode: AgentMode = request.agent === "explore" ? "ask" : "agent";
const agentKey = String(request.agent);
const isExplore = agentKey === "explore";
const isGeneral = agentKey === "general";
const subagents = loadValidSubAgents();
const custom = !isExplore && !isGeneral ? findCustomSubagent(agentKey, subagents) : undefined;
if (!isExplore && !isGeneral && !custom) {
const message = `Unknown sub-agent "${agentKey}". Use general, explore, or a configured name from ~/.grok/user-settings.json.`;
return {
success: false,
output: message,
task: {
agent: agentKey,
description: request.description,
summary: message,
},
};
}
const childMode: AgentMode = isExplore ? "ask" : "agent";
const childBash = new BashTool(this.bash.getCwd());
const childBaseTools = createTools(childBash, provider, childMode);
const initialDetail = request.agent === "explore" ? "Scanning the codebase" : "Planning delegated work";
const initialDetail = isExplore ? "Scanning the codebase" : "Planning delegated work";
let assistantText = "";
let lastActivity = initialDetail;
let childTools: ToolSet = childBaseTools;
let closeMcp: (() => Promise<void>) | undefined;
const childModelId = isExplore ? "grok-4-1-fast" : custom ? custom.model : this.modelId;
onActivity?.(initialDetail);
@@ -509,17 +569,17 @@ export class Agent {
}
const result = streamText({
model: provider(request.agent === "explore" ? "grok-4-1-fast" : this.modelId),
system: buildSubagentPrompt(request, childBash.getCwd()),
model: provider(childModelId),
system: buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents),
messages: [{ role: "user", content: request.prompt }],
tools: childTools,
stopWhen: stepCountIs(Math.min(this.maxToolRounds, request.agent === "explore" ? 60 : 120)),
stopWhen: stepCountIs(Math.min(this.maxToolRounds, isExplore ? 60 : 120)),
maxRetries: 0,
abortSignal: signal,
temperature: request.agent === "explore" ? 0.2 : 0.5,
temperature: isExplore ? 0.2 : 0.5,
maxOutputTokens: Math.min(this.maxTokens, 8_192),
onFinish: ({ totalUsage }) => {
this.recordUsage(totalUsage, "task");
this.recordUsage(totalUsage, "task", childModelId);
},
});
@@ -728,7 +788,8 @@ export class Agent {
this.messageSeqs.push(null);
const provider = this.requireProvider();
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext);
const subagents = loadValidSubAgents();
const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents);
const modelInfo = getModelInfo(this.modelId);
this.planContext = null;
let attemptedOverflowRecovery = false;
@@ -761,6 +822,7 @@ export class Agent {
this.runDelegation(request, combineAbortSignals(signal, abortSignal)),
readDelegation: (id) => this.readDelegation(id),
listDelegations: () => this.listDelegations(),
subagents,
});
let tools: ToolSet = baseTools;
if (this.mode === "agent") {
+38 -23
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { BashTool } from "../tools/bash";
import { editFile, readFile, writeFile } from "../tools/file";
import type { AgentMode, TaskRequest, ToolResult } from "../types/index";
import { type CustomSubagentConfig, loadValidSubAgents } from "../utils/settings";
import type { XaiProvider } from "./client";
const RESPONSES_SEARCH_MODEL = "grok-4-fast-non-reasoning";
@@ -12,6 +13,7 @@ interface CreateToolsOptions {
runDelegation?: (request: TaskRequest, abortSignal?: AbortSignal) => Promise<ToolResult>;
readDelegation?: (id: string) => Promise<ToolResult>;
listDelegations?: () => Promise<ToolResult>;
subagents?: CustomSubagentConfig[];
}
export function createTools(
@@ -25,12 +27,14 @@ export function createTools(
const runResponsesSearch = async (
query: string,
toolName: "web_search" | "x_search",
abortSignal?: AbortSignal,
): Promise<{ success: boolean; output: string }> => {
try {
const { text } = await generateText({
model: provider.responses(RESPONSES_SEARCH_MODEL),
maxOutputTokens: 4096,
prompt: query,
abortSignal,
tools: {
...(toolName === "web_search" ? { web_search: provider.tools.webSearch() } : {}),
...(toolName === "x_search" ? { x_search: provider.tools.xSearch() } : {}),
@@ -67,11 +71,11 @@ export function createTools(
"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, background }) => {
execute: async ({ command, timeout, background }, { abortSignal }) => {
if (background) {
return bash.startBackground(command);
}
const result = await bash.execute(command, timeout);
const result = await bash.execute(command, timeout, abortSignal);
return {
success: result.success,
output: result.success
@@ -131,8 +135,8 @@ export function createTools(
inputSchema: z.object({
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
return runResponsesSearch(query, "web_search");
execute: async ({ query }, { abortSignal }) => {
return runResponsesSearch(query, "web_search", abortSignal);
},
}),
@@ -142,14 +146,42 @@ export function createTools(
inputSchema: z.object({
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
return runResponsesSearch(query, "x_search");
execute: async ({ query }, { abortSignal }) => {
return runResponsesSearch(query, "x_search", abortSignal);
},
}),
};
const tools: ToolSet = { ...base };
if (options.runTask) {
const customNames = (options.subagents ?? loadValidSubAgents()).map((agent) => agent.name);
const taskAgentEnum = ["general", "explore", ...customNames] as [string, ...string[]];
const customHint =
customNames.length > 0
? ` You may also use these user-defined sub-agents by exact name: ${customNames.join(", ")}.`
: "";
tools.task = tool({
description: `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.${customHint} Provide a short description plus a detailed prompt for the child agent.`,
inputSchema: z.object({
agent: z
.enum(taskAgentEnum)
.default("general")
.describe(
customNames.length > 0
? "Built-in general or explore, or a configured custom sub-agent name from user settings"
: "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 }, { abortSignal }) => {
return options.runTask!({ agent, description, prompt }, abortSignal);
},
});
}
if (mode === "agent") {
tools.write_file = tool({
description:
@@ -176,23 +208,6 @@ export function createTools(
},
});
if (options.runTask) {
tools.task = tool({
description:
"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"]).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 }, { abortSignal }) => {
return options.runTask!({ agent, description, prompt }, abortSignal);
},
});
}
if (options.runDelegation) {
tools.delegate = tool({
description:
+68 -15
View File
@@ -3,11 +3,8 @@ import { createReadStream, createWriteStream } from "fs";
import { mkdtemp, rm, stat, unlink } from "fs/promises";
import os from "os";
import path from "path";
import { promisify } from "util";
import type { ToolResult } from "../types/index";
const execAsync = promisify(exec);
const MAX_TAIL_BYTES = 8_192;
const MAX_BACKGROUND_PROCESSES = 8;
@@ -41,7 +38,7 @@ export class BashTool {
return this.tmpDir;
}
async execute(command: string, timeout = 30_000): Promise<ToolResult> {
async execute(command: string, timeout = 30_000, abortSignal?: AbortSignal): Promise<ToolResult> {
try {
if (command.startsWith("cd ")) {
const dir = command
@@ -62,18 +59,74 @@ export class BashTool {
}
}
const { stdout, stderr } = await execAsync(command, {
cwd: this.cwd,
timeout,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
});
if (abortSignal?.aborted) {
return { success: false, error: "[Cancelled]" };
}
const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : "");
return {
success: true,
output: output.trim() || "Command executed successfully (no output)",
};
return await new Promise<ToolResult>((resolve) => {
let settled = false;
let aborted = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: ToolResult) => {
if (settled) return;
settled = true;
if (forceKillTimer) clearTimeout(forceKillTimer);
abortSignal?.removeEventListener("abort", onAbort);
resolve(result);
};
const child = exec(
command,
{
cwd: this.cwd,
timeout,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
},
(err, stdout, stderr) => {
if (aborted || abortSignal?.aborted) {
finish({ success: false, error: "[Cancelled]" });
return;
}
const output = stdout + (stderr ? `\nSTDERR: ${stderr}` : "");
if (err) {
if (output.trim()) {
finish({ success: false, error: output.trim() });
return;
}
finish({ success: false, error: `Command failed: ${err.message}` });
return;
}
finish({
success: true,
output: output.trim() || "Command executed successfully (no output)",
});
},
);
const onAbort = () => {
aborted = true;
try {
child.kill("SIGTERM");
} catch {
finish({ success: false, error: "[Cancelled]" });
return;
}
forceKillTimer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* already exited */
}
}, 1_000);
};
abortSignal?.addEventListener("abort", onAbort, { once: true });
});
} catch (err: unknown) {
if (err && typeof err === "object" && "stdout" in err) {
const execErr = err as { stdout?: string; stderr?: string; message: string };
+5 -3
View File
@@ -29,14 +29,16 @@ export interface Plan {
questions?: PlanQuestion[];
}
export type BuiltinSubagentId = "general" | "explore";
export interface TaskRequest {
agent: "general" | "explore";
agent: BuiltinSubagentId | string;
description: string;
prompt: string;
}
export interface TaskRun {
agent: "general" | "explore";
agent: string;
description: string;
summary: string;
activity?: string;
@@ -53,7 +55,7 @@ export interface DelegationRun {
}
export interface SubagentStatus {
agent: "general" | "explore";
agent: string;
description: string;
detail: string;
}
+271
View File
@@ -0,0 +1,271 @@
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
import { type RefObject, useEffect, useRef } from "react";
import { MODELS } from "../grok/models";
import type { CustomSubagentConfig } from "../utils/settings";
import type { Theme } from "./theme";
const EDITOR_KEYBINDINGS = [{ name: "return", action: "submit" as const }];
export type SubagentBrowseRow = { kind: "agent"; agent: CustomSubagentConfig };
export const SUBAGENT_EDITOR_FIELDS = ["name", "model", "instruction"] as const;
export type SubagentEditorField = (typeof SUBAGENT_EDITOR_FIELDS)[number];
export function buildSubagentBrowseRows(agents: CustomSubagentConfig[], query: string): SubagentBrowseRow[] {
const q = query.trim().toLowerCase();
const filtered = q
? agents.filter(
(agent) =>
agent.name.toLowerCase().includes(q) ||
agent.model.toLowerCase().includes(q) ||
agent.instruction.toLowerCase().includes(q),
)
: agents;
return filtered.map((agent) => ({ kind: "agent" as const, agent }));
}
function bottomAlignedModalTop(height: number, panelHeight: number): number {
return Math.max(2, Math.floor((height - panelHeight) / 2));
}
function syncRef(ref: RefObject<TextareaRenderable | null>, value: string): void {
ref.current?.clear();
if (value) {
ref.current?.insertText(value);
}
}
export function SubagentsBrowserModal({
t,
width,
height,
selectedIndex,
searchQuery,
rows,
}: {
t: Theme;
width: number;
height: number;
selectedIndex: number;
searchQuery: string;
rows: SubagentBrowseRow[];
}) {
const listRef = useRef<ScrollBoxRenderable>(null);
useEffect(() => {
const selected = rows[selectedIndex];
if (!selected) return;
listRef.current?.scrollChildIntoView(`subagent-${selected.agent.name}`);
}, [rows, selectedIndex]);
const itemCount = Math.max(rows.length, 1);
const contentHeight = itemCount + 8;
const panelHeight = Math.min(contentHeight, Math.floor(height * 0.6));
const panelWidth = Math.min(60, width - 6);
const overlayBg = "#000000cc" as string;
return (
<box
position="absolute"
left={0}
top={0}
width={width}
height={height}
alignItems="center"
paddingTop={bottomAlignedModalTop(height, panelHeight)}
backgroundColor={overlayBg}
>
<box
width={panelWidth}
height={panelHeight}
backgroundColor={t.backgroundPanel}
paddingTop={1}
paddingBottom={1}
flexDirection="column"
>
<box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text fg={t.primary}>
<b>{"Custom sub-agents"}</b>
</text>
<text fg={t.textMuted}>{"esc"}</text>
</box>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text fg={t.text}>
{searchQuery || <span style={{ fg: t.textMuted }}>{"Search by name, model..."}</span>}
</text>
</box>
<scrollbox ref={listRef} flexGrow={1} minHeight={0}>
{rows.map((row, idx) => {
const selected = idx === selectedIndex;
return (
<box
key={`agent-${row.agent.name}`}
id={`subagent-${row.agent.name}`}
width="100%"
backgroundColor={selected ? t.selectedBg : undefined}
paddingLeft={2}
paddingRight={2}
>
<box width="100%" flexDirection="row" justifyContent="space-between">
<text fg={selected ? t.primary : t.text}>
<b>{row.agent.name}</b>
</text>
<text fg={t.textMuted}>{row.agent.model}</text>
</box>
</box>
);
})}
{rows.length === 0 ? (
<box paddingLeft={2} paddingRight={2}>
<text fg={t.textMuted}>{"No custom sub-agents yet"}</text>
</box>
) : null}
</scrollbox>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={1}>
<text>
<span style={{ fg: t.primary }}>{"enter "}</span>
<span style={{ fg: t.textMuted }}>{"open selected · "}</span>
<span style={{ fg: t.primary }}>{"ctrl+a "}</span>
<span style={{ fg: t.textMuted }}>{"add"}</span>
</text>
</box>
</box>
</box>
);
}
export function SubagentEditorModal({
t,
width,
height,
draft,
focusedField,
modelIndex,
error,
title,
nameRef,
instructionRef,
onSubmit,
showRemoveHint,
}: {
t: Theme;
width: number;
height: number;
draft: { name: string; instruction: string };
focusedField: SubagentEditorField;
modelIndex: number;
error: string | null;
title: string;
nameRef: RefObject<TextareaRenderable | null>;
instructionRef: RefObject<TextareaRenderable | null>;
onSubmit: () => void;
showRemoveHint?: boolean;
}) {
const model = MODELS[modelIndex] ?? MODELS[0];
const panelWidth = Math.min(68, width - 6);
const panelHeight = Math.min(28, Math.floor(height * 0.75));
const overlayBg = "#000000cc" as string;
useEffect(() => {
syncRef(nameRef, draft.name);
syncRef(instructionRef, draft.instruction);
}, [draft, nameRef, instructionRef]);
return (
<box
position="absolute"
left={0}
top={0}
width={width}
height={height}
alignItems="center"
paddingTop={bottomAlignedModalTop(height, panelHeight)}
backgroundColor={overlayBg}
>
<box
width={panelWidth}
height={panelHeight}
backgroundColor={t.backgroundPanel}
paddingTop={1}
paddingBottom={1}
flexDirection="column"
>
<box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text fg={t.primary}>
<b>{title}</b>
</text>
<text fg={t.textMuted}>{"esc back"}</text>
</box>
<scrollbox flexGrow={1} minHeight={0} paddingLeft={2} paddingRight={2} paddingTop={1}>
<box paddingBottom={1}>
<text fg={focusedField === "name" ? t.primary : t.textMuted}>{"Name (task tool agent value)"}</text>
<box backgroundColor={t.backgroundElement} paddingLeft={1} paddingRight={1}>
<textarea
ref={nameRef}
focused={focusedField === "name"}
placeholder="e.g. security-review"
textColor={t.text}
backgroundColor={t.backgroundElement}
placeholderColor={t.textMuted}
minHeight={1}
maxHeight={2}
wrapMode="word"
keyBindings={EDITOR_KEYBINDINGS}
onSubmit={onSubmit as unknown as () => void}
/>
</box>
</box>
<box paddingBottom={1}>
<text fg={focusedField === "model" ? t.primary : t.textMuted}>
{"Model - "}
<span style={{ fg: t.text }}>{`${model.name} (${model.id})`}</span>
</text>
{focusedField === "model" ? <text fg={t.textMuted}>{"up/down or left/right to change model"}</text> : null}
</box>
<box paddingBottom={1}>
<text fg={focusedField === "instruction" ? t.primary : t.textMuted}>{"Instruction (system prompt)"}</text>
<box backgroundColor={t.backgroundElement} paddingLeft={1} paddingRight={1}>
<textarea
ref={instructionRef}
focused={focusedField === "instruction"}
placeholder="How this sub-agent should behave..."
textColor={t.text}
backgroundColor={t.backgroundElement}
placeholderColor={t.textMuted}
minHeight={4}
maxHeight={12}
wrapMode="word"
keyBindings={EDITOR_KEYBINDINGS}
onSubmit={onSubmit as unknown as () => void}
/>
</box>
</box>
{error ? (
<box paddingBottom={1}>
<text fg={t.diffRemovedFg}>{error}</text>
</box>
) : null}
</scrollbox>
<box
flexShrink={0}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
flexDirection="column"
gap={0}
>
{showRemoveHint ? (
<text>
<span style={{ fg: t.primary }}>{"ctrl+x "}</span>
<span style={{ fg: t.textMuted }}>{"remove sub-agent"}</span>
</text>
) : null}
<text fg={t.textMuted}>{"tab fields · enter save"}</text>
</box>
</box>
</box>
);
}
+361 -38
View File
@@ -1,10 +1,10 @@
import type { KeyBinding, KeyEvent, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
import { decodePasteBytes, type PasteEvent } from "@opentui/core";
import { decodePasteBytes, type PasteEvent, parseKeypress } from "@opentui/core";
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
import os from "os";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Agent } from "../agent/agent";
import { getModelInfo, MODELS } from "../grok/models";
import { getModelIds, getModelInfo, MODELS } from "../grok/models";
import { POPULAR_MCP_CATALOG } from "../mcp/catalog";
import { parseEnvLines, parseHeaderLines } from "../mcp/parse-headers";
import { toMcpServerId, validateMcpServerConfig } from "../mcp/validate";
@@ -25,10 +25,13 @@ import type {
import { MODES } from "../types/index";
import { copyTextToHostClipboard } from "../utils/host-clipboard";
import {
type CustomSubagentConfig,
getApiKey,
getTelegramBotToken,
isReservedSubagentName,
loadMcpServers,
loadUserSettings,
loadValidSubAgents,
type McpRemoteTransport,
type McpServerConfig,
saveMcpServers,
@@ -36,6 +39,13 @@ import {
saveUserSettings,
} from "../utils/settings";
import { discoverSkills, formatSkillsForChat } from "../utils/skills";
import {
buildSubagentBrowseRows,
SUBAGENT_EDITOR_FIELDS,
type SubagentEditorField,
SubagentEditorModal,
SubagentsBrowserModal,
} from "./agents-modal";
import { Markdown } from "./markdown";
import { buildMcpBrowseRows, McpBrowserModal, McpEditorModal } from "./mcp-modal";
import { createEmptyMcpEditorDraft, type McpEditorDraft, type McpEditorField } from "./mcp-modal-types";
@@ -240,6 +250,7 @@ const SLASH_MENU_ITEMS: SlashMenuItem[] = [
{ id: "exit", label: "exit", description: "Quit the CLI" },
{ id: "help", label: "help", description: "Show available commands" },
{ id: "remote-control", label: "remote-control", description: "Remote control" },
{ id: "agents", label: "agents", description: "Manage custom sub-agents" },
{ id: "mcp", label: "mcp", description: "Manage MCP servers" },
{ id: "models", label: "models", description: "Select a model" },
{ id: "new", label: "new session", description: "Start a new session" },
@@ -372,6 +383,26 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const mcpArgsRef = useRef<TextareaRenderable>(null);
const mcpCwdRef = useRef<TextareaRenderable>(null);
const mcpEnvRef = useRef<TextareaRenderable>(null);
const [showAgentsModal, setShowAgentsModal] = useState(false);
const [showAgentsEditor, setShowAgentsEditor] = useState(false);
const [subAgents, setSubAgents] = useState<CustomSubagentConfig[]>(() => loadValidSubAgents());
const [agentsSearchQuery, setAgentsSearchQuery] = useState("");
const [agentsModalIndex, setAgentsModalIndex] = useState(0);
const [editingSubagent, setEditingSubagent] = useState<CustomSubagentConfig | null>(null);
const [agentsEditorDraft, setAgentsEditorDraft] = useState({ name: "", instruction: "" });
const [agentsEditorField, setAgentsEditorField] = useState<SubagentEditorField>("name");
const [agentsEditorModelIndex, setAgentsEditorModelIndex] = useState(() =>
Math.max(
0,
MODELS.findIndex((model) => model.id === "grok-4-1-fast"),
),
);
const [agentsEditorSyncKey, setAgentsEditorSyncKey] = useState(0);
const [agentsEditorError, setAgentsEditorError] = useState<string | null>(null);
const showAgentsModalRef = useRef(false);
const showAgentsEditorRef = useRef(false);
const subagentNameRef = useRef<TextareaRenderable>(null);
const subagentInstructionRef = useRef<TextareaRenderable>(null);
const setMode = useCallback(
(m: AgentMode) => {
@@ -419,6 +450,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
: SLASH_MENU_ITEMS;
const mcpRows = buildMcpBrowseRows(mcpServers, POPULAR_MCP_CATALOG, mcpSearchQuery);
const mcpEditorFields = mcpEditorDraft.transport === "stdio" ? MCP_STDIO_FIELDS : MCP_REMOTE_FIELDS;
const agentRows = useMemo(
() => buildSubagentBrowseRows(subAgents, agentsSearchQuery),
[subAgents, agentsSearchQuery],
);
const syncStoredMcpServers = useCallback((servers: McpServerConfig[]) => {
setMcpServers(servers);
@@ -531,6 +566,91 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
[mcpRows.length, mcpServers, syncStoredMcpServers],
);
const openAgentsModal = useCallback(() => {
setSubAgents(loadValidSubAgents());
setAgentsSearchQuery("");
setAgentsModalIndex(0);
setEditingSubagent(null);
setAgentsEditorError(null);
setShowAgentsEditor(false);
setShowAgentsModal(true);
}, []);
const openSubagentEditor = useCallback((agent: CustomSubagentConfig | null) => {
setEditingSubagent(agent);
if (agent) {
setAgentsEditorDraft({ name: agent.name, instruction: agent.instruction });
setAgentsEditorModelIndex(
Math.max(
0,
MODELS.findIndex((model) => model.id === agent.model),
),
);
} else {
setAgentsEditorDraft({ name: "", instruction: "" });
setAgentsEditorModelIndex(
Math.max(
0,
MODELS.findIndex((model) => model.id === "grok-4-1-fast"),
),
);
}
setAgentsEditorField("name");
setAgentsEditorError(null);
setAgentsEditorSyncKey((n) => n + 1);
setShowAgentsEditor(true);
setShowAgentsModal(true);
}, []);
const submitSubagentEditor = useCallback(() => {
const name = (subagentNameRef.current?.plainText || "").trim();
const instruction = subagentInstructionRef.current?.plainText || "";
const model = MODELS[agentsEditorModelIndex]?.id;
if (!name) {
setAgentsEditorError("Name is required.");
return;
}
if (isReservedSubagentName(name)) {
setAgentsEditorError('Names "general" and "explore" are reserved.');
return;
}
if (!model || !getModelIds().includes(model)) {
setAgentsEditorError("Pick a valid model.");
return;
}
const next = [...subAgents];
if (editingSubagent) {
const index = next.findIndex((item) => item.name === editingSubagent.name);
if (index >= 0) next.splice(index, 1);
}
if (next.some((item) => item.name.toLowerCase() === name.toLowerCase())) {
setAgentsEditorError("Another sub-agent already uses this name.");
return;
}
next.push({ name, model, instruction });
saveUserSettings({ subAgents: next });
setSubAgents(loadValidSubAgents());
setShowAgentsEditor(false);
setEditingSubagent(null);
setAgentsEditorError(null);
}, [agentsEditorModelIndex, editingSubagent, subAgents]);
const removeEditingSubagent = useCallback(() => {
if (!editingSubagent) return;
const next = subAgents.filter((item) => item.name !== editingSubagent.name);
saveUserSettings({ subAgents: next });
setSubAgents(loadValidSubAgents());
setShowAgentsEditor(false);
setEditingSubagent(null);
setAgentsEditorError(null);
setAgentsModalIndex(0);
}, [editingSubagent, subAgents]);
const submitMcpEditor = useCallback(() => {
const draft: McpEditorDraft = {
label: mcpLabelRef.current?.plainText || "",
@@ -940,6 +1060,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
useEffect(() => {
showMcpEditorRef.current = showMcpEditor;
}, [showMcpEditor]);
useEffect(() => {
showAgentsModalRef.current = showAgentsModal;
}, [showAgentsModal]);
useEffect(() => {
showAgentsEditorRef.current = showAgentsEditor;
}, [showAgentsEditor]);
useEffect(() => {
return () => {
@@ -1048,13 +1174,65 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
}, [openApiKeyModal, startTelegramBridge]);
const invalidateActiveRun = useCallback(() => {
activeRunIdRef.current += 1;
setActiveToolCalls([]);
setActiveSubagent(null);
setStreamContent("");
setStreamReasoning("");
}, []);
const interruptActiveRun = useCallback(
(key?: KeyEvent) => {
if (!isProcessingRef.current) return false;
key?.preventDefault();
key?.stopPropagation();
interruptedRunIdRef.current = activeRunIdRef.current;
queuedMessagesRef.current = [];
setQueuedMessages([]);
setStreamContent("");
setStreamReasoning("");
setActiveToolCalls([]);
setActiveSubagent(null);
agent.abort();
return true;
},
[agent],
);
useEffect(() => {
const onInternalKey = (key: KeyEvent) => {
if (isEscapeKey(key)) {
interruptActiveRun(key);
}
};
renderer._internalKeyInput.onInternal("keypress", onInternalKey);
return () => {
renderer._internalKeyInput.offInternal("keypress", onInternalKey);
};
}, [interruptActiveRun, renderer]);
useEffect(() => {
const onRawInput = (sequence: string) => {
const parsed = parseKeypress(sequence, { useKittyKeyboard: renderer.useKittyKeyboard });
if (parsed?.name === "escape" || sequence === "\u001b" || sequence === "\u001b\u001b") {
return interruptActiveRun();
}
return false;
};
renderer.prependInputHandler(onRawInput);
return () => {
renderer.removeInputHandler(onRawInput);
};
}, [interruptActiveRun, renderer]);
useEffect(() => {
const onStdinData = (chunk: Buffer | string) => {
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (data.length === 1 && data[0] === 27) {
interruptActiveRun();
}
};
renderer.stdin.on("data", onStdinData);
return () => {
renderer.stdin.off("data", onStdinData);
};
}, [interruptActiveRun, renderer]);
const resetToNewSession = useCallback(() => {
const snapshot = agent.startNewSession();
@@ -1271,6 +1449,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
openMcpModal();
return true;
}
if (c === "/agents" || c === "/agent") {
openAgentsModal();
return true;
}
if (c === "/quit" || c === "/exit" || c === "/q") {
handleExit();
return true;
@@ -1281,7 +1463,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
return false;
},
[handleExit, openMcpModal, processMessage, resetToNewSession],
[handleExit, openAgentsModal, openMcpModal, processMessage, resetToNewSession],
);
const handleSlashMenuSelect = useCallback(
@@ -1327,15 +1509,24 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
case "mcp":
openMcpModal();
break;
case "agents":
openAgentsModal();
break;
case "review":
processMessage(REVIEW_PROMPT);
break;
}
},
[agent, handleExit, openMcpModal, processMessage, resetToNewSession],
[agent, handleExit, openAgentsModal, openMcpModal, processMessage, resetToNewSession],
);
const blockPrompt = showConnectModal || showTelegramTokenModal || showTelegramPairModal || showMcpModal;
const blockPrompt =
showConnectModal ||
showTelegramTokenModal ||
showTelegramPairModal ||
showMcpModal ||
showAgentsModal ||
showAgentsEditor;
const showPlanPanel = !!activePlan?.questions?.length;
const planQuestions = activePlan?.questions ?? [];
@@ -1404,7 +1595,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const q = planQuestions[pqs.tab];
// Escape always dismisses
if (key.name === "escape") {
if (isEscapeKey(key)) {
dismissPlan();
return;
}
@@ -1526,7 +1717,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showMcpEditorRef.current) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowMcpEditor(false);
setMcpEditorError(null);
setMcpSearchQuery("");
@@ -1550,9 +1741,49 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
}
if (showAgentsEditorRef.current) {
if (isEscapeKey(key)) {
setShowAgentsEditor(false);
setAgentsEditorError(null);
return;
}
if (key.name === "x" && key.ctrl && editingSubagent) {
removeEditingSubagent();
return;
}
if (key.name === "return") {
submitSubagentEditor();
return;
}
if (
agentsEditorField === "model" &&
(key.name === "up" ||
key.name === "down" ||
key.name === "left" ||
key.name === "right" ||
key.name === "j" ||
key.name === "k")
) {
const decrement = key.name === "up" || key.name === "left" || key.name === "k";
setAgentsEditorModelIndex((index) =>
decrement ? Math.max(0, index - 1) : Math.min(MODELS.length - 1, index + 1),
);
return;
}
if (key.name === "tab") {
const index = SUBAGENT_EDITOR_FIELDS.indexOf(agentsEditorField);
const nextIndex =
(index + (key.shift ? -1 : 1) + SUBAGENT_EDITOR_FIELDS.length) % SUBAGENT_EDITOR_FIELDS.length;
setAgentsEditorField(SUBAGENT_EDITOR_FIELDS[nextIndex]);
return;
}
if (agentsEditorField === "model") {
return;
}
}
if (showMcpModalRef.current) {
const row = mcpRows[mcpModalIndex];
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowMcpEditor(false);
setShowMcpModal(false);
setMcpSearchQuery("");
@@ -1602,8 +1833,48 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
return;
}
if (showAgentsModalRef.current && !showAgentsEditorRef.current) {
const row = agentRows[agentsModalIndex];
if (isEscapeKey(key)) {
setShowAgentsModal(false);
setShowAgentsEditor(false);
setAgentsSearchQuery("");
setEditingSubagent(null);
setAgentsEditorError(null);
return;
}
if (key.name === "up") {
setAgentsModalIndex((index) => Math.max(0, index - 1));
return;
}
if (key.name === "down") {
setAgentsModalIndex((index) => Math.min(Math.max(0, agentRows.length - 1), index + 1));
return;
}
if (key.name === "return") {
if (row?.kind === "agent") {
openSubagentEditor(row.agent);
}
return;
}
if (key.name === "a" && key.ctrl) {
openSubagentEditor(null);
return;
}
if (key.name === "backspace") {
setAgentsSearchQuery((query) => query.slice(0, -1));
setAgentsModalIndex(0);
return;
}
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
setAgentsSearchQuery((query) => query + key.sequence);
setAgentsModalIndex(0);
return;
}
return;
}
if (showTelegramTokenModalRef.current) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowTelegramTokenModal(false);
setTelegramTokenError(null);
return;
@@ -1614,7 +1885,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showTelegramPairModalRef.current) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowTelegramPairModal(false);
setTelegramPairError(null);
return;
@@ -1625,7 +1896,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showConnectModalRef.current) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowConnectModal(false);
return;
}
@@ -1645,7 +1916,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showApiKeyModalRef.current) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
closeApiKeyModal();
return;
}
@@ -1655,7 +1926,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showSlashMenu) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowSlashMenu(false);
setSlashSearchQuery("");
inputRef.current?.clear();
@@ -1688,7 +1959,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showModelPicker) {
if (key.name === "escape") {
if (isEscapeKey(key)) {
setShowModelPicker(false);
setModelSearchQuery("");
return;
@@ -1725,18 +1996,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
}
return;
}
if (!hasApiKeyRef.current && shouldOpenApiKeyModalForKey(key)) {
openApiKeyModal();
if (isEscapeKey(key) && interruptActiveRun(key)) {
return;
}
if (isProcessing && key.name === "escape") {
invalidateActiveRun();
if (queuedMessagesRef.current.length > 0) {
queuedMessagesRef.current = [];
setQueuedMessages([]);
} else {
agent.abort();
}
if (!hasApiKeyRef.current && shouldOpenApiKeyModalForKey(key)) {
openApiKeyModal();
return;
}
if (key.sequence === "/" && !isProcessing) {
@@ -1791,6 +2057,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
},
[
agent,
agentRows,
agentsEditorField,
agentsModalIndex,
beginTelegramFromConnect,
closeApiKeyModal,
connectModalIndex,
@@ -1798,13 +2067,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
cycleMcpEditorTransport,
deleteSavedMcp,
dismissPlan,
editingSubagent,
editSavedMcp,
filteredModelIds,
filteredSlashItems,
handleExit,
handlePlanSelect,
handleSlashMenuSelect,
invalidateActiveRun,
interruptActiveRun,
isPlanConfirmTab,
isProcessing,
isSinglePlan,
@@ -1816,12 +2086,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
openApiKeyModal,
openCatalogMcp,
openMcpEditor,
openSubagentEditor,
submitTelegramPair,
submitTelegramToken,
submitMcpEditor,
submitSubagentEditor,
planQuestions,
planTabCount,
pqs,
removeEditingSubagent,
showModelPicker,
showPlanPanel,
showSlashMenu,
@@ -1930,7 +2203,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
{/* Active tool calls — pending inline */}
{activeToolCalls.map((tc) =>
tc.function.name === "task" ? (
<SubagentTaskLine key={tc.id} t={t} label={toolArgs(tc) || "Working"} pending />
<SubagentTaskLine
key={tc.id}
t={t}
agent={tryParseArg(tc, "agent") || "sub-agent"}
label={toolArgs(tc) || "Working"}
pending
/>
) : tc.function.name === "delegate" ? (
<DelegationTaskLine
key={tc.id}
@@ -2072,6 +2351,33 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
onSubmit={submitMcpEditor}
/>
)}
{showAgentsModal && !showAgentsEditor && (
<SubagentsBrowserModal
t={t}
width={width}
height={height}
selectedIndex={agentsModalIndex}
searchQuery={agentsSearchQuery}
rows={agentRows}
/>
)}
{showAgentsEditor && (
<SubagentEditorModal
key={`subagent-editor-${agentsEditorSyncKey}`}
t={t}
width={width}
height={height}
draft={agentsEditorDraft}
focusedField={agentsEditorField}
modelIndex={agentsEditorModelIndex}
error={agentsEditorError}
title={editingSubagent ? `Edit sub-agent: ${editingSubagent.name}` : "Add sub-agent"}
nameRef={subagentNameRef}
instructionRef={subagentInstructionRef}
onSubmit={submitSubagentEditor}
showRemoveHint={!!editingSubagent}
/>
)}
{showModelPicker && (
<ModelPickerModal
t={t}
@@ -2340,7 +2646,7 @@ function PromptModeLabel({
return <PromptLoadingBoxes t={t} color={modeInfo.color} />;
}
function PromptLoadingBoxes({ t, color }: { t: Theme; color: string }) {
function PromptLoadingBoxes({ t: _t, color }: { t: Theme; color: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
@@ -2786,8 +3092,15 @@ function InlineTool({ t, pending: _pending, children }: { t: Theme; pending: boo
);
}
function SubagentTaskLine({ t, label, pending }: { t: Theme; label: string; pending: boolean }) {
function formatSubagentName(agent: string): string {
if (agent === "general") return "General";
if (agent === "explore") return "Explore";
return agent || "Sub-agent";
}
function SubagentTaskLine({ t, agent, label, pending }: { t: Theme; agent: string; label: string; pending: boolean }) {
const displayLabel = compactTaskLabel(label);
const displayAgent = formatSubagentName(agent);
return (
<box paddingLeft={3}>
@@ -2799,7 +3112,7 @@ function SubagentTaskLine({ t, label, pending }: { t: Theme; label: string; pend
) : null}
{pending ? " " : ""}
<span style={{ fg: t.subagentAccent }}>
<b>{`Sub-agent: ${displayLabel}`}</b>
<b>{`${displayAgent}: ${displayLabel}`}</b>
</span>
</text>
</box>
@@ -2860,7 +3173,7 @@ function TaskResultView({ t, entry }: { t: Theme; entry: ChatEntry }) {
return (
<box gap={0}>
<SubagentTaskLine t={t} label={task.description} pending={false} />
<SubagentTaskLine t={t} agent={task.agent} label={task.description} pending={false} />
<box paddingLeft={5}>
<text fg={t.text}>
{task.agent}
@@ -3417,6 +3730,16 @@ function ModelPickerModal({
/* ── Helpers ──────────────────────────────────────────────────── */
function isEscapeKey(key: KeyEvent): boolean {
return (
key.name === "escape" ||
key.code === "Escape" ||
key.baseCode === 27 ||
key.sequence === "\u001b" ||
key.raw === "\u001b"
);
}
function toolArgs(tc?: ToolCall): string {
if (!tc) return "";
try {
+48 -1
View File
@@ -1,7 +1,7 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { DEFAULT_MODEL } from "../grok/models";
import { DEFAULT_MODEL, getModelIds } from "../grok/models";
export type TelegramStreamingMode = "off" | "partial";
@@ -36,11 +36,57 @@ export interface McpSettings {
servers?: McpServerConfig[];
}
export interface CustomSubagentConfig {
name: string;
model: string;
instruction: string;
}
const RESERVED_SUBAGENT_NAMES = new Set(["general", "explore"]);
export function isReservedSubagentName(name: string): boolean {
return RESERVED_SUBAGENT_NAMES.has(name.trim().toLowerCase());
}
export function parseSubAgentsRawList(raw: unknown): CustomSubagentConfig[] {
if (!Array.isArray(raw)) return [];
const validModels = new Set(getModelIds());
const seen = new Set<string>();
const agents: CustomSubagentConfig[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const entry = item as Record<string, unknown>;
const name = typeof entry.name === "string" ? entry.name.trim() : "";
const model = typeof entry.model === "string" ? entry.model.trim() : "";
const instruction = typeof entry.instruction === "string" ? entry.instruction : "";
if (!name || isReservedSubagentName(name) || !validModels.has(model)) {
continue;
}
const dedupeKey = name.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
agents.push({ name, model, instruction });
}
return agents;
}
export function loadValidSubAgents(): CustomSubagentConfig[] {
return parseSubAgentsRawList(loadUserSettings().subAgents);
}
export interface UserSettings {
apiKey?: string;
defaultModel?: string;
telegram?: TelegramSettings;
mcp?: McpSettings;
subAgents?: CustomSubagentConfig[];
}
export interface ProjectSettings {
@@ -102,6 +148,7 @@ export function saveUserSettings(partial: Partial<UserSettings>): void {
},
}
: {}),
...(partial.subAgents !== undefined ? { subAgents: partial.subAgents } : {}),
};
writeJson(USER_SETTINGS_PATH, next);
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseSubAgentsRawList } from "./settings";
describe("parseSubAgentsRawList", () => {
it("returns empty for non-array or missing", () => {
expect(parseSubAgentsRawList(undefined)).toEqual([]);
expect(parseSubAgentsRawList(null)).toEqual([]);
expect(parseSubAgentsRawList({})).toEqual([]);
});
it("keeps valid entries with known model ids", () => {
expect(
parseSubAgentsRawList([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]),
).toEqual([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]);
});
it("skips unknown models", () => {
expect(parseSubAgentsRawList([{ name: "bad", model: "not-a-real-model", instruction: "x" }])).toEqual([]);
});
it("skips reserved and empty names", () => {
expect(
parseSubAgentsRawList([
{ name: "general", model: "grok-4-1-fast", instruction: "x" },
{ name: "Explore", model: "grok-4-1-fast", instruction: "x" },
{ name: "", model: "grok-4-1-fast", instruction: "x" },
{ name: " ", model: "grok-4-1-fast", instruction: "x" },
]),
).toEqual([]);
});
it("dedupes by case-insensitive name with first entry winning", () => {
expect(
parseSubAgentsRawList([
{ name: "Docs", model: "grok-4-1-fast", instruction: "first" },
{ name: "docs", model: "grok-code-fast-1", instruction: "second" },
]),
).toEqual([{ name: "Docs", model: "grok-4-1-fast", instruction: "first" }]);
});
it("ignores non-object rows", () => {
expect(parseSubAgentsRawList([null, "x", { name: "ok", model: "grok-3-mini", instruction: "" }])).toEqual([
{ name: "ok", model: "grok-3-mini", instruction: "" },
]);
});
});