docs: add mtui to changelog

This commit is contained in:
Vj 2026-01-30 06:19:17 +00:00
parent 8232c857dc
commit 6fbe8b2b46
9 changed files with 112 additions and 215 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.molt.bot
Status: beta. Status: beta.
### Highlights ### Highlights
- CLI: add modern TUI (mtui) using React and Ink. (#4419) Thanks @M1Vj.
- Rebrand: rename the npm package/CLI to `moltbot`, keep a `moltbot` compatibility shim, move extensions to the `@moltbot/*` scope, and update bot.molt bundle IDs/labels/logging subsystems. Thanks @thewilloftheshadow. - Rebrand: rename the npm package/CLI to `moltbot`, keep a `moltbot` compatibility shim, move extensions to the `@moltbot/*` scope, and update bot.molt bundle IDs/labels/logging subsystems. Thanks @thewilloftheshadow.
- New channels/plugins: Twitch plugin; Google Chat (beta) with Workspace Add-on events + typing indicator. (#1612, #1635) Thanks @tyler6204, @iHildy. - New channels/plugins: Twitch plugin; Google Chat (beta) with Workspace Add-on events + typing indicator. (#1612, #1635) Thanks @tyler6204, @iHildy.
- Security hardening: gateway auth defaults required, hook token query-param deprecation, Windows ACL audits, mDNS minimal discovery, and SSH target option injection fix. (#4001, #2016, #1957, #1882, #2200) - Security hardening: gateway auth defaults required, hook token query-param deprecation, Windows ACL audits, mDNS minimal discovery, and SSH target option injection fix. (#4001, #2016, #1957, #1882, #2200)

View File

@ -19,27 +19,25 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => {
const { exit } = useApp(); const { exit } = useApp();
const gateway = useGateway(); const gateway = useGateway();
const { showThinking, setShowThinking } = useSettings(); const { showThinking, setShowThinking } = useSettings();
const [connectionStatus, setConnectionStatus] = useState< const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
"connecting" | "connected" | "disconnected"
>("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [overlay, setOverlay] = useState<{ type: "model" | "agent"; items: any[] } | null>(null); const [overlay, setOverlay] = useState<{ type: "model" | "agent"; items: any[] } | null>(null);
const { const {
messages, messages,
status, status,
sendMessage, sendMessage,
addMessage, addMessage,
sessionInfo, sessionInfo,
sessionKey, sessionKey,
refreshSessionInfo, refreshSessionInfo,
loadHistory, loadHistory
} = useChat(options.session || "main"); } = useChat(options.session || "main");
const { handleLocalShell, handleSlashCommand } = useCommands( const { handleLocalShell, handleSlashCommand } = useCommands(
sessionKey, sessionKey,
addMessage, addMessage,
refreshSessionInfo, refreshSessionInfo
); );
useEffect(() => { useEffect(() => {
@ -67,12 +65,9 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => {
} else if (value.startsWith("/")) { } else if (value.startsWith("/")) {
if (value === "/model") { if (value === "/model") {
const models = await gateway.listModels(); const models = await gateway.listModels();
setOverlay({ setOverlay({
type: "model", type: "model",
items: models.map((m) => ({ items: models.map(m => ({ label: `${m.provider}/${m.id}`, value: `${m.provider}/${m.id}` }))
label: `${m.provider}/${m.id}`,
value: `${m.provider}/${m.id}`,
})),
}); });
} else { } else {
await handleSlashCommand(value); await handleSlashCommand(value);
@ -98,48 +93,31 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => {
total: sessionInfo.totalTokens, total: sessionInfo.totalTokens,
context: sessionInfo.contextTokens, context: sessionInfo.contextTokens,
remaining: (sessionInfo.contextTokens ?? 0) - (sessionInfo.totalTokens ?? 0), remaining: (sessionInfo.contextTokens ?? 0) - (sessionInfo.totalTokens ?? 0),
percent: percent: sessionInfo.totalTokens && sessionInfo.contextTokens ? (sessionInfo.totalTokens / sessionInfo.contextTokens) * 100 : null
sessionInfo.totalTokens && sessionInfo.contextTokens
? (sessionInfo.totalTokens / sessionInfo.contextTokens) * 100
: null,
}); });
return ( return (
<Box flexDirection="column" padding={1} minHeight={20}> <Box flexDirection="column" padding={1} minHeight={20}>
<Box borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}> <Box borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
<Text bold color="white"> <Text bold color="white">Moltbot MTUI</Text>
Moltbot MTUI
</Text>
<Box flexGrow={1} /> <Box flexGrow={1} />
<Box paddingX={2}> <Box paddingX={2}>
<Text dimColor>{sessionInfo.model || "no model"}</Text> <Text dimColor>{sessionInfo.model || "no model"}</Text>
</Box> </Box>
<Text <Text color={connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red"}>
color={
connectionStatus === "connected"
? "green"
: connectionStatus === "connecting"
? "yellow"
: "red"
}
>
{connectionStatus === "connecting" && <Spinner type="dots" />} {connectionStatus} {connectionStatus === "connecting" && <Spinner type="dots" />} {connectionStatus}
</Text> </Text>
</Box> </Box>
{overlay ? ( {overlay ? (
<Box flexGrow={1} justifyContent="center" alignItems="center"> <Box flexGrow={1} justifyContent="center" alignItems="center">
<Selector <Selector
title={`Select ${overlay.type}`} title={`Select ${overlay.type}`}
items={overlay.items} items={overlay.items}
onSelect={async (item) => { onSelect={async (item) => {
if (overlay.type === "model") { if (overlay.type === "model") {
await gateway.patchSession({ key: sessionKey, model: item.value }); await gateway.patchSession({ key: sessionKey, model: item.value });
addMessage({ addMessage({ id: Math.random().toString(), role: "system", content: `Model set to ${item.value}` });
id: Math.random().toString(),
role: "system",
content: `Model set to ${item.value}`,
});
await refreshSessionInfo(); await refreshSessionInfo();
} }
setOverlay(null); setOverlay(null);
@ -159,9 +137,7 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => {
{status === "running" && !overlay && ( {status === "running" && !overlay && (
<Box paddingX={1} marginBottom={1}> <Box paddingX={1} marginBottom={1}>
<Text color="yellow"> <Text color="yellow"><Spinner type="dots" /> Assistant is thinking...</Text>
<Spinner type="dots" /> Assistant is thinking...
</Text>
</Box> </Box>
)} )}
@ -178,7 +154,7 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => {
</Box> </Box>
<InputBar onSubmit={handleSubmit} status={status} /> <InputBar onSubmit={handleSubmit} status={status} />
<Box paddingX={1} marginTop={1}> <Box paddingX={1} marginTop={1}>
<Text dimColor>Ctrl+C exit | Ctrl+T think | /model | /reset | !ls</Text> <Text dimColor>Ctrl+C exit | Ctrl+T think | /model | /reset | !ls</Text>
</Box> </Box>

View File

@ -19,9 +19,7 @@ export const InputBar: React.FC<InputBarProps> = ({ onSubmit, status }) => {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Box paddingX={1} borderStyle="single" borderColor={status === "idle" ? "cyan" : "yellow"}> <Box paddingX={1} borderStyle="single" borderColor={status === "idle" ? "cyan" : "yellow"}>
<Text bold color="cyan"> <Text bold color="cyan">moltbot {">"} </Text>
moltbot {">"}{" "}
</Text>
<TextInput <TextInput
value={value} value={value}
onChange={setValue} onChange={setValue}

View File

@ -30,37 +30,25 @@ export const MessageView: React.FC<MessageViewProps> = ({ message }) => {
</Box> </Box>
{showThinking && ( {showThinking && (
<Box paddingLeft={2} borderStyle="single" borderColor="gray"> <Box paddingLeft={2} borderStyle="single" borderColor="gray">
<Text italic dimColor> <Text italic dimColor>{message.thinking}</Text>
{message.thinking}
</Text>
</Box> </Box>
)} )}
</Box> </Box>
)} )}
{renderContent(message.content)} {renderContent(message.content)}
{message.tools && message.tools.length > 0 && ( {message.tools && message.tools.length > 0 && (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
{message.tools.map((tool) => ( {message.tools.map((tool) => (
<Box <Box key={tool.id} flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
key={tool.id} <Text bold color="cyan">Tool: {tool.name}</Text>
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={1}
marginBottom={1}
>
<Text bold color="cyan">
Tool: {tool.name}
</Text>
<Text dimColor>Args: {JSON.stringify(tool.args)}</Text> <Text dimColor>Args: {JSON.stringify(tool.args)}</Text>
{tool.isStreaming && <Text color="yellow">Running...</Text>} {tool.isStreaming && <Text color="yellow">Running...</Text>}
{tool.result && ( {tool.result && (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={tool.isError ? "red" : "gray"}> <Text color={tool.isError ? "red" : "gray"}>
Result:{" "} Result: {typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)}
{typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)}
</Text> </Text>
</Box> </Box>
)} )}

View File

@ -17,10 +17,9 @@ type SelectorProps = {
export const Selector: React.FC<SelectorProps> = ({ title, items, onSelect, onCancel }) => { export const Selector: React.FC<SelectorProps> = ({ title, items, onSelect, onCancel }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const filteredItems = items.filter( const filteredItems = items.filter(item =>
(item) => item.label.toLowerCase().includes(query.toLowerCase()) ||
item.label.toLowerCase().includes(query.toLowerCase()) || item.value.toLowerCase().includes(query.toLowerCase())
item.value.toLowerCase().includes(query.toLowerCase()),
); );
useInput((input, key) => { useInput((input, key) => {
@ -28,10 +27,10 @@ export const Selector: React.FC<SelectorProps> = ({ title, items, onSelect, onCa
onCancel(); onCancel();
} }
if (!key.ctrl && !key.meta && input.length === 1 && !key.return) { if (!key.ctrl && !key.meta && input.length === 1 && !key.return) {
setQuery((q) => q + input); setQuery(q => q + input);
} }
if (key.backspace || key.delete) { if (key.backspace || key.delete) {
setQuery((q) => q.slice(0, -1)); setQuery(q => q.slice(0, -1));
} }
}); });

View File

@ -4,13 +4,14 @@ import type { TuiOptions } from "../../tui/tui-types.js";
const GatewayContext = createContext<GatewayChatClient | null>(null); const GatewayContext = createContext<GatewayChatClient | null>(null);
export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({ export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({ options, children }) => {
options,
children,
}) => {
const client = useMemo(() => new GatewayChatClient(options), [options]); const client = useMemo(() => new GatewayChatClient(options), [options]);
return <GatewayContext.Provider value={client}>{children}</GatewayContext.Provider>; return (
<GatewayContext.Provider value={client}>
{children}
</GatewayContext.Provider>
);
}; };
export const useGateway = () => { export const useGateway = () => {

View File

@ -14,9 +14,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [toolsExpanded, setToolsExpanded] = useState(false); const [toolsExpanded, setToolsExpanded] = useState(false);
return ( return (
<SettingsContext.Provider <SettingsContext.Provider value={{ showThinking, setShowThinking, toolsExpanded, setToolsExpanded }}>
value={{ showThinking, setShowThinking, toolsExpanded, setToolsExpanded }}
>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
); );

View File

@ -32,7 +32,7 @@ export const useChat = (initialSessionKey: string) => {
const refreshSessionInfo = useCallback(async () => { const refreshSessionInfo = useCallback(async () => {
try { try {
const statusRes = (await gateway.getStatus()) as any; const statusRes = await gateway.getStatus() as any;
if (statusRes?.sessions?.recent) { if (statusRes?.sessions?.recent) {
const current = statusRes.sessions.recent.find((s: any) => s.key === sessionKey); const current = statusRes.sessions.recent.find((s: any) => s.key === sessionKey);
if (current) { if (current) {
@ -61,23 +61,15 @@ export const useChat = (initialSessionKey: string) => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last && last.id === payload.runId) { if (last && last.id === payload.runId) {
return [ return [
...prev.slice(0, -1), ...prev.slice(0, -1),
{ {
...last, ...last,
content: last.content + (content || ""), content: last.content + (content || ""),
thinking: last.thinking + (thinking || ""), thinking: last.thinking + (thinking || "")
}, }
]; ];
} }
return [ return [...prev, { id: payload.runId, role: "assistant", content: content || "", thinking: thinking || "" }];
...prev,
{
id: payload.runId,
role: "assistant",
content: content || "",
thinking: thinking || "",
},
];
}); });
setStatus("streaming"); setStatus("streaming");
} else if (payload.state === "final") { } else if (payload.state === "final") {
@ -89,25 +81,16 @@ export const useChat = (initialSessionKey: string) => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last && last.id === payload.runId) { if (last && last.id === payload.runId) {
return [ return [
...prev.slice(0, -1), ...prev.slice(0, -1),
{ {
...last, ...last,
content: content || last.content, content: content || last.content,
thinking: thinking || last.thinking, thinking: thinking || last.thinking,
isFinal: true, isFinal: true
}, }
]; ];
} }
return [ return [...prev, { id: payload.runId, role: "assistant", content: content || "", thinking: thinking || "", isFinal: true }];
...prev,
{
id: payload.runId,
role: "assistant",
content: content || "",
thinking: thinking || "",
isFinal: true,
},
];
}); });
setActiveRunId(null); setActiveRunId(null);
setStatus("idle"); setStatus("idle");
@ -122,33 +105,22 @@ export const useChat = (initialSessionKey: string) => {
if (payload.stream === "tool") { if (payload.stream === "tool") {
const data = payload.data as any; const data = payload.data as any;
const { phase, toolCallId, name: toolName } = data; const { phase, toolCallId, name: toolName } = data;
setMessages((prev) => { setMessages((prev) => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (!last || last.role !== "assistant") return prev; if (!last || last.role !== "assistant") return prev;
const tools = last.tools || []; const tools = last.tools || [];
let nextTools = [...tools]; let nextTools = [...tools];
if (phase === "start") { if (phase === "start") {
nextTools.push({ nextTools.push({ id: toolCallId, name: toolName, args: data.args, isStreaming: true });
id: toolCallId,
name: toolName,
args: data.args,
isStreaming: true,
});
} else if (phase === "update") { } else if (phase === "update") {
nextTools = nextTools.map((t) => nextTools = nextTools.map(t => t.id === toolCallId ? { ...t, result: data.partialResult } : t);
t.id === toolCallId ? { ...t, result: data.partialResult } : t,
);
} else if (phase === "result") { } else if (phase === "result") {
nextTools = nextTools.map((t) => nextTools = nextTools.map(t => t.id === toolCallId ? { ...t, result: data.result, isError: data.isError, isStreaming: false } : t);
t.id === toolCallId
? { ...t, result: data.result, isError: data.isError, isStreaming: false }
: t,
);
} }
return [...prev.slice(0, -1), { ...last, tools: nextTools }]; return [...prev.slice(0, -1), { ...last, tools: nextTools }];
}); });
} }
@ -164,14 +136,14 @@ export const useChat = (initialSessionKey: string) => {
const loadHistory = useCallback(async () => { const loadHistory = useCallback(async () => {
try { try {
const history = (await gateway.loadHistory({ sessionKey, limit: 100 })) as any; const history = await gateway.loadHistory({ sessionKey, limit: 100 }) as any;
if (Array.isArray(history?.messages)) { if (Array.isArray(history?.messages)) {
const msgs = history.messages.map((m: any) => ({ const msgs = history.messages.map((m: any) => ({
id: m.id || Math.random().toString(), id: m.id || Math.random().toString(),
role: m.role, role: m.role,
content: extractContentFromMessage(m) || "", content: extractContentFromMessage(m) || "",
thinking: extractThinkingFromMessage(m) || "", thinking: extractThinkingFromMessage(m) || "",
isFinal: true, isFinal: true
})); }));
setMessages(msgs); setMessages(msgs);
} }
@ -180,26 +152,12 @@ export const useChat = (initialSessionKey: string) => {
} }
}, [gateway, sessionKey]); }, [gateway, sessionKey]);
const sendMessage = useCallback( const sendMessage = useCallback(async (text: string) => {
async (text: string) => { addMessage({ id: Math.random().toString(), role: "user", content: text });
addMessage({ id: Math.random().toString(), role: "user", content: text }); setStatus("running");
setStatus("running"); const { runId } = await gateway.sendChat({ sessionKey, message: text });
const { runId } = await gateway.sendChat({ sessionKey, message: text }); setActiveRunId(runId);
setActiveRunId(runId); }, [gateway, sessionKey, addMessage]);
},
[gateway, sessionKey, addMessage],
);
return { return { messages, status, sendMessage, addMessage, sessionInfo, sessionKey, setSessionKey, refreshSessionInfo, loadHistory, activeRunId };
messages,
status,
sendMessage,
addMessage,
sessionInfo,
sessionKey,
setSessionKey,
refreshSessionInfo,
loadHistory,
activeRunId,
};
}; };

View File

@ -6,71 +6,49 @@ import { spawn } from "node:child_process";
export const useCommands = ( export const useCommands = (
sessionKey: string, sessionKey: string,
addMessage: (msg: Message) => void, addMessage: (msg: Message) => void,
refreshSessionInfo: () => Promise<void>, refreshSessionInfo: () => Promise<void>
) => { ) => {
const gateway = useGateway(); const gateway = useGateway();
const handleLocalShell = useCallback( const handleLocalShell = useCallback(async (line: string) => {
async (line: string) => { const cmd = line.slice(1);
const cmd = line.slice(1); addMessage({ id: Math.random().toString(), role: "system", content: `[local] $ ${cmd}` });
addMessage({ id: Math.random().toString(), role: "system", content: `[local] $ ${cmd}` });
return new Promise<void>((resolve) => {
return new Promise<void>((resolve) => { const child = spawn(cmd, { shell: true });
const child = spawn(cmd, { shell: true }); let output = "";
let output = "";
child.stdout.on("data", (data) => { output += data.toString(); });
child.stdout.on("data", (data) => { child.stderr.on("data", (data) => { output += data.toString(); });
output += data.toString();
}); child.on("close", (code) => {
child.stderr.on("data", (data) => { addMessage({ id: Math.random().toString(), role: "system", content: output.trim() || `Exit code: ${code}` });
output += data.toString(); resolve();
});
child.on("close", (code) => {
addMessage({
id: Math.random().toString(),
role: "system",
content: output.trim() || `Exit code: ${code}`,
});
resolve();
});
}); });
}, });
[addMessage], }, [addMessage]);
);
const handleSlashCommand = useCallback( const handleSlashCommand = useCallback(async (text: string) => {
async (text: string) => { const parts = text.slice(1).split(" ");
const parts = text.slice(1).split(" "); const command = parts[0];
const command = parts[0]; const args = parts.slice(1).join(" ");
const args = parts.slice(1).join(" ");
switch (command) { switch (command) {
case "reset": case "reset":
await gateway.resetSession(sessionKey); await gateway.resetSession(sessionKey);
addMessage({ id: Math.random().toString(), role: "system", content: "Session reset." }); addMessage({ id: Math.random().toString(), role: "system", content: "Session reset." });
break; break;
case "model": case "model":
if (args) { if (args) {
await gateway.patchSession({ key: sessionKey, model: args }); await gateway.patchSession({ key: sessionKey, model: args });
addMessage({ addMessage({ id: Math.random().toString(), role: "system", content: `Model set to ${args}` });
id: Math.random().toString(), await refreshSessionInfo();
role: "system", }
content: `Model set to ${args}`, break;
}); default:
await refreshSessionInfo(); addMessage({ id: Math.random().toString(), role: "system", content: `Unknown command: /${command}` });
} }
break; }, [gateway, sessionKey, addMessage, refreshSessionInfo]);
default:
addMessage({
id: Math.random().toString(),
role: "system",
content: `Unknown command: /${command}`,
});
}
},
[gateway, sessionKey, addMessage, refreshSessionInfo],
);
return { handleLocalShell, handleSlashCommand }; return { handleLocalShell, handleSlashCommand };
}; };