refactor(tui): split handlers
This commit is contained in:
441
src/tui/tui-command-handlers.ts
Normal file
441
src/tui/tui-command-handlers.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import type { Component, TUI } from "@mariozechner/pi-tui";
|
||||||
|
import {
|
||||||
|
formatThinkingLevels,
|
||||||
|
normalizeUsageDisplay,
|
||||||
|
} from "../auto-reply/thinking.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
|
import {
|
||||||
|
createSelectList,
|
||||||
|
createSettingsList,
|
||||||
|
} from "./components/selectors.js";
|
||||||
|
import type { GatewayChatClient } from "./gateway-chat.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCommandHandlers(context: CommandHandlerContext) {
|
||||||
|
const {
|
||||||
|
client,
|
||||||
|
chatLog,
|
||||||
|
tui,
|
||||||
|
opts,
|
||||||
|
state,
|
||||||
|
deliverDefault,
|
||||||
|
openOverlay,
|
||||||
|
closeOverlay,
|
||||||
|
refreshSessionInfo,
|
||||||
|
loadHistory,
|
||||||
|
setSession,
|
||||||
|
refreshAgents,
|
||||||
|
abortActive,
|
||||||
|
setActivityStatus,
|
||||||
|
formatSessionKey,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
const setAgent = async (id: string) => {
|
||||||
|
state.currentAgentId = normalizeAgentId(id);
|
||||||
|
await setSession("");
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = createSelectList(items, 9);
|
||||||
|
selector.onSelect = (item) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
model: item.value,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`model set to ${item.value}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
selector.onCancel = () => {
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
openOverlay(selector);
|
||||||
|
tui.requestRender();
|
||||||
|
} 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 = 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: state.currentAgentId,
|
||||||
|
});
|
||||||
|
const items = result.sessions.map((session) => ({
|
||||||
|
value: session.key,
|
||||||
|
label: session.displayName
|
||||||
|
? `${session.displayName} (${formatSessionKey(session.key)})`
|
||||||
|
: formatSessionKey(session.key),
|
||||||
|
description: session.updatedAt
|
||||||
|
? new Date(session.updatedAt).toLocaleString()
|
||||||
|
: "",
|
||||||
|
}));
|
||||||
|
const selector = createSelectList(items, 9);
|
||||||
|
selector.onSelect = (item) => {
|
||||||
|
void (async () => {
|
||||||
|
closeOverlay();
|
||||||
|
await setSession(item.value);
|
||||||
|
tui.requestRender();
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
selector.onCancel = () => {
|
||||||
|
closeOverlay();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
openOverlay(selector);
|
||||||
|
tui.requestRender();
|
||||||
|
} 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 {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
model: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`model set to ${args}`);
|
||||||
|
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 {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
thinkingLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`thinking set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "verbose":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /verbose <on|off>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
verboseLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`verbose set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "reasoning":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /reasoning <on|off>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
reasoningLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`reasoning set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`reasoning failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "cost": {
|
||||||
|
const normalized = args ? normalizeUsageDisplay(args) : undefined;
|
||||||
|
if (args && !normalized) {
|
||||||
|
chatLog.addSystem("usage: /cost <on|off>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const current = state.sessionInfo.responseUsage === "on" ? "on" : "off";
|
||||||
|
const next = normalized ?? (current === "on" ? "off" : "on");
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
responseUsage: next === "off" ? null : next,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(
|
||||||
|
next === "on" ? "usage line enabled" : "usage line disabled",
|
||||||
|
);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`cost failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "elevated":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /elevated <on|off>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
elevatedLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`elevated set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`elevated failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "activation":
|
||||||
|
if (!args) {
|
||||||
|
chatLog.addSystem("usage: /activation <mention|always>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.patchSession({
|
||||||
|
key: state.currentSessionKey,
|
||||||
|
groupActivation: args === "always" ? "always" : "mention",
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`activation set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`activation failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "new":
|
||||||
|
case "reset":
|
||||||
|
try {
|
||||||
|
await client.resetSession(state.currentSessionKey);
|
||||||
|
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
||||||
|
await loadHistory();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "abort":
|
||||||
|
await abortActive();
|
||||||
|
break;
|
||||||
|
case "settings":
|
||||||
|
openSettings();
|
||||||
|
break;
|
||||||
|
case "exit":
|
||||||
|
case "quit":
|
||||||
|
client.stop();
|
||||||
|
tui.stop();
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
chatLog.addSystem(`unknown command: /${name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
try {
|
||||||
|
chatLog.addUser(text);
|
||||||
|
tui.requestRender();
|
||||||
|
setActivityStatus("sending");
|
||||||
|
const { runId } = await client.sendChat({
|
||||||
|
sessionKey: state.currentSessionKey,
|
||||||
|
message: text,
|
||||||
|
thinking: opts.thinking,
|
||||||
|
deliver: deliverDefault,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
state.activeChatRunId = runId;
|
||||||
|
setActivityStatus("waiting");
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||||
|
setActivityStatus("error");
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleCommand,
|
||||||
|
sendMessage,
|
||||||
|
openModelSelector,
|
||||||
|
openAgentSelector,
|
||||||
|
openSessionSelector,
|
||||||
|
openSettings,
|
||||||
|
setAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
113
src/tui/tui-event-handlers.ts
Normal file
113
src/tui/tui-event-handlers.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { TUI } from "@mariozechner/pi-tui";
|
||||||
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
extractTextFromMessage,
|
||||||
|
resolveFinalAssistantText,
|
||||||
|
} from "./tui-formatters.js";
|
||||||
|
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
|
type EventHandlerContext = {
|
||||||
|
chatLog: ChatLog;
|
||||||
|
tui: TUI;
|
||||||
|
state: TuiStateAccess;
|
||||||
|
setActivityStatus: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEventHandlers(context: EventHandlerContext) {
|
||||||
|
const { chatLog, tui, state, setActivityStatus } = context;
|
||||||
|
const finalizedRuns = new Map<string, number>();
|
||||||
|
|
||||||
|
const noteFinalizedRun = (runId: string) => {
|
||||||
|
finalizedRuns.set(runId, Date.now());
|
||||||
|
if (finalizedRuns.size <= 200) return;
|
||||||
|
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||||
|
for (const [key, ts] of finalizedRuns) {
|
||||||
|
if (finalizedRuns.size <= 150) break;
|
||||||
|
if (ts < keepUntil) finalizedRuns.delete(key);
|
||||||
|
}
|
||||||
|
if (finalizedRuns.size > 200) {
|
||||||
|
for (const key of finalizedRuns.keys()) {
|
||||||
|
finalizedRuns.delete(key);
|
||||||
|
if (finalizedRuns.size <= 150) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatEvent = (payload: unknown) => {
|
||||||
|
if (!payload || typeof payload !== "object") return;
|
||||||
|
const evt = payload as ChatEvent;
|
||||||
|
if (evt.sessionKey !== state.currentSessionKey) return;
|
||||||
|
if (finalizedRuns.has(evt.runId)) {
|
||||||
|
if (evt.state === "delta") return;
|
||||||
|
if (evt.state === "final") return;
|
||||||
|
}
|
||||||
|
if (evt.state === "delta") {
|
||||||
|
const text = extractTextFromMessage(evt.message, {
|
||||||
|
includeThinking: state.showThinking,
|
||||||
|
});
|
||||||
|
if (!text) return;
|
||||||
|
chatLog.updateAssistant(text, evt.runId);
|
||||||
|
setActivityStatus("streaming");
|
||||||
|
}
|
||||||
|
if (evt.state === "final") {
|
||||||
|
const text = extractTextFromMessage(evt.message, {
|
||||||
|
includeThinking: state.showThinking,
|
||||||
|
});
|
||||||
|
const finalText = resolveFinalAssistantText({
|
||||||
|
finalText: text,
|
||||||
|
streamedText: chatLog.getStreamingText(evt.runId),
|
||||||
|
});
|
||||||
|
chatLog.finalizeAssistant(finalText, evt.runId);
|
||||||
|
noteFinalizedRun(evt.runId);
|
||||||
|
state.activeChatRunId = null;
|
||||||
|
setActivityStatus("idle");
|
||||||
|
}
|
||||||
|
if (evt.state === "aborted") {
|
||||||
|
chatLog.addSystem("run aborted");
|
||||||
|
state.activeChatRunId = null;
|
||||||
|
setActivityStatus("aborted");
|
||||||
|
}
|
||||||
|
if (evt.state === "error") {
|
||||||
|
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||||
|
state.activeChatRunId = null;
|
||||||
|
setActivityStatus("error");
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentEvent = (payload: unknown) => {
|
||||||
|
if (!payload || typeof payload !== "object") return;
|
||||||
|
const evt = payload as AgentEvent;
|
||||||
|
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
|
||||||
|
if (evt.stream === "tool") {
|
||||||
|
const data = evt.data ?? {};
|
||||||
|
const phase = asString(data.phase, "");
|
||||||
|
const toolCallId = asString(data.toolCallId, "");
|
||||||
|
const toolName = asString(data.name, "tool");
|
||||||
|
if (!toolCallId) return;
|
||||||
|
if (phase === "start") {
|
||||||
|
chatLog.startTool(toolCallId, toolName, data.args);
|
||||||
|
} else if (phase === "update") {
|
||||||
|
chatLog.updateToolResult(toolCallId, data.partialResult, {
|
||||||
|
partial: true,
|
||||||
|
});
|
||||||
|
} else if (phase === "result") {
|
||||||
|
chatLog.updateToolResult(toolCallId, data.result, {
|
||||||
|
isError: Boolean(data.isError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt.stream === "lifecycle") {
|
||||||
|
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
|
||||||
|
if (phase === "start") setActivityStatus("running");
|
||||||
|
if (phase === "end") setActivityStatus("idle");
|
||||||
|
if (phase === "error") setActivityStatus("error");
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleChatEvent, handleAgentEvent };
|
||||||
|
}
|
||||||
89
src/tui/tui-formatters.ts
Normal file
89
src/tui/tui-formatters.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { formatTokenCount } from "../utils/usage-format.js";
|
||||||
|
|
||||||
|
export function resolveFinalAssistantText(params: {
|
||||||
|
finalText?: string | null;
|
||||||
|
streamedText?: string | null;
|
||||||
|
}) {
|
||||||
|
const finalText = params.finalText ?? "";
|
||||||
|
if (finalText.trim()) return finalText;
|
||||||
|
const streamedText = params.streamedText ?? "";
|
||||||
|
if (streamedText.trim()) return streamedText;
|
||||||
|
return "(no output)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextBlocks(
|
||||||
|
content: unknown,
|
||||||
|
opts?: { includeThinking?: boolean },
|
||||||
|
): string {
|
||||||
|
if (typeof content === "string") return content.trim();
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") continue;
|
||||||
|
const record = block as Record<string, unknown>;
|
||||||
|
if (record.type === "text" && typeof record.text === "string") {
|
||||||
|
parts.push(record.text);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
opts?.includeThinking &&
|
||||||
|
record.type === "thinking" &&
|
||||||
|
typeof record.thinking === "string"
|
||||||
|
) {
|
||||||
|
parts.push(`[thinking]\n${record.thinking}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTextFromMessage(
|
||||||
|
message: unknown,
|
||||||
|
opts?: { includeThinking?: boolean },
|
||||||
|
): string {
|
||||||
|
if (!message || typeof message !== "object") return "";
|
||||||
|
const record = message as Record<string, unknown>;
|
||||||
|
return extractTextBlocks(record.content, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokens(total?: number | null, context?: number | null) {
|
||||||
|
if (total == null && context == null) return "tokens ?";
|
||||||
|
const totalLabel = total == null ? "?" : formatTokenCount(total);
|
||||||
|
if (context == null) return `tokens ${totalLabel}`;
|
||||||
|
const pct =
|
||||||
|
typeof total === "number" && context > 0
|
||||||
|
? Math.min(999, Math.round((total / context) * 100))
|
||||||
|
: null;
|
||||||
|
return `tokens ${totalLabel}/${formatTokenCount(context)}${
|
||||||
|
pct !== null ? ` (${pct}%)` : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatContextUsageLine(params: {
|
||||||
|
total?: number | null;
|
||||||
|
context?: number | null;
|
||||||
|
remaining?: number | null;
|
||||||
|
percent?: number | null;
|
||||||
|
}) {
|
||||||
|
const totalLabel =
|
||||||
|
typeof params.total === "number" ? formatTokenCount(params.total) : "?";
|
||||||
|
const ctxLabel =
|
||||||
|
typeof params.context === "number" ? formatTokenCount(params.context) : "?";
|
||||||
|
const pct =
|
||||||
|
typeof params.percent === "number"
|
||||||
|
? Math.min(999, Math.round(params.percent))
|
||||||
|
: null;
|
||||||
|
const remainingLabel =
|
||||||
|
typeof params.remaining === "number"
|
||||||
|
? `${formatTokenCount(params.remaining)} left`
|
||||||
|
: null;
|
||||||
|
const pctLabel = pct !== null ? `${pct}%` : null;
|
||||||
|
const extra = [remainingLabel, pctLabel].filter(Boolean).join(", ");
|
||||||
|
return `tokens ${totalLabel}/${ctxLabel}${extra ? ` (${extra})` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asString(value: unknown, fallback = ""): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
241
src/tui/tui-session-actions.ts
Normal file
241
src/tui/tui-session-actions.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import type { TUI } from "@mariozechner/pi-tui";
|
||||||
|
import {
|
||||||
|
normalizeAgentId,
|
||||||
|
normalizeMainKey,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
|
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
|
||||||
|
import { asString, extractTextFromMessage } from "./tui-formatters.js";
|
||||||
|
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||||
|
|
||||||
|
type SessionActionContext = {
|
||||||
|
client: GatewayChatClient;
|
||||||
|
chatLog: ChatLog;
|
||||||
|
tui: TUI;
|
||||||
|
opts: TuiOptions;
|
||||||
|
state: TuiStateAccess;
|
||||||
|
agentNames: Map<string, string>;
|
||||||
|
initialSessionInput: string;
|
||||||
|
initialSessionAgentId: string | null;
|
||||||
|
resolveSessionKey: (raw?: string) => string;
|
||||||
|
updateHeader: () => void;
|
||||||
|
updateFooter: () => void;
|
||||||
|
updateAutocompleteProvider: () => void;
|
||||||
|
setActivityStatus: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSessionActions(context: SessionActionContext) {
|
||||||
|
const {
|
||||||
|
client,
|
||||||
|
chatLog,
|
||||||
|
tui,
|
||||||
|
opts,
|
||||||
|
state,
|
||||||
|
agentNames,
|
||||||
|
initialSessionInput,
|
||||||
|
initialSessionAgentId,
|
||||||
|
resolveSessionKey,
|
||||||
|
updateHeader,
|
||||||
|
updateFooter,
|
||||||
|
updateAutocompleteProvider,
|
||||||
|
setActivityStatus,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
const applyAgentsResult = (result: GatewayAgentsList) => {
|
||||||
|
state.agentDefaultId = normalizeAgentId(result.defaultId);
|
||||||
|
state.sessionMainKey = normalizeMainKey(result.mainKey);
|
||||||
|
state.sessionScope = result.scope ?? state.sessionScope;
|
||||||
|
state.agents = result.agents.map((agent) => ({
|
||||||
|
id: normalizeAgentId(agent.id),
|
||||||
|
name: agent.name?.trim() || undefined,
|
||||||
|
}));
|
||||||
|
agentNames.clear();
|
||||||
|
for (const agent of state.agents) {
|
||||||
|
if (agent.name) agentNames.set(agent.id, agent.name);
|
||||||
|
}
|
||||||
|
if (!state.initialSessionApplied) {
|
||||||
|
if (initialSessionAgentId) {
|
||||||
|
if (state.agents.some((agent) => agent.id === initialSessionAgentId)) {
|
||||||
|
state.currentAgentId = initialSessionAgentId;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
!state.agents.some((agent) => agent.id === state.currentAgentId)
|
||||||
|
) {
|
||||||
|
state.currentAgentId =
|
||||||
|
state.agents[0]?.id ??
|
||||||
|
normalizeAgentId(result.defaultId ?? state.currentAgentId);
|
||||||
|
}
|
||||||
|
const nextSessionKey = resolveSessionKey(initialSessionInput);
|
||||||
|
if (nextSessionKey !== state.currentSessionKey) {
|
||||||
|
state.currentSessionKey = nextSessionKey;
|
||||||
|
}
|
||||||
|
state.initialSessionApplied = true;
|
||||||
|
} else if (
|
||||||
|
!state.agents.some((agent) => agent.id === state.currentAgentId)
|
||||||
|
) {
|
||||||
|
state.currentAgentId =
|
||||||
|
state.agents[0]?.id ??
|
||||||
|
normalizeAgentId(result.defaultId ?? state.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 !== state.currentAgentId) {
|
||||||
|
state.currentAgentId = next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSessionInfo = async () => {
|
||||||
|
try {
|
||||||
|
const listAgentId =
|
||||||
|
state.currentSessionKey === "global" ||
|
||||||
|
state.currentSessionKey === "unknown"
|
||||||
|
? undefined
|
||||||
|
: state.currentAgentId;
|
||||||
|
const result = await client.listSessions({
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
agentId: listAgentId,
|
||||||
|
});
|
||||||
|
const entry = result.sessions.find((row) => {
|
||||||
|
// Exact match
|
||||||
|
if (row.key === state.currentSessionKey) return true;
|
||||||
|
// Also match canonical keys like "agent:default:main" against "main"
|
||||||
|
const parsed = parseAgentSessionKey(row.key);
|
||||||
|
return parsed?.rest === state.currentSessionKey;
|
||||||
|
});
|
||||||
|
state.sessionInfo = {
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
reasoningLevel: entry?.reasoningLevel,
|
||||||
|
model: entry?.model ?? result.defaults?.model ?? undefined,
|
||||||
|
modelProvider: entry?.modelProvider,
|
||||||
|
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
|
||||||
|
inputTokens: entry?.inputTokens ?? null,
|
||||||
|
outputTokens: entry?.outputTokens ?? null,
|
||||||
|
totalTokens: entry?.totalTokens ?? null,
|
||||||
|
responseUsage: entry?.responseUsage,
|
||||||
|
updatedAt: entry?.updatedAt ?? null,
|
||||||
|
displayName: entry?.displayName,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
updateAutocompleteProvider();
|
||||||
|
updateFooter();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
try {
|
||||||
|
const history = await client.loadHistory({
|
||||||
|
sessionKey: state.currentSessionKey,
|
||||||
|
limit: opts.historyLimit ?? 200,
|
||||||
|
});
|
||||||
|
const record = history as {
|
||||||
|
messages?: unknown[];
|
||||||
|
sessionId?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
};
|
||||||
|
state.currentSessionId =
|
||||||
|
typeof record.sessionId === "string" ? record.sessionId : null;
|
||||||
|
state.sessionInfo.thinkingLevel =
|
||||||
|
record.thinkingLevel ?? state.sessionInfo.thinkingLevel;
|
||||||
|
chatLog.clearAll();
|
||||||
|
chatLog.addSystem(`session ${state.currentSessionKey}`);
|
||||||
|
for (const entry of record.messages ?? []) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
const message = entry as Record<string, unknown>;
|
||||||
|
if (message.role === "user") {
|
||||||
|
const text = extractTextFromMessage(message);
|
||||||
|
if (text) chatLog.addUser(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const text = extractTextFromMessage(message, {
|
||||||
|
includeThinking: state.showThinking,
|
||||||
|
});
|
||||||
|
if (text) chatLog.finalizeAssistant(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.role === "toolResult") {
|
||||||
|
const toolCallId = asString(message.toolCallId, "");
|
||||||
|
const toolName = asString(message.toolName, "tool");
|
||||||
|
const component = chatLog.startTool(toolCallId, toolName, {});
|
||||||
|
component.setResult(
|
||||||
|
{
|
||||||
|
content: Array.isArray(message.content)
|
||||||
|
? (message.content as Record<string, unknown>[])
|
||||||
|
: [],
|
||||||
|
details:
|
||||||
|
typeof message.details === "object" && message.details
|
||||||
|
? (message.details as Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ isError: Boolean(message.isError) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.historyLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`history failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
await refreshSessionInfo();
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSession = async (rawKey: string) => {
|
||||||
|
const nextKey = resolveSessionKey(rawKey);
|
||||||
|
updateAgentFromSessionKey(nextKey);
|
||||||
|
state.currentSessionKey = nextKey;
|
||||||
|
state.activeChatRunId = null;
|
||||||
|
state.currentSessionId = null;
|
||||||
|
state.historyLoaded = false;
|
||||||
|
updateHeader();
|
||||||
|
updateFooter();
|
||||||
|
await loadHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortActive = async () => {
|
||||||
|
if (!state.activeChatRunId) {
|
||||||
|
chatLog.addSystem("no active run");
|
||||||
|
tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.abortChat({
|
||||||
|
sessionKey: state.currentSessionKey,
|
||||||
|
runId: state.activeChatRunId,
|
||||||
|
});
|
||||||
|
setActivityStatus("aborted");
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`abort failed: ${String(err)}`);
|
||||||
|
setActivityStatus("abort failed");
|
||||||
|
}
|
||||||
|
tui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyAgentsResult,
|
||||||
|
refreshAgents,
|
||||||
|
refreshSessionInfo,
|
||||||
|
loadHistory,
|
||||||
|
setSession,
|
||||||
|
abortActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
src/tui/tui-status-summary.ts
Normal file
85
src/tui/tui-status-summary.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { formatAge } from "../infra/channel-summary.js";
|
||||||
|
import { formatTokenCount } from "../utils/usage-format.js";
|
||||||
|
import { formatContextUsageLine } from "./tui-formatters.js";
|
||||||
|
import type { GatewayStatusSummary } from "./tui-types.js";
|
||||||
|
|
||||||
|
export function formatStatusSummary(summary: GatewayStatusSummary) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("Gateway status");
|
||||||
|
|
||||||
|
if (!summary.linkProvider) {
|
||||||
|
lines.push("Link provider: unknown");
|
||||||
|
} else {
|
||||||
|
const linkLabel = summary.linkProvider.label ?? "Link provider";
|
||||||
|
const linked = summary.linkProvider.linked === true;
|
||||||
|
const authAge =
|
||||||
|
linked && typeof summary.linkProvider.authAgeMs === "number"
|
||||||
|
? ` (last refreshed ${formatAge(summary.linkProvider.authAgeMs)})`
|
||||||
|
: "";
|
||||||
|
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerSummary = Array.isArray(summary.providerSummary)
|
||||||
|
? summary.providerSummary
|
||||||
|
: [];
|
||||||
|
if (providerSummary.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("System:");
|
||||||
|
for (const line of providerSummary) {
|
||||||
|
lines.push(` ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof summary.heartbeatSeconds === "number") {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Heartbeat: ${summary.heartbeatSeconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionPath = summary.sessions?.path;
|
||||||
|
if (sessionPath) lines.push(`Session store: ${sessionPath}`);
|
||||||
|
|
||||||
|
const defaults = summary.sessions?.defaults;
|
||||||
|
const defaultModel = defaults?.model ?? "unknown";
|
||||||
|
const defaultCtx =
|
||||||
|
typeof defaults?.contextTokens === "number"
|
||||||
|
? ` (${formatTokenCount(defaults.contextTokens)} ctx)`
|
||||||
|
: "";
|
||||||
|
lines.push(`Default model: ${defaultModel}${defaultCtx}`);
|
||||||
|
|
||||||
|
const sessionCount = summary.sessions?.count ?? 0;
|
||||||
|
lines.push(`Active sessions: ${sessionCount}`);
|
||||||
|
|
||||||
|
const recent = Array.isArray(summary.sessions?.recent)
|
||||||
|
? summary.sessions?.recent
|
||||||
|
: [];
|
||||||
|
if (recent.length > 0) {
|
||||||
|
lines.push("Recent sessions:");
|
||||||
|
for (const entry of recent) {
|
||||||
|
const ageLabel =
|
||||||
|
typeof entry.age === "number" ? formatAge(entry.age) : "no activity";
|
||||||
|
const model = entry.model ?? "unknown";
|
||||||
|
const usage = formatContextUsageLine({
|
||||||
|
total: entry.totalTokens ?? null,
|
||||||
|
context: entry.contextTokens ?? null,
|
||||||
|
remaining: entry.remainingTokens ?? null,
|
||||||
|
percent: entry.percentUsed ?? null,
|
||||||
|
});
|
||||||
|
const flags = entry.flags?.length
|
||||||
|
? ` | flags: ${entry.flags.join(", ")}`
|
||||||
|
: "";
|
||||||
|
lines.push(
|
||||||
|
`- ${entry.key}${entry.kind ? ` [${entry.kind}]` : ""} | ${ageLabel} | model ${model} | ${usage}${flags}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queued = Array.isArray(summary.queuedSystemEvents)
|
||||||
|
? summary.queuedSystemEvents
|
||||||
|
: [];
|
||||||
|
if (queued.length > 0) {
|
||||||
|
const preview = queued.slice(0, 3).join(" | ");
|
||||||
|
lines.push(`Queued system events (${queued.length}): ${preview}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
97
src/tui/tui-types.ts
Normal file
97
src/tui/tui-types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
export type TuiOptions = {
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
password?: string;
|
||||||
|
session?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
thinking?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
historyLimit?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatEvent = {
|
||||||
|
runId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
state: "delta" | "final" | "aborted" | "error";
|
||||||
|
message?: unknown;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentEvent = {
|
||||||
|
runId: string;
|
||||||
|
stream: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionInfo = {
|
||||||
|
thinkingLevel?: string;
|
||||||
|
verboseLevel?: string;
|
||||||
|
reasoningLevel?: string;
|
||||||
|
model?: string;
|
||||||
|
modelProvider?: string;
|
||||||
|
contextTokens?: number | null;
|
||||||
|
inputTokens?: number | null;
|
||||||
|
outputTokens?: number | null;
|
||||||
|
totalTokens?: number | null;
|
||||||
|
responseUsage?: "on" | "off";
|
||||||
|
updatedAt?: number | null;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
|
export type AgentSummary = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayStatusSummary = {
|
||||||
|
linkProvider?: {
|
||||||
|
label?: string;
|
||||||
|
linked?: boolean;
|
||||||
|
authAgeMs?: number | null;
|
||||||
|
};
|
||||||
|
heartbeatSeconds?: number;
|
||||||
|
providerSummary?: string[];
|
||||||
|
queuedSystemEvents?: string[];
|
||||||
|
sessions?: {
|
||||||
|
path?: string;
|
||||||
|
count?: number;
|
||||||
|
defaults?: { model?: string | null; contextTokens?: number | null };
|
||||||
|
recent?: Array<{
|
||||||
|
key: string;
|
||||||
|
kind?: string;
|
||||||
|
updatedAt?: number | null;
|
||||||
|
age?: number | null;
|
||||||
|
model?: string | null;
|
||||||
|
totalTokens?: number | null;
|
||||||
|
contextTokens?: number | null;
|
||||||
|
remainingTokens?: number | null;
|
||||||
|
percentUsed?: number | null;
|
||||||
|
flags?: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TuiStateAccess = {
|
||||||
|
agentDefaultId: string;
|
||||||
|
sessionMainKey: string;
|
||||||
|
sessionScope: SessionScope;
|
||||||
|
agents: AgentSummary[];
|
||||||
|
currentAgentId: string;
|
||||||
|
currentSessionKey: string;
|
||||||
|
currentSessionId: string | null;
|
||||||
|
activeChatRunId: string | null;
|
||||||
|
historyLoaded: boolean;
|
||||||
|
sessionInfo: SessionInfo;
|
||||||
|
initialSessionApplied: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
autoMessageSent: boolean;
|
||||||
|
toolsExpanded: boolean;
|
||||||
|
showThinking: boolean;
|
||||||
|
connectionStatus: string;
|
||||||
|
activityStatus: string;
|
||||||
|
statusTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
lastCtrlCAt: number;
|
||||||
|
};
|
||||||
1082
src/tui/tui.ts
1082
src/tui/tui.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user