964 lines
25 KiB
TypeScript
964 lines
25 KiB
TypeScript
import {
|
|
CombinedAutocompleteProvider,
|
|
Container,
|
|
Key,
|
|
Loader,
|
|
matchesKey,
|
|
ProcessTerminal,
|
|
Text,
|
|
TUI,
|
|
} from "@mariozechner/pi-tui";
|
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import {
|
|
buildAgentMainSessionKey,
|
|
normalizeAgentId,
|
|
normalizeMainKey,
|
|
parseAgentSessionKey,
|
|
} from "../routing/session-key.js";
|
|
import { getSlashCommands } from "./commands.js";
|
|
import { ChatLog } from "./components/chat-log.js";
|
|
import { CustomEditor } from "./components/custom-editor.js";
|
|
import { GatewayChatClient } from "./gateway-chat.js";
|
|
import { editorTheme, theme } from "./theme/theme.js";
|
|
import { createCommandHandlers } from "./tui-command-handlers.js";
|
|
import { createEventHandlers } from "./tui-event-handlers.js";
|
|
import { formatTokens } from "./tui-formatters.js";
|
|
import { createLocalShellRunner } from "./tui-local-shell.js";
|
|
import { createOverlayHandlers } from "./tui-overlays.js";
|
|
import { createSessionActions } from "./tui-session-actions.js";
|
|
import type {
|
|
AgentSummary,
|
|
SessionInfo,
|
|
SessionScope,
|
|
TuiOptions,
|
|
TuiStateAccess,
|
|
} from "./tui-types.js";
|
|
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
|
|
|
|
export { resolveFinalAssistantText } from "./tui-formatters.js";
|
|
export type { TuiOptions } from "./tui-types.js";
|
|
|
|
export function createEditorSubmitHandler(params: {
|
|
editor: {
|
|
setText: (value: string) => void;
|
|
addToHistory: (value: string) => void;
|
|
};
|
|
handleCommand: (value: string) => Promise<void> | void;
|
|
sendMessage: (value: string) => Promise<void> | void;
|
|
handleBangLine: (value: string) => Promise<void> | void;
|
|
}) {
|
|
return (text: string) => {
|
|
const raw = text;
|
|
const value = raw.trim();
|
|
params.editor.setText("");
|
|
|
|
// Keep previous behavior: ignore empty/whitespace-only submissions.
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
// Bash mode: only if the very first character is '!' and it's not just '!'.
|
|
// IMPORTANT: use the raw (untrimmed) text so leading spaces do NOT trigger.
|
|
// Per requirement: a lone '!' should be treated as a normal message.
|
|
if (raw.startsWith("!") && raw !== "!") {
|
|
params.editor.addToHistory(raw);
|
|
void params.handleBangLine(raw);
|
|
return;
|
|
}
|
|
|
|
// Enable built-in editor prompt history navigation (up/down).
|
|
params.editor.addToHistory(value);
|
|
|
|
if (value.startsWith("/")) {
|
|
void params.handleCommand(value);
|
|
return;
|
|
}
|
|
|
|
void params.sendMessage(value);
|
|
};
|
|
}
|
|
|
|
export function shouldEnableWindowsGitBashPasteFallback(params?: {
|
|
platform?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
const platform = params?.platform ?? process.platform;
|
|
const env = params?.env ?? process.env;
|
|
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
|
|
// Some macOS terminals emit multiline paste as rapid single-line submits.
|
|
// Enable burst coalescing so pasted blocks stay as one user message.
|
|
if (platform === "darwin") {
|
|
if (termProgram.includes("iterm") || termProgram.includes("apple_terminal")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (platform !== "win32") {
|
|
return false;
|
|
}
|
|
|
|
const msystem = (env.MSYSTEM ?? "").toUpperCase();
|
|
const shell = env.SHELL ?? "";
|
|
if (msystem.startsWith("MINGW") || msystem.startsWith("MSYS")) {
|
|
return true;
|
|
}
|
|
if (shell.toLowerCase().includes("bash")) {
|
|
return true;
|
|
}
|
|
return termProgram.includes("mintty");
|
|
}
|
|
|
|
export function createSubmitBurstCoalescer(params: {
|
|
submit: (value: string) => void;
|
|
enabled: boolean;
|
|
burstWindowMs?: number;
|
|
now?: () => number;
|
|
setTimer?: typeof setTimeout;
|
|
clearTimer?: typeof clearTimeout;
|
|
}) {
|
|
const windowMs = Math.max(1, params.burstWindowMs ?? 50);
|
|
const now = params.now ?? (() => Date.now());
|
|
const setTimer = params.setTimer ?? setTimeout;
|
|
const clearTimer = params.clearTimer ?? clearTimeout;
|
|
let pending: string | null = null;
|
|
let pendingAt = 0;
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const clearFlushTimer = () => {
|
|
if (!flushTimer) {
|
|
return;
|
|
}
|
|
clearTimer(flushTimer);
|
|
flushTimer = null;
|
|
};
|
|
|
|
const flushPending = () => {
|
|
if (pending === null) {
|
|
return;
|
|
}
|
|
const value = pending;
|
|
pending = null;
|
|
pendingAt = 0;
|
|
clearFlushTimer();
|
|
params.submit(value);
|
|
};
|
|
|
|
const scheduleFlush = () => {
|
|
clearFlushTimer();
|
|
flushTimer = setTimer(() => {
|
|
flushPending();
|
|
}, windowMs);
|
|
};
|
|
|
|
return (value: string) => {
|
|
if (!params.enabled) {
|
|
params.submit(value);
|
|
return;
|
|
}
|
|
if (value.includes("\n")) {
|
|
flushPending();
|
|
params.submit(value);
|
|
return;
|
|
}
|
|
const ts = now();
|
|
if (pending === null) {
|
|
pending = value;
|
|
pendingAt = ts;
|
|
scheduleFlush();
|
|
return;
|
|
}
|
|
if (ts - pendingAt <= windowMs) {
|
|
pending = `${pending}\n${value}`;
|
|
pendingAt = ts;
|
|
scheduleFlush();
|
|
return;
|
|
}
|
|
flushPending();
|
|
pending = value;
|
|
pendingAt = ts;
|
|
scheduleFlush();
|
|
};
|
|
}
|
|
|
|
export function resolveTuiSessionKey(params: {
|
|
raw?: string;
|
|
sessionScope: SessionScope;
|
|
currentAgentId: string;
|
|
sessionMainKey: string;
|
|
}) {
|
|
const trimmed = (params.raw ?? "").trim();
|
|
if (!trimmed) {
|
|
if (params.sessionScope === "global") {
|
|
return "global";
|
|
}
|
|
return buildAgentMainSessionKey({
|
|
agentId: params.currentAgentId,
|
|
mainKey: params.sessionMainKey,
|
|
});
|
|
}
|
|
if (trimmed === "global" || trimmed === "unknown") {
|
|
return trimmed;
|
|
}
|
|
if (trimmed.startsWith("agent:")) {
|
|
return trimmed;
|
|
}
|
|
return `agent:${params.currentAgentId}:${trimmed}`;
|
|
}
|
|
|
|
export function resolveGatewayDisconnectState(reason?: string): {
|
|
connectionStatus: string;
|
|
activityStatus: string;
|
|
pairingHint?: string;
|
|
} {
|
|
const reasonLabel = reason?.trim() ? reason.trim() : "closed";
|
|
if (/pairing required/i.test(reasonLabel)) {
|
|
return {
|
|
connectionStatus: `gateway disconnected: ${reasonLabel}`,
|
|
activityStatus: "pairing required: run openclaw devices list",
|
|
pairingHint:
|
|
"Pairing required. Run `openclaw devices list`, approve your request ID, then reconnect.",
|
|
};
|
|
}
|
|
return {
|
|
connectionStatus: `gateway disconnected: ${reasonLabel}`,
|
|
activityStatus: "idle",
|
|
};
|
|
}
|
|
|
|
export function createBackspaceDeduper(params?: { dedupeWindowMs?: number; now?: () => number }) {
|
|
const dedupeWindowMs = Math.max(0, Math.floor(params?.dedupeWindowMs ?? 8));
|
|
const now = params?.now ?? (() => Date.now());
|
|
let lastBackspaceAt = -1;
|
|
|
|
return (data: string): string => {
|
|
if (!matchesKey(data, Key.backspace)) {
|
|
return data;
|
|
}
|
|
const ts = now();
|
|
if (lastBackspaceAt >= 0 && ts - lastBackspaceAt <= dedupeWindowMs) {
|
|
return "";
|
|
}
|
|
lastBackspaceAt = ts;
|
|
return data;
|
|
};
|
|
}
|
|
|
|
export function isIgnorableTuiStopError(error: unknown): boolean {
|
|
if (!error || typeof error !== "object") {
|
|
return false;
|
|
}
|
|
const err = error as { code?: unknown; syscall?: unknown; message?: unknown };
|
|
const code = typeof err.code === "string" ? err.code : "";
|
|
const syscall = typeof err.syscall === "string" ? err.syscall : "";
|
|
const message = typeof err.message === "string" ? err.message : "";
|
|
if (code === "EBADF" && syscall === "setRawMode") {
|
|
return true;
|
|
}
|
|
return /setRawMode/i.test(message) && /EBADF/i.test(message);
|
|
}
|
|
|
|
export function stopTuiSafely(stop: () => void): void {
|
|
try {
|
|
stop();
|
|
} catch (error) {
|
|
if (!isIgnorableTuiStopError(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
type CtrlCAction = "clear" | "warn" | "exit";
|
|
|
|
export function resolveCtrlCAction(params: {
|
|
hasInput: boolean;
|
|
now: number;
|
|
lastCtrlCAt: number;
|
|
exitWindowMs?: number;
|
|
}): { action: CtrlCAction; nextLastCtrlCAt: number } {
|
|
const exitWindowMs = Math.max(1, Math.floor(params.exitWindowMs ?? 1000));
|
|
if (params.hasInput) {
|
|
return {
|
|
action: "clear",
|
|
nextLastCtrlCAt: params.now,
|
|
};
|
|
}
|
|
if (params.now - params.lastCtrlCAt <= exitWindowMs) {
|
|
return {
|
|
action: "exit",
|
|
nextLastCtrlCAt: params.lastCtrlCAt,
|
|
};
|
|
}
|
|
return {
|
|
action: "warn",
|
|
nextLastCtrlCAt: params.now,
|
|
};
|
|
}
|
|
|
|
export async function runTui(opts: TuiOptions) {
|
|
const config = loadConfig();
|
|
const initialSessionInput = (opts.session ?? "").trim();
|
|
let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope;
|
|
let sessionMainKey = normalizeMainKey(config.session?.mainKey);
|
|
let agentDefaultId = resolveDefaultAgentId(config);
|
|
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;
|
|
let historyLoaded = false;
|
|
let isConnected = false;
|
|
let wasDisconnected = false;
|
|
let toolsExpanded = false;
|
|
let showThinking = false;
|
|
let pairingHintShown = false;
|
|
const localRunIds = new Set<string>();
|
|
|
|
const deliverDefault = opts.deliver ?? false;
|
|
const autoMessage = opts.message?.trim();
|
|
let autoMessageSent = false;
|
|
let sessionInfo: SessionInfo = {};
|
|
let lastCtrlCAt = 0;
|
|
let exitRequested = false;
|
|
let activityStatus = "idle";
|
|
let connectionStatus = "connecting";
|
|
let statusTimeout: NodeJS.Timeout | null = null;
|
|
let statusTimer: NodeJS.Timeout | null = null;
|
|
let statusStartedAt: number | null = null;
|
|
let lastActivityStatus = activityStatus;
|
|
|
|
const state: TuiStateAccess = {
|
|
get agentDefaultId() {
|
|
return agentDefaultId;
|
|
},
|
|
set agentDefaultId(value) {
|
|
agentDefaultId = value;
|
|
},
|
|
get sessionMainKey() {
|
|
return sessionMainKey;
|
|
},
|
|
set sessionMainKey(value) {
|
|
sessionMainKey = value;
|
|
},
|
|
get sessionScope() {
|
|
return sessionScope;
|
|
},
|
|
set sessionScope(value) {
|
|
sessionScope = value;
|
|
},
|
|
get agents() {
|
|
return agents;
|
|
},
|
|
set agents(value) {
|
|
agents = value;
|
|
},
|
|
get currentAgentId() {
|
|
return currentAgentId;
|
|
},
|
|
set currentAgentId(value) {
|
|
currentAgentId = value;
|
|
},
|
|
get currentSessionKey() {
|
|
return currentSessionKey;
|
|
},
|
|
set currentSessionKey(value) {
|
|
currentSessionKey = value;
|
|
},
|
|
get currentSessionId() {
|
|
return currentSessionId;
|
|
},
|
|
set currentSessionId(value) {
|
|
currentSessionId = value;
|
|
},
|
|
get activeChatRunId() {
|
|
return activeChatRunId;
|
|
},
|
|
set activeChatRunId(value) {
|
|
activeChatRunId = value;
|
|
},
|
|
get historyLoaded() {
|
|
return historyLoaded;
|
|
},
|
|
set historyLoaded(value) {
|
|
historyLoaded = value;
|
|
},
|
|
get sessionInfo() {
|
|
return sessionInfo;
|
|
},
|
|
set sessionInfo(value) {
|
|
sessionInfo = value;
|
|
},
|
|
get initialSessionApplied() {
|
|
return initialSessionApplied;
|
|
},
|
|
set initialSessionApplied(value) {
|
|
initialSessionApplied = value;
|
|
},
|
|
get isConnected() {
|
|
return isConnected;
|
|
},
|
|
set isConnected(value) {
|
|
isConnected = value;
|
|
},
|
|
get autoMessageSent() {
|
|
return autoMessageSent;
|
|
},
|
|
set autoMessageSent(value) {
|
|
autoMessageSent = value;
|
|
},
|
|
get toolsExpanded() {
|
|
return toolsExpanded;
|
|
},
|
|
set toolsExpanded(value) {
|
|
toolsExpanded = value;
|
|
},
|
|
get showThinking() {
|
|
return showThinking;
|
|
},
|
|
set showThinking(value) {
|
|
showThinking = value;
|
|
},
|
|
get connectionStatus() {
|
|
return connectionStatus;
|
|
},
|
|
set connectionStatus(value) {
|
|
connectionStatus = value;
|
|
},
|
|
get activityStatus() {
|
|
return activityStatus;
|
|
},
|
|
set activityStatus(value) {
|
|
activityStatus = value;
|
|
},
|
|
get statusTimeout() {
|
|
return statusTimeout;
|
|
},
|
|
set statusTimeout(value) {
|
|
statusTimeout = value;
|
|
},
|
|
get lastCtrlCAt() {
|
|
return lastCtrlCAt;
|
|
},
|
|
set lastCtrlCAt(value) {
|
|
lastCtrlCAt = value;
|
|
},
|
|
};
|
|
|
|
const noteLocalRunId = (runId: string) => {
|
|
if (!runId) {
|
|
return;
|
|
}
|
|
localRunIds.add(runId);
|
|
if (localRunIds.size > 200) {
|
|
const [first] = localRunIds;
|
|
if (first) {
|
|
localRunIds.delete(first);
|
|
}
|
|
}
|
|
};
|
|
|
|
const forgetLocalRunId = (runId: string) => {
|
|
localRunIds.delete(runId);
|
|
};
|
|
|
|
const isLocalRunId = (runId: string) => localRunIds.has(runId);
|
|
|
|
const clearLocalRunIds = () => {
|
|
localRunIds.clear();
|
|
};
|
|
|
|
const client = new GatewayChatClient({
|
|
url: opts.url,
|
|
token: opts.token,
|
|
password: opts.password,
|
|
});
|
|
|
|
const tui = new TUI(new ProcessTerminal());
|
|
const dedupeBackspace = createBackspaceDeduper();
|
|
tui.addInputListener((data) => {
|
|
const next = dedupeBackspace(data);
|
|
if (next.length === 0) {
|
|
return { consume: true };
|
|
}
|
|
return { data: next };
|
|
});
|
|
const header = new Text("", 1, 0);
|
|
const statusContainer = new Container();
|
|
const footer = new Text("", 1, 0);
|
|
const chatLog = new ChatLog();
|
|
const editor = new CustomEditor(tui, editorTheme);
|
|
const root = new Container();
|
|
root.addChild(header);
|
|
root.addChild(chatLog);
|
|
root.addChild(statusContainer);
|
|
root.addChild(footer);
|
|
root.addChild(editor);
|
|
|
|
const updateAutocompleteProvider = () => {
|
|
editor.setAutocompleteProvider(
|
|
new CombinedAutocompleteProvider(
|
|
getSlashCommands({
|
|
cfg: config,
|
|
provider: sessionInfo.modelProvider,
|
|
model: sessionInfo.model,
|
|
}),
|
|
process.cwd(),
|
|
),
|
|
);
|
|
};
|
|
|
|
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) => {
|
|
return resolveTuiSessionKey({
|
|
raw,
|
|
sessionScope,
|
|
currentAgentId,
|
|
sessionMainKey,
|
|
});
|
|
};
|
|
|
|
currentSessionKey = resolveSessionKey(initialSessionInput);
|
|
|
|
const updateHeader = () => {
|
|
const sessionLabel = formatSessionKey(currentSessionKey);
|
|
const agentLabel = formatAgentLabel(currentAgentId);
|
|
header.setText(
|
|
theme.header(
|
|
`openclaw tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
|
|
),
|
|
);
|
|
};
|
|
|
|
const busyStates = new Set(["sending", "waiting", "streaming", "running"]);
|
|
let statusText: Text | null = null;
|
|
let statusLoader: Loader | null = null;
|
|
|
|
const formatElapsed = (startMs: number) => {
|
|
const totalSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
|
if (totalSeconds < 60) {
|
|
return `${totalSeconds}s`;
|
|
}
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}m ${seconds}s`;
|
|
};
|
|
|
|
const ensureStatusText = () => {
|
|
if (statusText) {
|
|
return;
|
|
}
|
|
statusContainer.clear();
|
|
statusLoader?.stop();
|
|
statusLoader = null;
|
|
statusText = new Text("", 1, 0);
|
|
statusContainer.addChild(statusText);
|
|
};
|
|
|
|
const ensureStatusLoader = () => {
|
|
if (statusLoader) {
|
|
return;
|
|
}
|
|
statusContainer.clear();
|
|
statusText = null;
|
|
statusLoader = new Loader(
|
|
tui,
|
|
(spinner) => theme.accent(spinner),
|
|
(text) => theme.bold(theme.accentSoft(text)),
|
|
"",
|
|
);
|
|
statusContainer.addChild(statusLoader);
|
|
};
|
|
|
|
let waitingTick = 0;
|
|
let waitingTimer: NodeJS.Timeout | null = null;
|
|
let waitingPhrase: string | null = null;
|
|
|
|
const updateBusyStatusMessage = () => {
|
|
if (!statusLoader || !statusStartedAt) {
|
|
return;
|
|
}
|
|
const elapsed = formatElapsed(statusStartedAt);
|
|
|
|
if (activityStatus === "waiting") {
|
|
waitingTick++;
|
|
statusLoader.setMessage(
|
|
buildWaitingStatusMessage({
|
|
theme,
|
|
tick: waitingTick,
|
|
elapsed,
|
|
connectionStatus,
|
|
phrases: waitingPhrase ? [waitingPhrase] : undefined,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`);
|
|
};
|
|
|
|
const startStatusTimer = () => {
|
|
if (statusTimer) {
|
|
return;
|
|
}
|
|
statusTimer = setInterval(() => {
|
|
if (!busyStates.has(activityStatus)) {
|
|
return;
|
|
}
|
|
updateBusyStatusMessage();
|
|
}, 1000);
|
|
};
|
|
|
|
const stopStatusTimer = () => {
|
|
if (!statusTimer) {
|
|
return;
|
|
}
|
|
clearInterval(statusTimer);
|
|
statusTimer = null;
|
|
};
|
|
|
|
const startWaitingTimer = () => {
|
|
if (waitingTimer) {
|
|
return;
|
|
}
|
|
|
|
// Pick a phrase once per waiting session.
|
|
if (!waitingPhrase) {
|
|
const idx = Math.floor(Math.random() * defaultWaitingPhrases.length);
|
|
waitingPhrase = defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting";
|
|
}
|
|
|
|
waitingTick = 0;
|
|
|
|
waitingTimer = setInterval(() => {
|
|
if (activityStatus !== "waiting") {
|
|
return;
|
|
}
|
|
updateBusyStatusMessage();
|
|
}, 120);
|
|
};
|
|
|
|
const stopWaitingTimer = () => {
|
|
if (!waitingTimer) {
|
|
return;
|
|
}
|
|
clearInterval(waitingTimer);
|
|
waitingTimer = null;
|
|
waitingPhrase = null;
|
|
};
|
|
|
|
const renderStatus = () => {
|
|
const isBusy = busyStates.has(activityStatus);
|
|
if (isBusy) {
|
|
if (!statusStartedAt || lastActivityStatus !== activityStatus) {
|
|
statusStartedAt = Date.now();
|
|
}
|
|
ensureStatusLoader();
|
|
if (activityStatus === "waiting") {
|
|
stopStatusTimer();
|
|
startWaitingTimer();
|
|
} else {
|
|
stopWaitingTimer();
|
|
startStatusTimer();
|
|
}
|
|
updateBusyStatusMessage();
|
|
} else {
|
|
statusStartedAt = null;
|
|
stopStatusTimer();
|
|
stopWaitingTimer();
|
|
statusLoader?.stop();
|
|
statusLoader = null;
|
|
ensureStatusText();
|
|
const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus;
|
|
statusText?.setText(theme.dim(text));
|
|
}
|
|
lastActivityStatus = activityStatus;
|
|
};
|
|
|
|
const setConnectionStatus = (text: string, ttlMs?: number) => {
|
|
connectionStatus = text;
|
|
renderStatus();
|
|
if (statusTimeout) {
|
|
clearTimeout(statusTimeout);
|
|
}
|
|
if (ttlMs && ttlMs > 0) {
|
|
statusTimeout = setTimeout(() => {
|
|
connectionStatus = isConnected ? "connected" : "disconnected";
|
|
renderStatus();
|
|
}, ttlMs);
|
|
}
|
|
};
|
|
|
|
const setActivityStatus = (text: string) => {
|
|
activityStatus = text;
|
|
renderStatus();
|
|
};
|
|
|
|
const updateFooter = () => {
|
|
const sessionKeyLabel = formatSessionKey(currentSessionKey);
|
|
const sessionLabel = sessionInfo.displayName
|
|
? `${sessionKeyLabel} (${sessionInfo.displayName})`
|
|
: sessionKeyLabel;
|
|
const agentLabel = formatAgentLabel(currentAgentId);
|
|
const modelLabel = sessionInfo.model
|
|
? sessionInfo.modelProvider
|
|
? `${sessionInfo.modelProvider}/${sessionInfo.model}`
|
|
: sessionInfo.model
|
|
: "unknown";
|
|
const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null);
|
|
const think = sessionInfo.thinkingLevel ?? "off";
|
|
const verbose = sessionInfo.verboseLevel ?? "off";
|
|
const reasoning = sessionInfo.reasoningLevel ?? "off";
|
|
const reasoningLabel =
|
|
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
|
|
const footerParts = [
|
|
`agent ${agentLabel}`,
|
|
`session ${sessionLabel}`,
|
|
modelLabel,
|
|
think !== "off" ? `think ${think}` : null,
|
|
verbose !== "off" ? `verbose ${verbose}` : null,
|
|
reasoningLabel,
|
|
tokens,
|
|
].filter(Boolean);
|
|
footer.setText(theme.dim(footerParts.join(" | ")));
|
|
};
|
|
|
|
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
|
|
|
const initialSessionAgentId = (() => {
|
|
if (!initialSessionInput) {
|
|
return null;
|
|
}
|
|
const parsed = parseAgentSessionKey(initialSessionInput);
|
|
return parsed ? normalizeAgentId(parsed.agentId) : null;
|
|
})();
|
|
|
|
const sessionActions = createSessionActions({
|
|
client,
|
|
chatLog,
|
|
tui,
|
|
opts,
|
|
state,
|
|
agentNames,
|
|
initialSessionInput,
|
|
initialSessionAgentId,
|
|
resolveSessionKey,
|
|
updateHeader,
|
|
updateFooter,
|
|
updateAutocompleteProvider,
|
|
setActivityStatus,
|
|
clearLocalRunIds,
|
|
});
|
|
const {
|
|
refreshAgents,
|
|
refreshSessionInfo,
|
|
applySessionInfoFromPatch,
|
|
loadHistory,
|
|
setSession,
|
|
abortActive,
|
|
} = sessionActions;
|
|
|
|
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
|
chatLog,
|
|
tui,
|
|
state,
|
|
setActivityStatus,
|
|
refreshSessionInfo,
|
|
loadHistory,
|
|
isLocalRunId,
|
|
forgetLocalRunId,
|
|
clearLocalRunIds,
|
|
});
|
|
|
|
const requestExit = () => {
|
|
if (exitRequested) {
|
|
return;
|
|
}
|
|
exitRequested = true;
|
|
client.stop();
|
|
stopTuiSafely(() => tui.stop());
|
|
process.exit(0);
|
|
};
|
|
|
|
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
|
|
createCommandHandlers({
|
|
client,
|
|
chatLog,
|
|
tui,
|
|
opts,
|
|
state,
|
|
deliverDefault,
|
|
openOverlay,
|
|
closeOverlay,
|
|
refreshSessionInfo,
|
|
applySessionInfoFromPatch,
|
|
loadHistory,
|
|
setSession,
|
|
refreshAgents,
|
|
abortActive,
|
|
setActivityStatus,
|
|
formatSessionKey,
|
|
noteLocalRunId,
|
|
forgetLocalRunId,
|
|
requestExit,
|
|
});
|
|
|
|
const { runLocalShellLine } = createLocalShellRunner({
|
|
chatLog,
|
|
tui,
|
|
openOverlay,
|
|
closeOverlay,
|
|
});
|
|
updateAutocompleteProvider();
|
|
const submitHandler = createEditorSubmitHandler({
|
|
editor,
|
|
handleCommand,
|
|
sendMessage,
|
|
handleBangLine: runLocalShellLine,
|
|
});
|
|
editor.onSubmit = createSubmitBurstCoalescer({
|
|
submit: submitHandler,
|
|
enabled: shouldEnableWindowsGitBashPasteFallback(),
|
|
});
|
|
|
|
editor.onEscape = () => {
|
|
void abortActive();
|
|
};
|
|
const handleCtrlC = () => {
|
|
const now = Date.now();
|
|
const decision = resolveCtrlCAction({
|
|
hasInput: editor.getText().trim().length > 0,
|
|
now,
|
|
lastCtrlCAt,
|
|
});
|
|
lastCtrlCAt = decision.nextLastCtrlCAt;
|
|
if (decision.action === "clear") {
|
|
editor.setText("");
|
|
setActivityStatus("cleared input; press ctrl+c again to exit");
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
if (decision.action === "exit") {
|
|
requestExit();
|
|
return;
|
|
}
|
|
setActivityStatus("press ctrl+c again to exit");
|
|
tui.requestRender();
|
|
};
|
|
editor.onCtrlC = () => {
|
|
handleCtrlC();
|
|
};
|
|
editor.onCtrlD = () => {
|
|
requestExit();
|
|
};
|
|
editor.onCtrlO = () => {
|
|
toolsExpanded = !toolsExpanded;
|
|
chatLog.setToolsExpanded(toolsExpanded);
|
|
setActivityStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
|
|
tui.requestRender();
|
|
};
|
|
editor.onCtrlL = () => {
|
|
void openModelSelector();
|
|
};
|
|
editor.onCtrlG = () => {
|
|
void openAgentSelector();
|
|
};
|
|
editor.onCtrlP = () => {
|
|
void openSessionSelector();
|
|
};
|
|
editor.onCtrlT = () => {
|
|
showThinking = !showThinking;
|
|
void loadHistory();
|
|
};
|
|
|
|
client.onEvent = (evt) => {
|
|
if (evt.event === "chat") {
|
|
handleChatEvent(evt.payload);
|
|
}
|
|
if (evt.event === "agent") {
|
|
handleAgentEvent(evt.payload);
|
|
}
|
|
};
|
|
|
|
client.onConnected = () => {
|
|
isConnected = true;
|
|
pairingHintShown = false;
|
|
const reconnected = wasDisconnected;
|
|
wasDisconnected = false;
|
|
setConnectionStatus("connected");
|
|
void (async () => {
|
|
await refreshAgents();
|
|
updateHeader();
|
|
await loadHistory();
|
|
setConnectionStatus(reconnected ? "gateway reconnected" : "gateway connected", 4000);
|
|
tui.requestRender();
|
|
if (!autoMessageSent && autoMessage) {
|
|
autoMessageSent = true;
|
|
await sendMessage(autoMessage);
|
|
}
|
|
updateFooter();
|
|
tui.requestRender();
|
|
})();
|
|
};
|
|
|
|
client.onDisconnected = (reason) => {
|
|
isConnected = false;
|
|
wasDisconnected = true;
|
|
historyLoaded = false;
|
|
const disconnectState = resolveGatewayDisconnectState(reason);
|
|
setConnectionStatus(disconnectState.connectionStatus, 5000);
|
|
setActivityStatus(disconnectState.activityStatus);
|
|
if (disconnectState.pairingHint && !pairingHintShown) {
|
|
pairingHintShown = true;
|
|
chatLog.addSystem(disconnectState.pairingHint);
|
|
}
|
|
updateFooter();
|
|
tui.requestRender();
|
|
};
|
|
|
|
client.onGap = (info) => {
|
|
setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000);
|
|
tui.requestRender();
|
|
};
|
|
|
|
updateHeader();
|
|
setConnectionStatus("connecting");
|
|
updateFooter();
|
|
const sigintHandler = () => {
|
|
handleCtrlC();
|
|
};
|
|
const sigtermHandler = () => {
|
|
requestExit();
|
|
};
|
|
process.on("SIGINT", sigintHandler);
|
|
process.on("SIGTERM", sigtermHandler);
|
|
tui.start();
|
|
client.start();
|
|
await new Promise<void>((resolve) => {
|
|
const finish = () => {
|
|
process.removeListener("SIGINT", sigintHandler);
|
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
resolve();
|
|
};
|
|
process.once("exit", finish);
|
|
});
|
|
}
|