diff --git a/CHANGELOG.md b/CHANGELOG.md index a134359f5..a27722a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### 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. - 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) diff --git a/src/mtui/App.tsx b/src/mtui/App.tsx index 710a576dc..e2d991843 100644 --- a/src/mtui/App.tsx +++ b/src/mtui/App.tsx @@ -19,27 +19,25 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { const { exit } = useApp(); const gateway = useGateway(); const { showThinking, setShowThinking } = useSettings(); - const [connectionStatus, setConnectionStatus] = useState< - "connecting" | "connected" | "disconnected" - >("connecting"); + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting"); const [error, setError] = useState(null); const [overlay, setOverlay] = useState<{ type: "model" | "agent"; items: any[] } | null>(null); - - const { - messages, - status, - sendMessage, - addMessage, - sessionInfo, - sessionKey, + + const { + messages, + status, + sendMessage, + addMessage, + sessionInfo, + sessionKey, refreshSessionInfo, - loadHistory, + loadHistory } = useChat(options.session || "main"); const { handleLocalShell, handleSlashCommand } = useCommands( - sessionKey, - addMessage, - refreshSessionInfo, + sessionKey, + addMessage, + refreshSessionInfo ); useEffect(() => { @@ -67,12 +65,9 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { } else if (value.startsWith("/")) { if (value === "/model") { const models = await gateway.listModels(); - setOverlay({ - type: "model", - items: models.map((m) => ({ - label: `${m.provider}/${m.id}`, - value: `${m.provider}/${m.id}`, - })), + setOverlay({ + type: "model", + items: models.map(m => ({ label: `${m.provider}/${m.id}`, value: `${m.provider}/${m.id}` })) }); } else { await handleSlashCommand(value); @@ -98,48 +93,31 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { total: sessionInfo.totalTokens, context: sessionInfo.contextTokens, remaining: (sessionInfo.contextTokens ?? 0) - (sessionInfo.totalTokens ?? 0), - percent: - sessionInfo.totalTokens && sessionInfo.contextTokens - ? (sessionInfo.totalTokens / sessionInfo.contextTokens) * 100 - : null, + percent: sessionInfo.totalTokens && sessionInfo.contextTokens ? (sessionInfo.totalTokens / sessionInfo.contextTokens) * 100 : null }); return ( - - Moltbot MTUI - + Moltbot MTUI {sessionInfo.model || "no model"} - + {connectionStatus === "connecting" && } {connectionStatus} {overlay ? ( - { if (overlay.type === "model") { await gateway.patchSession({ key: sessionKey, model: item.value }); - addMessage({ - id: Math.random().toString(), - role: "system", - content: `Model set to ${item.value}`, - }); + addMessage({ id: Math.random().toString(), role: "system", content: `Model set to ${item.value}` }); await refreshSessionInfo(); } setOverlay(null); @@ -159,9 +137,7 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { {status === "running" && !overlay && ( - - Assistant is thinking... - + Assistant is thinking... )} @@ -178,7 +154,7 @@ const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { - + Ctrl+C exit | Ctrl+T think | /model | /reset | !ls diff --git a/src/mtui/components/InputBar.tsx b/src/mtui/components/InputBar.tsx index 66cc6db3a..c13bb0da3 100644 --- a/src/mtui/components/InputBar.tsx +++ b/src/mtui/components/InputBar.tsx @@ -19,9 +19,7 @@ export const InputBar: React.FC = ({ onSubmit, status }) => { return ( - - moltbot {">"}{" "} - + moltbot {">"} = ({ message }) => { {showThinking && ( - - {message.thinking} - + {message.thinking} )} )} - + {renderContent(message.content)} - + {message.tools && message.tools.length > 0 && ( {message.tools.map((tool) => ( - - - Tool: {tool.name} - + + Tool: {tool.name} Args: {JSON.stringify(tool.args)} {tool.isStreaming && Running...} {tool.result && ( - Result:{" "} - {typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)} + Result: {typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)} )} diff --git a/src/mtui/components/Selector.tsx b/src/mtui/components/Selector.tsx index 29f65eef5..a2d9f0f5d 100644 --- a/src/mtui/components/Selector.tsx +++ b/src/mtui/components/Selector.tsx @@ -17,10 +17,9 @@ type SelectorProps = { export const Selector: React.FC = ({ title, items, onSelect, onCancel }) => { const [query, setQuery] = useState(""); - const filteredItems = items.filter( - (item) => - item.label.toLowerCase().includes(query.toLowerCase()) || - item.value.toLowerCase().includes(query.toLowerCase()), + const filteredItems = items.filter(item => + item.label.toLowerCase().includes(query.toLowerCase()) || + item.value.toLowerCase().includes(query.toLowerCase()) ); useInput((input, key) => { @@ -28,10 +27,10 @@ export const Selector: React.FC = ({ title, items, onSelect, onCa onCancel(); } if (!key.ctrl && !key.meta && input.length === 1 && !key.return) { - setQuery((q) => q + input); + setQuery(q => q + input); } if (key.backspace || key.delete) { - setQuery((q) => q.slice(0, -1)); + setQuery(q => q.slice(0, -1)); } }); diff --git a/src/mtui/context/GatewayContext.tsx b/src/mtui/context/GatewayContext.tsx index e36349de5..fb502ac28 100644 --- a/src/mtui/context/GatewayContext.tsx +++ b/src/mtui/context/GatewayContext.tsx @@ -4,13 +4,14 @@ import type { TuiOptions } from "../../tui/tui-types.js"; const GatewayContext = createContext(null); -export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({ - options, - children, -}) => { +export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({ options, children }) => { const client = useMemo(() => new GatewayChatClient(options), [options]); - - return {children}; + + return ( + + {children} + + ); }; export const useGateway = () => { diff --git a/src/mtui/context/SettingsContext.tsx b/src/mtui/context/SettingsContext.tsx index 82e687673..e99c34e07 100644 --- a/src/mtui/context/SettingsContext.tsx +++ b/src/mtui/context/SettingsContext.tsx @@ -14,9 +14,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil const [toolsExpanded, setToolsExpanded] = useState(false); return ( - + {children} ); diff --git a/src/mtui/hooks/useChat.ts b/src/mtui/hooks/useChat.ts index ab8db9de1..53b74deca 100644 --- a/src/mtui/hooks/useChat.ts +++ b/src/mtui/hooks/useChat.ts @@ -32,7 +32,7 @@ export const useChat = (initialSessionKey: string) => { const refreshSessionInfo = useCallback(async () => { try { - const statusRes = (await gateway.getStatus()) as any; + const statusRes = await gateway.getStatus() as any; if (statusRes?.sessions?.recent) { const current = statusRes.sessions.recent.find((s: any) => s.key === sessionKey); if (current) { @@ -61,23 +61,15 @@ export const useChat = (initialSessionKey: string) => { const last = prev[prev.length - 1]; if (last && last.id === payload.runId) { return [ - ...prev.slice(0, -1), - { - ...last, - content: last.content + (content || ""), - thinking: last.thinking + (thinking || ""), - }, + ...prev.slice(0, -1), + { + ...last, + content: last.content + (content || ""), + thinking: last.thinking + (thinking || "") + } ]; } - return [ - ...prev, - { - id: payload.runId, - role: "assistant", - content: content || "", - thinking: thinking || "", - }, - ]; + return [...prev, { id: payload.runId, role: "assistant", content: content || "", thinking: thinking || "" }]; }); setStatus("streaming"); } else if (payload.state === "final") { @@ -89,25 +81,16 @@ export const useChat = (initialSessionKey: string) => { const last = prev[prev.length - 1]; if (last && last.id === payload.runId) { return [ - ...prev.slice(0, -1), - { - ...last, - content: content || last.content, + ...prev.slice(0, -1), + { + ...last, + content: content || last.content, thinking: thinking || last.thinking, - isFinal: true, - }, + isFinal: true + } ]; } - return [ - ...prev, - { - id: payload.runId, - role: "assistant", - content: content || "", - thinking: thinking || "", - isFinal: true, - }, - ]; + return [...prev, { id: payload.runId, role: "assistant", content: content || "", thinking: thinking || "", isFinal: true }]; }); setActiveRunId(null); setStatus("idle"); @@ -122,33 +105,22 @@ export const useChat = (initialSessionKey: string) => { if (payload.stream === "tool") { const data = payload.data as any; const { phase, toolCallId, name: toolName } = data; - + setMessages((prev) => { const last = prev[prev.length - 1]; if (!last || last.role !== "assistant") return prev; - + const tools = last.tools || []; let nextTools = [...tools]; - + if (phase === "start") { - nextTools.push({ - id: toolCallId, - name: toolName, - args: data.args, - isStreaming: true, - }); + nextTools.push({ id: toolCallId, name: toolName, args: data.args, isStreaming: true }); } else if (phase === "update") { - nextTools = nextTools.map((t) => - t.id === toolCallId ? { ...t, result: data.partialResult } : t, - ); + nextTools = nextTools.map(t => t.id === toolCallId ? { ...t, result: data.partialResult } : t); } else if (phase === "result") { - nextTools = nextTools.map((t) => - t.id === toolCallId - ? { ...t, result: data.result, isError: data.isError, isStreaming: false } - : t, - ); + nextTools = nextTools.map(t => t.id === toolCallId ? { ...t, result: data.result, isError: data.isError, isStreaming: false } : t); } - + return [...prev.slice(0, -1), { ...last, tools: nextTools }]; }); } @@ -164,14 +136,14 @@ export const useChat = (initialSessionKey: string) => { const loadHistory = useCallback(async () => { 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)) { const msgs = history.messages.map((m: any) => ({ id: m.id || Math.random().toString(), role: m.role, content: extractContentFromMessage(m) || "", thinking: extractThinkingFromMessage(m) || "", - isFinal: true, + isFinal: true })); setMessages(msgs); } @@ -180,26 +152,12 @@ export const useChat = (initialSessionKey: string) => { } }, [gateway, sessionKey]); - const sendMessage = useCallback( - async (text: string) => { - addMessage({ id: Math.random().toString(), role: "user", content: text }); - setStatus("running"); - const { runId } = await gateway.sendChat({ sessionKey, message: text }); - setActiveRunId(runId); - }, - [gateway, sessionKey, addMessage], - ); + const sendMessage = useCallback(async (text: string) => { + addMessage({ id: Math.random().toString(), role: "user", content: text }); + setStatus("running"); + const { runId } = await gateway.sendChat({ sessionKey, message: text }); + setActiveRunId(runId); + }, [gateway, sessionKey, addMessage]); - return { - messages, - status, - sendMessage, - addMessage, - sessionInfo, - sessionKey, - setSessionKey, - refreshSessionInfo, - loadHistory, - activeRunId, - }; + return { messages, status, sendMessage, addMessage, sessionInfo, sessionKey, setSessionKey, refreshSessionInfo, loadHistory, activeRunId }; }; diff --git a/src/mtui/hooks/useCommands.ts b/src/mtui/hooks/useCommands.ts index d3e32e49c..34fcdeb17 100644 --- a/src/mtui/hooks/useCommands.ts +++ b/src/mtui/hooks/useCommands.ts @@ -6,71 +6,49 @@ import { spawn } from "node:child_process"; export const useCommands = ( sessionKey: string, addMessage: (msg: Message) => void, - refreshSessionInfo: () => Promise, + refreshSessionInfo: () => Promise ) => { const gateway = useGateway(); - const handleLocalShell = useCallback( - async (line: string) => { - const cmd = line.slice(1); - addMessage({ id: Math.random().toString(), role: "system", content: `[local] $ ${cmd}` }); - - return new Promise((resolve) => { - const child = spawn(cmd, { shell: true }); - let output = ""; - - child.stdout.on("data", (data) => { - output += data.toString(); - }); - child.stderr.on("data", (data) => { - output += data.toString(); - }); - - child.on("close", (code) => { - addMessage({ - id: Math.random().toString(), - role: "system", - content: output.trim() || `Exit code: ${code}`, - }); - resolve(); - }); + const handleLocalShell = useCallback(async (line: string) => { + const cmd = line.slice(1); + addMessage({ id: Math.random().toString(), role: "system", content: `[local] $ ${cmd}` }); + + return new Promise((resolve) => { + const child = spawn(cmd, { shell: true }); + let output = ""; + + child.stdout.on("data", (data) => { output += data.toString(); }); + child.stderr.on("data", (data) => { output += data.toString(); }); + + child.on("close", (code) => { + addMessage({ id: Math.random().toString(), role: "system", content: output.trim() || `Exit code: ${code}` }); + resolve(); }); - }, - [addMessage], - ); + }); + }, [addMessage]); - const handleSlashCommand = useCallback( - async (text: string) => { - const parts = text.slice(1).split(" "); - const command = parts[0]; - const args = parts.slice(1).join(" "); + const handleSlashCommand = useCallback(async (text: string) => { + const parts = text.slice(1).split(" "); + const command = parts[0]; + const args = parts.slice(1).join(" "); - switch (command) { - case "reset": - await gateway.resetSession(sessionKey); - addMessage({ id: Math.random().toString(), role: "system", content: "Session reset." }); - break; - case "model": - if (args) { - await gateway.patchSession({ key: sessionKey, model: args }); - addMessage({ - id: Math.random().toString(), - role: "system", - content: `Model set to ${args}`, - }); - await refreshSessionInfo(); - } - break; - default: - addMessage({ - id: Math.random().toString(), - role: "system", - content: `Unknown command: /${command}`, - }); - } - }, - [gateway, sessionKey, addMessage, refreshSessionInfo], - ); + switch (command) { + case "reset": + await gateway.resetSession(sessionKey); + addMessage({ id: Math.random().toString(), role: "system", content: "Session reset." }); + break; + case "model": + if (args) { + await gateway.patchSession({ key: sessionKey, model: args }); + addMessage({ id: Math.random().toString(), role: "system", content: `Model set to ${args}` }); + await refreshSessionInfo(); + } + break; + default: + addMessage({ id: Math.random().toString(), role: "system", content: `Unknown command: /${command}` }); + } + }, [gateway, sessionKey, addMessage, refreshSessionInfo]); return { handleLocalShell, handleSlashCommand }; };