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+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>`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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();
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
209
src/tui/tui.ts
209
src/tui/tui.ts
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user