feat(tui): add agent picker and agents list rpc

This commit is contained in:
Peter Steinberger
2026-01-09 00:53:11 +01:00
parent a5f0f62e0d
commit 714e170c16
14 changed files with 471 additions and 20 deletions

View File

@@ -39,6 +39,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- Ctrl+C: clear input (press twice to exit) - Ctrl+C: clear input (press twice to exit)
- Ctrl+D: exit - Ctrl+D: exit
- Ctrl+L: model picker - Ctrl+L: model picker
- Ctrl+G: agent picker
- Ctrl+P: session picker - Ctrl+P: session picker
- Ctrl+O: toggle tool output expansion - Ctrl+O: toggle tool output expansion
- Ctrl+T: toggle thinking visibility - Ctrl+T: toggle thinking visibility
@@ -46,6 +47,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Slash commands ## Slash commands
- `/help` - `/help`
- `/status` - `/status`
- `/agent <id>` (or `/agents`)
- `/session <key>` (or `/sessions`) - `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/model list`, `/models`) - `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`

View File

@@ -3,6 +3,12 @@ import {
type AgentEvent, type AgentEvent,
AgentEventSchema, AgentEventSchema,
AgentParamsSchema, AgentParamsSchema,
type AgentSummary,
AgentSummarySchema,
type AgentsListParams,
AgentsListParamsSchema,
type AgentsListResult,
AgentsListResultSchema,
type AgentWaitParams, type AgentWaitParams,
AgentWaitParamsSchema, AgentWaitParamsSchema,
type ChatAbortParams, type ChatAbortParams,
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
AgentWaitParamsSchema, AgentWaitParamsSchema,
); );
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema); export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
AgentsListParamsSchema,
);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>( export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema, NodePairRequestParamsSchema,
); );
@@ -332,6 +341,9 @@ export {
ProvidersStatusParamsSchema, ProvidersStatusParamsSchema,
WebLoginStartParamsSchema, WebLoginStartParamsSchema,
WebLoginWaitParamsSchema, WebLoginWaitParamsSchema,
AgentSummarySchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelsListParamsSchema, ModelsListParamsSchema,
SkillsStatusParamsSchema, SkillsStatusParamsSchema,
SkillsInstallParamsSchema, SkillsInstallParamsSchema,
@@ -394,6 +406,9 @@ export type {
ProvidersStatusParams, ProvidersStatusParams,
WebLoginStartParams, WebLoginStartParams,
WebLoginWaitParams, WebLoginWaitParams,
AgentSummary,
AgentsListParams,
AgentsListResult,
SkillsStatusParams, SkillsStatusParams,
SkillsInstallParams, SkillsInstallParams,
SkillsUpdateParams, SkillsUpdateParams,

View File

@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
{ additionalProperties: false }, { 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( export const ModelsListParamsSchema = Type.Object(
{}, {},
{ additionalProperties: false }, { additionalProperties: false },
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ProvidersStatusParams: ProvidersStatusParamsSchema, ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
AgentsListParams: AgentsListParamsSchema,
AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema, ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema, ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema, ModelsListResult: ModelsListResultSchema,
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>; export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>; export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>; 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 ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>; export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>; export type ModelsListResult = Static<typeof ModelsListResultSchema>;

View File

@@ -1,5 +1,6 @@
import { ErrorCodes, errorShape } from "./protocol/index.js"; import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js"; import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { chatHandlers } from "./server-methods/chat.js"; import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js"; import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js"; import { connectHandlers } from "./server-methods/connect.js";
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
...sendHandlers, ...sendHandlers,
...usageHandlers, ...usageHandlers,
...agentHandlers, ...agentHandlers,
...agentsHandlers,
}; };
export async function handleGatewayRequest( export async function handleGatewayRequest(

View 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);
},
};

View 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");
});
});

View File

@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
ws.close(); ws.close();
await server.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",
]);
});
}); });

View File

