feat(tui): add agent picker and agents list rpc
This commit is contained in:
@@ -39,6 +39,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
- Ctrl+C: clear input (press twice to exit)
|
||||
- Ctrl+D: exit
|
||||
- Ctrl+L: model picker
|
||||
- Ctrl+G: agent picker
|
||||
- Ctrl+P: session picker
|
||||
- Ctrl+O: toggle tool output expansion
|
||||
- Ctrl+T: toggle thinking visibility
|
||||
@@ -46,6 +47,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
## Slash commands
|
||||
- `/help`
|
||||
- `/status`
|
||||
- `/agent <id>` (or `/agents`)
|
||||
- `/session <key>` (or `/sessions`)
|
||||
- `/model <provider/model>` (or `/model list`, `/models`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
type AgentEvent,
|
||||
AgentEventSchema,
|
||||
AgentParamsSchema,
|
||||
type AgentSummary,
|
||||
AgentSummarySchema,
|
||||
type AgentsListParams,
|
||||
AgentsListParamsSchema,
|
||||
type AgentsListResult,
|
||||
AgentsListResultSchema,
|
||||
type AgentWaitParams,
|
||||
AgentWaitParamsSchema,
|
||||
type ChatAbortParams,
|
||||
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
|
||||
AgentWaitParamsSchema,
|
||||
);
|
||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
|
||||
AgentsListParamsSchema,
|
||||
);
|
||||
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
|
||||
NodePairRequestParamsSchema,
|
||||
);
|
||||
@@ -332,6 +341,9 @@ export {
|
||||
ProvidersStatusParamsSchema,
|
||||
WebLoginStartParamsSchema,
|
||||
WebLoginWaitParamsSchema,
|
||||
AgentSummarySchema,
|
||||
AgentsListParamsSchema,
|
||||
AgentsListResultSchema,
|
||||
ModelsListParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
@@ -394,6 +406,9 @@ export type {
|
||||
ProvidersStatusParams,
|
||||
WebLoginStartParams,
|
||||
WebLoginWaitParams,
|
||||
AgentSummary,
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
SkillsStatusParams,
|
||||
SkillsInstallParams,
|
||||
SkillsUpdateParams,
|
||||
|
||||
@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentSummarySchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
name: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentsListParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const AgentsListResultSchema = Type.Object(
|
||||
{
|
||||
defaultId: NonEmptyString,
|
||||
mainKey: NonEmptyString,
|
||||
scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]),
|
||||
agents: Type.Array(AgentSummarySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ModelsListParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||
AgentSummary: AgentSummarySchema,
|
||||
AgentsListParams: AgentsListParamsSchema,
|
||||
AgentsListResult: AgentsListResultSchema,
|
||||
ModelChoice: ModelChoiceSchema,
|
||||
ModelsListParams: ModelsListParamsSchema,
|
||||
ModelsListResult: ModelsListResultSchema,
|
||||
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
|
||||
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
||||
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
|
||||
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
|
||||
export type AgentSummary = Static<typeof AgentSummarySchema>;
|
||||
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
|
||||
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
|
||||
export type ModelChoice = Static<typeof ModelChoiceSchema>;
|
||||
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
|
||||
export type ModelsListResult = Static<typeof ModelsListResultSchema>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ErrorCodes, errorShape } from "./protocol/index.js";
|
||||
import { agentHandlers } from "./server-methods/agent.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { configHandlers } from "./server-methods/config.js";
|
||||
import { connectHandlers } from "./server-methods/connect.js";
|
||||
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
|
||||
...sendHandlers,
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
...agentsHandlers,
|
||||
};
|
||||
|
||||
export async function handleGatewayRequest(
|
||||
|
||||
29
src/gateway/server-methods/agents.ts
Normal file
29
src/gateway/server-methods/agents.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateAgentsListParams,
|
||||
} from "../protocol/index.js";
|
||||
import { listAgentsForGateway } from "../session-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const agentsHandlers: GatewayRequestHandlers = {
|
||||
"agents.list": ({ params, respond }) => {
|
||||
if (!validateAgentsListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const result = listAgentsForGateway(cfg);
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
};
|
||||
49
src/gateway/server.agents.test.ts
Normal file
49
src/gateway/server.agents.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server agents", () => {
|
||||
test("lists configured agents via agents.list RPC", async () => {
|
||||
testState.routingConfig = {
|
||||
defaultAgentId: "work",
|
||||
agents: {
|
||||
work: { name: "Work" },
|
||||
home: { name: "Home" },
|
||||
},
|
||||
};
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
const hello = await connectOk(ws);
|
||||
expect(
|
||||
(hello as unknown as { features?: { methods?: string[] } }).features
|
||||
?.methods,
|
||||
).toEqual(expect.arrayContaining(["agents.list"]));
|
||||
|
||||
const res = await rpcReq<{
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: string;
|
||||
agents: Array<{ id: string; name?: string }>;
|
||||
}>(ws, "agents.list", {});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.defaultId).toBe("work");
|
||||
expect(res.payload?.mainKey).toBe("main");
|
||||
expect(res.payload?.scope).toBe("per-sender");
|
||||
expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
|
||||
"work",
|
||||
"home",
|
||||
]);
|
||||
const work = res.payload?.agents.find((agent) => agent.id === "work");
|
||||
const home = res.payload?.agents.find((agent) => agent.id === "home");
|
||||
expect(work?.name).toBe("Work");
|
||||
expect(home?.name).toBe("Home");
|
||||
});
|
||||
});
|
||||
@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("filters sessions by agentId", async () => {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-sessions-agents-"),
|
||||
);
|
||||
testState.sessionConfig = {
|
||||
store: path.join(dir, "{agentId}", "sessions.json"),
|
||||
};
|
||||
testState.routingConfig = {
|
||||
defaultAgentId: "home",
|
||||
agents: {
|
||||
home: {},
|
||||
work: {},
|
||||
},
|
||||
};
|
||||
const homeDir = path.join(dir, "home");
|
||||
const workDir = path.join(dir, "work");
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(homeDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:home:main": {
|
||||
sessionId: "sess-home-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
"agent:home:discord:group:dev": {
|
||||
sessionId: "sess-home-group",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:work:main": {
|
||||
sessionId: "sess-work-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const homeSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "home",
|
||||
});
|
||||
expect(homeSessions.ok).toBe(true);
|
||||
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
|
||||
"agent:home:discord:group:dev",
|
||||
"agent:home:main",
|
||||
]);
|
||||
|
||||
const workSessions = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: "work",
|
||||
});
|
||||
expect(workSessions.ok).toBe(true);
|
||||
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual([
|
||||
"agent:work:main",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,6 +227,7 @@ const METHODS = [
|
||||
"wizard.status",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
resolveSessionTranscriptPath,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
} from "../config/sessions.js";
|
||||
import {
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
|
||||
lastAccountId?: string;
|
||||
};
|
||||
|
||||
export type GatewayAgentRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
ts: number;
|
||||
path: string;
|
||||
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function listAgentsForGateway(cfg: ClawdbotConfig): {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: SessionScope;
|
||||
agents: GatewayAgentRow[];
|
||||
} {
|
||||
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
|
||||
const mainKey =
|
||||
(cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
const scope = cfg.session?.scope ?? "per-sender";
|
||||
const configured = cfg.routing?.agents;
|
||||
const configuredById = new Map<string, { name?: string }>();
|
||||
if (configured && typeof configured === "object") {
|
||||
for (const [key, value] of Object.entries(configured)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
configuredById.set(normalizeAgentId(key), {
|
||||
name:
|
||||
typeof value.name === "string" && value.name.trim()
|
||||
? value.name.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
const agents = listConfiguredAgentIds(cfg).map((id) => {
|
||||
const meta = configuredById.get(id);
|
||||
return {
|
||||
id,
|
||||
name: meta?.name,
|
||||
};
|
||||
});
|
||||
return { defaultId, mainKey, scope, agents };
|
||||
}
|
||||
|
||||
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
||||
if (key === "global" || key === "unknown") return key;
|
||||
if (key.startsWith("agent:")) return key;
|
||||
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const agentId =
|
||||
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
const activeMinutes =
|
||||
typeof opts.activeMinutes === "number" &&
|
||||
Number.isFinite(opts.activeMinutes)
|
||||
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
|
||||
.filter(([key]) => {
|
||||
if (!includeGlobal && key === "global") return false;
|
||||
if (!includeUnknown && key === "unknown") return false;
|
||||
if (agentId) {
|
||||
if (key === "global" || key === "unknown") return false;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
if (!parsed) return false;
|
||||
return normalizeAgentId(parsed.agentId) === agentId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(([key, entry]) => {
|
||||
|
||||
@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
|
||||
|
||||
export const testState = {
|
||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||
routingConfig: undefined as Record<string, unknown> | undefined,
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
...testState.agentConfig,
|
||||
},
|
||||
routing: testState.routingConfig,
|
||||
whatsapp: {
|
||||
allowFrom: testState.allowFrom,
|
||||
},
|
||||
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
|
||||
testState.sessionConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.agentConfig = undefined;
|
||||
testState.routingConfig = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
|
||||
@@ -31,6 +31,8 @@ export function getSlashCommands(): SlashCommand[] {
|
||||
return [
|
||||
{ name: "help", description: "Show slash command help" },
|
||||
{ name: "status", description: "Show gateway status summary" },
|
||||
{ name: "agent", description: "Switch agent (or open picker)" },
|
||||
{ name: "agents", description: "Open agent picker" },
|
||||
{ name: "session", description: "Switch session (or open picker)" },
|
||||
{ name: "sessions", description: "Open session picker" },
|
||||
{
|
||||
@@ -108,6 +110,7 @@ export function helpText(): string {
|
||||
"Slash commands:",
|
||||
"/help",
|
||||
"/status",
|
||||
"/agent <id> (or /agents)",
|
||||
"/session <key> (or /sessions)",
|
||||
"/model <provider/model> (or /models)",
|
||||
"/think <off|minimal|low|medium|high>",
|
||||
|
||||
@@ -4,6 +4,7 @@ export class CustomEditor extends Editor {
|
||||
onEscape?: () => void;
|
||||
onCtrlC?: () => void;
|
||||
onCtrlD?: () => void;
|
||||
onCtrlG?: () => void;
|
||||
onCtrlL?: () => void;
|
||||
onCtrlO?: () => void;
|
||||
onCtrlP?: () => void;
|
||||
@@ -28,6 +29,10 @@ export class CustomEditor extends Editor {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl("g")) && this.onCtrlG) {
|
||||
this.onCtrlG();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
|
||||
this.onCtrlT();
|
||||
return;
|
||||
|
||||
@@ -57,6 +57,16 @@ export type GatewaySessionList = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayAgentsList = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: "per-sender" | "global";
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayModelChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -165,9 +175,14 @@ export class GatewayChatClient {
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
includeGlobal: opts?.includeGlobal,
|
||||
includeUnknown: opts?.includeUnknown,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
async listAgents() {
|
||||
return await this.client.request<GatewayAgentsList>("agents.list", {});
|
||||
}
|
||||
|
||||
async patchSession(opts: SessionsPatchParams) {
|
||||
return await this.client.request("sessions.patch", opts);
|
||||
}
|
||||
|
||||
209
src/tui/tui.ts
209
src/tui/tui.ts
@@ -7,6 +7,11 @@ import {
|
||||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
|
||||
import { ChatLog } from "./components/chat-log.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
@@ -14,7 +19,7 @@ import {
|
||||
createSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { type GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
|
||||
import { editorTheme, theme } from "./theme/theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
@@ -53,6 +58,13 @@ type SessionInfo = {
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type SessionScope = "per-sender" | "global";
|
||||
|
||||
type AgentSummary = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function extractTextBlocks(
|
||||
content: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
@@ -106,9 +118,18 @@ function asString(value: unknown, fallback = ""): string {
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSessionKey = defaultSession;
|
||||
const initialSessionInput = (opts.session ?? "").trim();
|
||||
let sessionScope: SessionScope = (config.session?.scope ??
|
||||
"per-sender") as SessionScope;
|
||||
let sessionMainKey = (config.session?.mainKey ?? "main").trim() || "main";
|
||||
let agentDefaultId = normalizeAgentId(
|
||||
config.routing?.defaultAgentId ?? "main",
|
||||
);
|
||||
let currentAgentId = agentDefaultId;
|
||||
let agents: AgentSummary[] = [];
|
||||
const agentNames = new Map<string, string>();
|
||||
let currentSessionKey = "";
|
||||
let initialSessionApplied = false;
|
||||
let currentSessionId: string | null = null;
|
||||
let activeChatRunId: string | null = null;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
@@ -144,10 +165,39 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.addChild(root);
|
||||
tui.setFocus(editor);
|
||||
|
||||
const formatSessionKey = (key: string) => {
|
||||
if (key === "global" || key === "unknown") return key;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return parsed?.rest ?? key;
|
||||
};
|
||||
|
||||
const formatAgentLabel = (id: string) => {
|
||||
const name = agentNames.get(id);
|
||||
return name ? `${id} (${name})` : id;
|
||||
};
|
||||
|
||||
const resolveSessionKey = (raw?: string) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (sessionScope === "global") return "global";
|
||||
if (!trimmed) {
|
||||
return buildAgentMainSessionKey({
|
||||
agentId: currentAgentId,
|
||||
mainKey: sessionMainKey,
|
||||
});
|
||||
}
|
||||
if (trimmed === "global" || trimmed === "unknown") return trimmed;
|
||||
if (trimmed.startsWith("agent:")) return trimmed;
|
||||
return `agent:${currentAgentId}:${trimmed}`;
|
||||
};
|
||||
|
||||
currentSessionKey = resolveSessionKey(initialSessionInput);
|
||||
|
||||
const updateHeader = () => {
|
||||
const sessionLabel = formatSessionKey(currentSessionKey);
|
||||
const agentLabel = formatAgentLabel(currentAgentId);
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdbot tui - ${client.connection.url} - session ${currentSessionKey}`,
|
||||
`clawdbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -158,9 +208,11 @@ export async function runTui(opts: TuiOptions) {
|
||||
|
||||
const updateFooter = () => {
|
||||
const connection = isConnected ? "connected" : "disconnected";
|
||||
const sessionKeyLabel = formatSessionKey(currentSessionKey);
|
||||
const sessionLabel = sessionInfo.displayName
|
||||
? `${currentSessionKey} (${sessionInfo.displayName})`
|
||||
: currentSessionKey;
|
||||
? `${sessionKeyLabel} (${sessionInfo.displayName})`
|
||||
: sessionKeyLabel;
|
||||
const agentLabel = formatAgentLabel(currentAgentId);
|
||||
const modelLabel = sessionInfo.model ?? "unknown";
|
||||
const tokens = formatTokens(
|
||||
sessionInfo.totalTokens ?? null,
|
||||
@@ -172,7 +224,7 @@ export async function runTui(opts: TuiOptions) {
|
||||
const deliver = deliverDefault ? "on" : "off";
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
|
||||
`${connection} | agent ${agentLabel} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | reasoning ${reasoning} | ${tokens} | deliver ${deliver}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -188,11 +240,74 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.setFocus(component);
|
||||
};
|
||||
|
||||
const initialSessionAgentId = (() => {
|
||||
if (!initialSessionInput) return null;
|
||||
const parsed = parseAgentSessionKey(initialSessionInput);
|
||||
return parsed ? normalizeAgentId(parsed.agentId) : null;
|
||||
})();
|
||||
|
||||
const applyAgentsResult = (result: GatewayAgentsList) => {
|
||||
agentDefaultId = normalizeAgentId(result.defaultId);
|
||||
sessionMainKey = result.mainKey.trim() || sessionMainKey;
|
||||
sessionScope = result.scope ?? sessionScope;
|
||||
agents = result.agents.map((agent) => ({
|
||||
id: normalizeAgentId(agent.id),
|
||||
name: agent.name?.trim() || undefined,
|
||||
}));
|
||||
agentNames.clear();
|
||||
for (const agent of agents) {
|
||||
if (agent.name) agentNames.set(agent.id, agent.name);
|
||||
}
|
||||
if (!initialSessionApplied) {
|
||||
if (initialSessionAgentId) {
|
||||
if (agents.some((agent) => agent.id === initialSessionAgentId)) {
|
||||
currentAgentId = initialSessionAgentId;
|
||||
}
|
||||
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
|
||||
currentAgentId =
|
||||
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
|
||||
}
|
||||
const nextSessionKey = resolveSessionKey(initialSessionInput);
|
||||
if (nextSessionKey !== currentSessionKey) {
|
||||
currentSessionKey = nextSessionKey;
|
||||
}
|
||||
initialSessionApplied = true;
|
||||
} else if (!agents.some((agent) => agent.id === currentAgentId)) {
|
||||
currentAgentId =
|
||||
agents[0]?.id ?? normalizeAgentId(result.defaultId ?? currentAgentId);
|
||||
}
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
};
|
||||
|
||||
const refreshAgents = async () => {
|
||||
try {
|
||||
const result = await client.listAgents();
|
||||
applyAgentsResult(result);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`agents list failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAgentFromSessionKey = (key: string) => {
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
if (!parsed) return;
|
||||
const next = normalizeAgentId(parsed.agentId);
|
||||
if (next !== currentAgentId) {
|
||||
currentAgentId = next;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessionInfo = async () => {
|
||||
try {
|
||||
const listAgentId =
|
||||
currentSessionKey === "global" || currentSessionKey === "unknown"
|
||||
? undefined
|
||||
: currentAgentId;
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: listAgentId,
|
||||
});
|
||||
const entry = result.sessions.find(
|
||||
(row) => row.key === currentSessionKey,
|
||||
@@ -272,12 +387,15 @@ export async function runTui(opts: TuiOptions) {
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const setSession = async (key: string) => {
|
||||
currentSessionKey = key;
|
||||
const setSession = async (rawKey: string) => {
|
||||
const nextKey = resolveSessionKey(rawKey);
|
||||
updateAgentFromSessionKey(nextKey);
|
||||
currentSessionKey = nextKey;
|
||||
activeChatRunId = null;
|
||||
currentSessionId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
updateFooter();
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
@@ -429,15 +547,51 @@ export async function runTui(opts: TuiOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
const setAgent = async (id: string) => {
|
||||
currentAgentId = normalizeAgentId(id);
|
||||
await setSession("");
|
||||
};
|
||||
|
||||
const openAgentSelector = async () => {
|
||||
await refreshAgents();
|
||||
if (agents.length === 0) {
|
||||
chatLog.addSystem("no agents found");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const items = agents.map((agent) => ({
|
||||
value: agent.id,
|
||||
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
||||
description: agent.id === agentDefaultId ? "default" : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
await setAgent(item.value);
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const openSessionSelector = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
agentId: currentAgentId,
|
||||
});
|
||||
const items = result.sessions.map((session) => ({
|
||||
value: session.key,
|
||||
label: session.displayName ?? session.key,
|
||||
label: session.displayName
|
||||
? `${session.displayName} (${formatSessionKey(session.key)})`
|
||||
: formatSessionKey(session.key),
|
||||
description: session.updatedAt
|
||||
? new Date(session.updatedAt).toLocaleString()
|
||||
: "",
|
||||
@@ -528,6 +682,16 @@ export async function runTui(opts: TuiOptions) {
|
||||
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "agent":
|
||||
if (!args) {
|
||||
await openAgentSelector();
|
||||
} else {
|
||||
await setAgent(args);
|
||||
}
|
||||
break;
|
||||
case "agents":
|
||||
await openAgentSelector();
|
||||
break;
|
||||
case "session":
|
||||
if (!args) {
|
||||
await openSessionSelector();
|
||||
@@ -744,6 +908,9 @@ export async function runTui(opts: TuiOptions) {
|
||||
editor.onCtrlL = () => {
|
||||
void openModelSelector();
|
||||
};
|
||||
editor.onCtrlG = () => {
|
||||
void openAgentSelector();
|
||||
};
|
||||
editor.onCtrlP = () => {
|
||||
void openSessionSelector();
|
||||
};
|
||||
@@ -760,17 +927,19 @@ export async function runTui(opts: TuiOptions) {
|
||||
client.onConnected = () => {
|
||||
isConnected = true;
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
void (async () => {
|
||||
await refreshAgents();
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
await loadHistory();
|
||||
chatLog.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
} else {
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
|
||||
Reference in New Issue
Block a user