From 714e170c16d41f32f1a4195a49246daacc2838ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 00:53:11 +0100 Subject: [PATCH] feat(tui): add agent picker and agents list rpc --- docs/web/tui.md | 2 + src/gateway/protocol/index.ts | 15 ++ src/gateway/protocol/schema.ts | 30 ++++ src/gateway/server-methods.ts | 2 + src/gateway/server-methods/agents.ts | 29 ++++ src/gateway/server.agents.test.ts | 49 +++++++ src/gateway/server.sessions.test.ts | 80 ++++++++++ src/gateway/server.ts | 1 + src/gateway/session-utils.ts | 48 ++++++ src/gateway/test-helpers.ts | 3 + src/tui/commands.ts | 3 + src/tui/components/custom-editor.ts | 5 + src/tui/gateway-chat.ts | 15 ++ src/tui/tui.ts | 209 ++++++++++++++++++++++++--- 14 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 src/gateway/server-methods/agents.ts create mode 100644 src/gateway/server.agents.test.ts diff --git a/docs/web/tui.md b/docs/web/tui.md index d6eb8d592..b4daa0e5a 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -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 ` (or `/agents`) - `/session ` (or `/sessions`) - `/model ` (or `/model list`, `/models`) - `/think ` diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 5f76ee5ed..18d40aff0 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( AgentWaitParamsSchema, ); export const validateWakeParams = ajv.compile(WakeParamsSchema); +export const validateAgentsListParams = ajv.compile( + AgentsListParamsSchema, +); export const validateNodePairRequestParams = ajv.compile( 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, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index eccb05ffe..ac11af14c 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 = { 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; export type ProvidersStatusParams = Static; export type WebLoginStartParams = Static; export type WebLoginWaitParams = Static; +export type AgentSummary = Static; +export type AgentsListParams = Static; +export type AgentsListResult = Static; export type ModelChoice = Static; export type ModelsListParams = Static; export type ModelsListResult = Static; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 39a19a748..c6d7d352f 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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( diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts new file mode 100644 index 000000000..444efbe9d --- /dev/null +++ b/src/gateway/server-methods/agents.ts @@ -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); + }, +}; diff --git a/src/gateway/server.agents.test.ts b/src/gateway/server.agents.test.ts new file mode 100644 index 000000000..36ab82f4a --- /dev/null +++ b/src/gateway/server.agents.test.ts @@ -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"); + }); +}); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index d700c2d65..2f346018f 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -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", + ]); + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 340887ddf..cfd2a849d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -227,6 +227,7 @@ const METHODS = [ "wizard.status", "talk.mode", "models.list", + "agents.list", "skills.status", "skills.install", "skills.update", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 91dc540e8..dd3bb0024 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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(); + 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]) => { diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 98caaa89a..c7b454603 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand; export const testState = { agentConfig: undefined as Record | undefined, + routingConfig: undefined as Record | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | 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(); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 8e7a81046..6b4d29947 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -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 (or /agents)", "/session (or /sessions)", "/model (or /models)", "/think ", diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts index 526584498..9b0618c5c 100644 --- a/src/tui/components/custom-editor.ts +++ b/src/tui/components/custom-editor.ts @@ -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; diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 67c448702..2b9f0c65b 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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("agents.list", {}); + } + async patchSession(opts: SessionsPatchParams) { return await this.client.request("sessions.patch", opts); } diff --git a/src/tui/tui.ts b/src/tui/tui.ts index f81012669..073685302 100644 --- a/src/tui/tui.ts +++ b/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(); + let currentSessionKey = ""; + let initialSessionApplied = false; let currentSessionId: string | null = null; let activeChatRunId: string | null = null; const finalizedRuns = new Map(); @@ -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) => {