Files
openclaw/src/tui/tui-command-handlers.ts
Nimrod Gutman 9aac55d306 Add /btw side questions (#45444)
* feat(agent): add /btw side questions

* fix(agent): gate and log /btw reviews

* feat(btw): isolate side-question delivery

* test(reply): update route reply runtime mocks

* fix(btw): complete side-result delivery across clients

* fix(gateway): handle streamed btw side results

* fix(telegram): unblock btw side questions

* fix(reply): make external btw replies explicit

* fix(chat): keep btw side results ephemeral in internal history

* fix(btw): address remaining review feedback

* fix(chat): preserve btw history on mobile refresh

* fix(acp): keep btw replies out of prompt history

* refactor(btw): narrow side questions to live channels

* fix(btw): preserve channel typing indicators

* fix(btw): keep side questions isolated in chat

* fix(outbound): restore typed channel send deps

* fix(btw): avoid blocking replies on transcript persistence

* fix(btw): keep side questions fast

* docs(commands): document btw slash command

* docs(changelog): add btw side questions entry

* test(outbound): align session transcript mocks
2026-03-14 17:27:54 +02:00

564 lines
17 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Component, SelectItem, TUI } from "@mariozechner/pi-tui";
import {
formatThinkingLevels,
normalizeUsageDisplay,
resolveResponseUsageMode,
} from "../auto-reply/thinking.js";
import type { SessionsPatchResult } from "../gateway/protocol/index.js";
import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts";
import { normalizeAgentId } from "../routing/session-key.js";
import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js";
import {
createFilterableSelectList,
createSearchableSelectList,
createSettingsList,
} from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js";
import { sanitizeRenderableText } from "./tui-formatters.js";
import { formatStatusSummary } from "./tui-status-summary.js";
import type {
AgentSummary,
GatewayStatusSummary,
TuiOptions,
TuiStateAccess,
} from "./tui-types.js";
type CommandHandlerContext = {
client: GatewayChatClient;
chatLog: ChatLog;
tui: TUI;
opts: TuiOptions;
state: TuiStateAccess;
deliverDefault: boolean;
openOverlay: (component: Component) => void;
closeOverlay: () => void;
refreshSessionInfo: () => Promise<void>;
loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>;
abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string;
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
noteLocalRunId: (runId: string) => void;
noteLocalBtwRunId?: (runId: string) => void;
forgetLocalRunId?: (runId: string) => void;
forgetLocalBtwRunId?: (runId: string) => void;
requestExit: () => void;
};
function isBtwCommand(text: string): boolean {
return /^\/btw(?::|\s|$)/i.test(text.trim());
}
export function createCommandHandlers(context: CommandHandlerContext) {
const {
client,
chatLog,
tui,
opts,
state,
deliverDefault,
openOverlay,
closeOverlay,
refreshSessionInfo,
loadHistory,
setSession,
refreshAgents,
abortActive,
setActivityStatus,
formatSessionKey,
applySessionInfoFromPatch,
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId,
forgetLocalBtwRunId,
requestExit,
} = context;
const setAgent = async (id: string) => {
state.currentAgentId = normalizeAgentId(id);
await setSession("");
};
const closeOverlayAndRender = () => {
closeOverlay();
tui.requestRender();
};
const openSelector = (
selector: {
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
},
onSelect: (value: string) => Promise<void>,
) => {
selector.onSelect = (item) => {
void (async () => {
await onSelect(item.value);
closeOverlayAndRender();
})();
};
selector.onCancel = closeOverlayAndRender;
openOverlay(selector as Component);
tui.requestRender();
};
const openModelSelector = async () => {
try {
const models = await client.listModels();
if (models.length === 0) {
chatLog.addSystem("no models available");
tui.requestRender();
return;
}
const items = models.map((model) => ({
value: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "",
}));
const selector = createSearchableSelectList(items, 9);
openSelector(selector, async (value) => {
try {
const result = await client.patchSession({
key: state.currentSessionKey,
model: value,
});
chatLog.addSystem(`model set to ${value}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
});
} catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender();
}
};
const openAgentSelector = async () => {
await refreshAgents();
if (state.agents.length === 0) {
chatLog.addSystem("no agents found");
tui.requestRender();
return;
}
const items = state.agents.map((agent: AgentSummary) => ({
value: agent.id,
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === state.agentDefaultId ? "default" : "",
}));
const selector = createSearchableSelectList(items, 9);
openSelector(selector, async (value) => {
await setAgent(value);
});
};
const openSessionSelector = async () => {
try {
const result = await client.listSessions({
includeGlobal: false,
includeUnknown: false,
includeDerivedTitles: true,
includeLastMessage: true,
agentId: state.currentAgentId,
});
const items = result.sessions.map((session) => {
const title = session.derivedTitle ?? session.displayName;
const formattedKey = formatSessionKey(session.key);
// Avoid redundant "title (key)" when title matches key
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
// Build description: time + message preview
const timePart = session.updatedAt
? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" })
: "";
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
const description =
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
return {
value: session.key,
label,
description,
searchText: [
session.displayName,
session.label,
session.subject,
session.sessionId,
session.key,
session.lastMessagePreview,
]
.filter(Boolean)
.join(" "),
};
});
const selector = createFilterableSelectList(items, 9);
openSelector(selector, async (value) => {
await setSession(value);
});
} catch (err) {
chatLog.addSystem(`sessions list failed: ${String(err)}`);
tui.requestRender();
}
};
const openSettings = () => {
const items = [
{
id: "tools",
label: "Tool output",
currentValue: state.toolsExpanded ? "expanded" : "collapsed",
values: ["collapsed", "expanded"],
},
{
id: "thinking",
label: "Show thinking",
currentValue: state.showThinking ? "on" : "off",
values: ["off", "on"],
},
];
const settings = createSettingsList(
items,
(id, value) => {
if (id === "tools") {
state.toolsExpanded = value === "expanded";
chatLog.setToolsExpanded(state.toolsExpanded);
}
if (id === "thinking") {
state.showThinking = value === "on";
void loadHistory();
}
tui.requestRender();
},
() => {
closeOverlay();
tui.requestRender();
},
);
openOverlay(settings);
tui.requestRender();
};
const handleCommand = async (raw: string) => {
const { name, args } = parseCommand(raw);
if (!name) {
return;
}
switch (name) {
case "help":
chatLog.addSystem(
helpText({
provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
}),
);
break;
case "status":
try {
const status = await client.getStatus();
if (typeof status === "string") {
chatLog.addSystem(status);
break;
}
if (status && typeof status === "object") {
const lines = formatStatusSummary(status as GatewayStatusSummary);
for (const line of lines) {
chatLog.addSystem(line);
}
break;
}
chatLog.addSystem("status: unknown response");
} catch (err) {
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();
} else {
await setSession(args);
}
break;
case "sessions":
await openSessionSelector();
break;
case "model":
if (!args) {
await openModelSelector();
} else {
try {
const result = await client.patchSession({
key: state.currentSessionKey,
model: args,
});
chatLog.addSystem(`model set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`);
}
}
break;
case "models":
await openModelSelector();
break;
case "think":
if (!args) {
const levels = formatThinkingLevels(
state.sessionInfo.modelProvider,
state.sessionInfo.model,
"|",
);
chatLog.addSystem(`usage: /think <${levels}>`);
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
thinkingLevel: args,
});
chatLog.addSystem(`thinking set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`);
}
break;
case "verbose":
if (!args) {
chatLog.addSystem("usage: /verbose <on|off>");
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
verboseLevel: args,
});
chatLog.addSystem(`verbose set to ${args}`);
applySessionInfoFromPatch(result);
await loadHistory();
} catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`);
}
break;
case "fast":
if (!args || args === "status") {
chatLog.addSystem(`fast mode: ${state.sessionInfo.fastMode ? "on" : "off"}`);
break;
}
if (args !== "on" && args !== "off") {
chatLog.addSystem("usage: /fast <status|on|off>");
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
fastMode: args === "on",
});
chatLog.addSystem(`fast mode ${args === "on" ? "enabled" : "disabled"}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`fast failed: ${String(err)}`);
}
break;
case "reasoning":
if (!args) {
chatLog.addSystem("usage: /reasoning <on|off>");
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
reasoningLevel: args,
});
chatLog.addSystem(`reasoning set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`);
}
break;
case "usage": {
const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) {
chatLog.addSystem("usage: /usage <off|tokens|full>");
break;
}
const currentRaw = state.sessionInfo.responseUsage;
const current = resolveResponseUsageMode(currentRaw);
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try {
const result = await client.patchSession({
key: state.currentSessionKey,
responseUsage: next === "off" ? null : next,
});
chatLog.addSystem(`usage footer: ${next}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`);
}
break;
}
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
elevatedLevel: args,
});
chatLog.addSystem(`elevated set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`);
}
break;
case "activation":
if (!args) {
chatLog.addSystem("usage: /activation <mention|always>");
break;
}
try {
const result = await client.patchSession({
key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention",
});
chatLog.addSystem(`activation set to ${args}`);
applySessionInfoFromPatch(result);
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`);
}
break;
case "new":
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
// Generate unique session key to isolate this TUI client (#39217)
// This ensures /new creates a fresh session that doesn't broadcast
// to other connected TUI clients sharing the original session key.
const uniqueKey = `tui-${randomUUID()}`;
await setSession(uniqueKey);
chatLog.addSystem(`new session: ${uniqueKey}`);
} catch (err) {
chatLog.addSystem(`new session failed: ${sanitizeRenderableText(String(err))}`);
}
break;
case "reset":
try {
// Clear token counts immediately to avoid stale display (#1523)
state.sessionInfo.inputTokens = null;
state.sessionInfo.outputTokens = null;
state.sessionInfo.totalTokens = null;
tui.requestRender();
await client.resetSession(state.currentSessionKey, name);
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory();
} catch (err) {
chatLog.addSystem(`reset failed: ${sanitizeRenderableText(String(err))}`);
}
break;
case "abort":
await abortActive();
break;
case "settings":
openSettings();
break;
case "exit":
case "quit":
requestExit();
break;
default:
await sendMessage(raw);
break;
}
tui.requestRender();
};
const sendMessage = async (text: string) => {
if (!state.isConnected) {
chatLog.addSystem("not connected to gateway — message not sent");
setActivityStatus("disconnected");
tui.requestRender();
return;
}
const isBtw = isBtwCommand(text);
const runId = randomUUID();
try {
if (!isBtw) {
chatLog.addUser(text);
noteLocalRunId(runId);
state.activeChatRunId = runId;
setActivityStatus("sending");
} else {
noteLocalBtwRunId?.(runId);
}
tui.requestRender();
await client.sendChat({
sessionKey: state.currentSessionKey,
message: text,
thinking: opts.thinking,
deliver: deliverDefault,
timeoutMs: opts.timeoutMs,
runId,
});
if (!isBtw) {
setActivityStatus("waiting");
tui.requestRender();
}
} catch (err) {
if (isBtw) {
forgetLocalBtwRunId?.(runId);
}
if (!isBtw && state.activeChatRunId) {
forgetLocalRunId?.(state.activeChatRunId);
}
if (!isBtw) {
state.activeChatRunId = null;
}
chatLog.addSystem(`${isBtw ? "btw failed" : "send failed"}: ${String(err)}`);
if (!isBtw) {
setActivityStatus("error");
}
tui.requestRender();
}
};
return {
handleCommand,
sendMessage,
openModelSelector,
openAgentSelector,
openSessionSelector,
openSettings,
setAgent,
};
}