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

@@ -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>",

View File

@@ -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;

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 = {
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);
}

View File

@@ -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) => {