@@ -227,6 +227,7 @@ const METHODS = [
"wizard.status", "wizard.status",
"talk.mode", "talk.mode",
"models.list", "models.list",
"agents.list",
"skills.status", "skills.status",
"skills.install", "skills.install",
"skills.update", "skills.update",

View File

@@ -17,8 +17,10 @@ import {
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
type SessionScope,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { import {
DEFAULT_MAIN_KEY,
normalizeAgentId, normalizeAgentId,
parseAgentSessionKey, parseAgentSessionKey,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
lastAccountId?: string; lastAccountId?: string;
}; };
export type GatewayAgentRow = {
id: string;
name?: string;
};
export type SessionsListResult = { export type SessionsListResult = {
ts: number; ts: number;
path: string; path: string;
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
return sorted; 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 { function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key; if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key; if (key.startsWith("agent:")) return key;
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true; const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true; const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const agentId =
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const activeMinutes = const activeMinutes =
typeof opts.activeMinutes === "number" && typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes) Number.isFinite(opts.activeMinutes)
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
.filter(([key]) => { .filter(([key]) => {
if (!includeGlobal && key === "global") return false; if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") 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; return true;
}) })
.filter(([key, entry]) => { .filter(([key, entry]) => {

View File

@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
export const testState = { export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined, agentConfig: undefined as Record<string, unknown> | undefined,
routingConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined, sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined, sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined, allowFrom: undefined as string[] | undefined,
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
workspace: path.join(os.tmpdir(), "clawd-gateway-test"), workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig, ...testState.agentConfig,
}, },
routing: testState.routingConfig,
whatsapp: { whatsapp: {
allowFrom: testState.allowFrom, allowFrom: testState.allowFrom,
}, },
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
testState.sessionConfig = undefined; testState.sessionConfig = undefined;
testState.sessionStorePath = undefined; testState.sessionStorePath = undefined;
testState.agentConfig = undefined; testState.agentConfig = undefined;
testState.routingConfig = undefined;
testState.allowFrom = undefined; testState.allowFrom = undefined;
testIsNixMode.value = false; testIsNixMode.value = false;
cronIsolatedRun.mockClear(); cronIsolatedRun.mockClear();

View File

@@ -31,6 +31,8 @@ export function getSlashCommands(): SlashCommand[] {
return [ return [
{ name: "help", description: "Show slash command help" }, { name: "help", description: "Show slash command help" },
{ name: "status", description: "Show gateway status summary" }, { 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: "session", description: "Switch session (or open picker)" },
{ name: "sessions", description: "Open session picker" }, { name: "sessions", description: "Open session picker" },
{ {
@@ -108,6 +110,7 @@ export function helpText(): string {
"Slash commands:", "Slash commands:",
"/help", "/help",
"/status", "/status",
"/agent <id> (or /agents)",
"/session <key> (or /sessions)", "/session <key> (or /sessions)",
"/model <provider/model> (or /models)", "/model <provider/model> (or /models)",
"/think <off|minimal|low|medium|high>", "/think <off|minimal|low|medium|high>",

View File

@@ -4,6 +4,7 @@ export class CustomEditor extends Editor {
onEscape?: () => void; onEscape?: () => void;
onCtrlC?: () => void; onCtrlC?: () => void;
onCtrlD?: () => void; onCtrlD?: () => void;
onCtrlG?: () => void;
onCtrlL?: () => void; onCtrlL?: () => void;
onCtrlO?: () => void; onCtrlO?: () => void;
onCtrlP?: () => void; onCtrlP?: () => void;
@@ -28,6 +29,10 @@ export class CustomEditor extends Editor {
this.onCtrlP(); this.onCtrlP();
return; return;
} }
if (matchesKey(data, Key.ctrl("g")) && this.onCtrlG) {
this.onCtrlG();
return;
}
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) { if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
this.onCtrlT(); this.onCtrlT();
return; return;

View File

@@ -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 = { export type GatewayModelChoice = {
id: string; id: string;
name: string; name: string;
@@ -165,9 +175,14 @@ export class GatewayChatClient {
activeMinutes: opts?.activeMinutes, activeMinutes: opts?.activeMinutes,
includeGlobal: opts?.includeGlobal, includeGlobal: opts?.includeGlobal,
includeUnknown: opts?.includeUnknown, includeUnknown: opts?.includeUnknown,
agentId: opts?.agentId,
}); });
} }
async listAgents() {
return await this.client.request<GatewayAgentsList>("agents.list", {});
}
async patchSession(opts: SessionsPatchParams) { async patchSession(opts: SessionsPatchParams) {
return await this.client.request("sessions.patch", opts); return await this.client.request("sessions.patch", opts);
} }

View File

@@ -7,6 +7,11 @@ import {
TUI, TUI,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { getSlashCommands, helpText, parseCommand } from "./commands.js";
import { ChatLog } from "./components/chat-log.js"; import { ChatLog } from "./components/chat-log.js";
import { CustomEditor } from "./components/custom-editor.js"; import { CustomEditor } from "./components/custom-editor.js";
@@ -14,7 +19,7 @@ import {
createSelectList, createSelectList,
createSettingsList, createSettingsList,
} from "./components/selectors.js"; } 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"; import { editorTheme, theme } from "./theme/theme.js";
export type TuiOptions = { export type TuiOptions = {
@@ -53,6 +58,13 @@ type SessionInfo = {
displayName?: string; displayName?: string;
}; };
type SessionScope = "per-sender" | "global";
type AgentSummary = {
id: string;
name?: string;
};
function extractTextBlocks( function extractTextBlocks(
content: unknown, content: unknown,
opts?: { includeThinking?: boolean }, opts?: { includeThinking?: boolean },
@@ -106,9 +118,18 @@ function asString(value: unknown, fallback = ""): string {
export async function runTui(opts: TuiOptions) { export async function runTui(opts: TuiOptions) {
const config = loadConfig(); const config = loadConfig();
const defaultSession = const initialSessionInput = (opts.session ?? "").trim();
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main"; let sessionScope: SessionScope = (config.session?.scope ??
let currentSessionKey = defaultSession; "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 currentSessionId: string | null = null;
let activeChatRunId: string | null = null; let activeChatRunId: string | null = null;
const finalizedRuns = new Map<string, number>(); const finalizedRuns = new Map<string, number>();
@@ -144,10 +165,39 @@ export async function runTui(opts: TuiOptions) {
tui.addChild(root); tui.addChild(root);
tui.setFocus(editor); 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 updateHeader = () => {
const sessionLabel = formatSessionKey(currentSessionKey);
const agentLabel = formatAgentLabel(currentAgentId);
header.setText( header.setText(
theme.header( 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 updateFooter = () => {
const connection = isConnected ? "connected" : "disconnected"; const connection = isConnected ? "connected" : "disconnected";
const sessionKeyLabel = formatSessionKey(currentSessionKey);
const sessionLabel = sessionInfo.displayName const sessionLabel = sessionInfo.displayName
? `${currentSessionKey} (${sessionInfo.displayName})` ? `${sessionKeyLabel} (${sessionInfo.displayName})`
: currentSessionKey; : sessionKeyLabel;
const agentLabel = formatAgentLabel(currentAgentId);
const modelLabel = sessionInfo.model ?? "unknown"; const modelLabel = sessionInfo.model ?? "unknown";
const tokens = formatTokens( const tokens = formatTokens(
sessionInfo.totalTokens ?? null, sessionInfo.totalTokens ?? null,
@@ -172,7 +224,7 @@ export async function runTui(opts: TuiOptions) {
const deliver = deliverDefault ? "on" : "off"; const deliver = deliverDefault ? "on" : "off";
footer.setText( footer.setText(
theme.dim( 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); 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 () => { const refreshSessionInfo = async () => {
try { try {
const listAgentId =
currentSessionKey === "global" || currentSessionKey === "unknown"
? undefined
: currentAgentId;
const result = await client.listSessions({ const result = await client.listSessions({
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
agentId: listAgentId,
}); });
const entry = result.sessions.find( const entry = result.sessions.find(
(row) => row.key === currentSessionKey, (row) => row.key === currentSessionKey,
@@ -272,12 +387,15 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender(); tui.requestRender();
}; };
const setSession = async (key: string) => { const setSession = async (rawKey: string) => {
currentSessionKey = key; const nextKey = resolveSessionKey(rawKey);
updateAgentFromSessionKey(nextKey);
currentSessionKey = nextKey;
activeChatRunId = null; activeChatRunId = null;
currentSessionId = null; currentSessionId = null;
historyLoaded = false; historyLoaded = false;
updateHeader(); updateHeader();
updateFooter();
await loadHistory(); 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 () => { const openSessionSelector = async () => {
try { try {
const result = await client.listSessions({ const result = await client.listSessions({
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
agentId: currentAgentId,
}); });
const items = result.sessions.map((session) => ({ const items = result.sessions.map((session) => ({
value: session.key, value: session.key,
label: session.displayName ?? session.key, label: session.displayName
? `${session.displayName} (${formatSessionKey(session.key)})`
: formatSessionKey(session.key),
description: session.updatedAt description: session.updatedAt
? new Date(session.updatedAt).toLocaleString() ? new Date(session.updatedAt).toLocaleString()
: "", : "",
@@ -528,6 +682,16 @@ export async function runTui(opts: TuiOptions) {
chatLog.addSystem(`status failed: ${String(err)}`); chatLog.addSystem(`status failed: ${String(err)}`);
} }
break; break;
case "agent":
if (!args) {
await openAgentSelector();
} else {
await setAgent(args);
}
break;
case "agents":
await openAgentSelector();
break;
case "session": case "session":
if (!args) { if (!args) {
await openSessionSelector(); await openSessionSelector();
@@ -744,6 +908,9 @@ export async function runTui(opts: TuiOptions) {
editor.onCtrlL = () => { editor.onCtrlL = () => {
void openModelSelector(); void openModelSelector();
}; };
editor.onCtrlG = () => {
void openAgentSelector();
};
editor.onCtrlP = () => { editor.onCtrlP = () => {
void openSessionSelector(); void openSessionSelector();
}; };
@@ -760,17 +927,19 @@ export async function runTui(opts: TuiOptions) {
client.onConnected = () => { client.onConnected = () => {
isConnected = true; isConnected = true;
setStatus("connected"); setStatus("connected");
updateHeader(); void (async () => {
if (!historyLoaded) { await refreshAgents();
void loadHistory().then(() => { updateHeader();
if (!historyLoaded) {
await loadHistory();
chatLog.addSystem("gateway connected"); chatLog.addSystem("gateway connected");
tui.requestRender(); tui.requestRender();
}); } else {
} else { chatLog.addSystem("gateway reconnected");
chatLog.addSystem("gateway reconnected"); }
} updateFooter();
updateFooter(); tui.requestRender();
tui.requestRender(); })();
}; };
client.onDisconnected = (reason) => { client.onDisconnected = (reason) => {