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:
1 parent
e98e36ddc6
commit
fc37434a2a
10 files changed
+946
-110
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user