feat(tui): add agent picker and agents list rpc
This commit is contained in:
@@ -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