From 1175c39022d96ea4d8d37f0b1c423340494c0883 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Fri, 20 Mar 2026 11:57:56 +0100 Subject: [PATCH] feat: add telegram remote control (#175) * add support for remote controlling via telegram * fix issues with grok search --- .env.example | 3 + README.md | 32 ++ bun.lock | 17 + package.json | 1 + src/grok/tools.ts | 70 +-- src/index.ts | 14 +- src/telegram/bridge.ts | 194 +++++++ src/telegram/index.ts | 3 + src/telegram/limits.ts | 11 + src/telegram/pairing.ts | 35 ++ src/telegram/preview-stream.ts | 212 +++++++ src/telegram/turn-coordinator.ts | 19 + src/telegram/typing-refresh.ts | 18 + src/types/index.ts | 2 + src/ui/app.tsx | 945 ++++++++++++++++++++++++++----- src/utils/settings.ts | 53 +- 16 files changed, 1451 insertions(+), 178 deletions(-) create mode 100644 src/telegram/bridge.ts create mode 100644 src/telegram/index.ts create mode 100644 src/telegram/limits.ts create mode 100644 src/telegram/pairing.ts create mode 100644 src/telegram/preview-stream.ts create mode 100644 src/telegram/turn-coordinator.ts create mode 100644 src/telegram/typing-refresh.ts diff --git a/.env.example b/.env.example index 3b2d6c6..88ba7cd 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ GROK_API_KEY=your_grok_api_key_here # Optional: Max tokens per response (default: 16384) # GROK_MAX_TOKENS=16384 + +# Optional: Telegram bot token for /connect → Telegram (see README) +# TELEGRAM_BOT_TOKEN=123456:ABC... diff --git a/README.md b/README.md index be18800..25a548b 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,38 @@ This file stores **global settings** that apply across all projects. These setti } ``` +### Telegram (optional) + +You can pair the CLI with a Telegram bot and send prompts from Telegram while the interactive session is running (similar in spirit to [OpenClaw-style channel pairing](https://docs.openclaw.ai/channels/telegram)). + +1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the bot token. +2. Set **`TELEGRAM_BOT_TOKEN`** in the environment, or add **`telegram.botToken`** to `~/.grok/user-settings.json` (the `/remote-control` flow can save it for you). +3. Start `grok`. If the bot token is already in settings or env, **long polling starts automatically** (no need to open **`/remote-control`** every time). Otherwise run **`/remote-control`** → **Telegram** and paste the token. +4. In Telegram, open a DM with your bot and send **`/pair`** (only needed once per user until you remove them from `approvedUserIds`). +5. The first time you pair a Telegram user, enter the **6-character code** in the terminal when prompted. After that, approval is remembered in `user-settings.json`. +6. **Keep the CLI open** while you use the bot — polling runs in that process. + +Optional env override: `TELEGRAM_BOT_TOKEN`. Approved Telegram user IDs and per-user session mappings are stored under the **`telegram`** object in `user-settings.json`. Treat the bot token like a password. + +**Optional Telegram UX** (in `~/.grok/user-settings.json` under `telegram`): + +| Key | Values | Default | Description | +|-----|--------|---------|-------------| +| `streaming` | `"partial"` \| `"off"` | `"partial"` | **`partial`**: live preview while the model replies (`sendMessage` + throttled `editMessageText`). **`off`**: buffer the full reply, then send (no preview updates). | +| `typingIndicator` | boolean | `true` | Send `typing` chat actions on an interval while the agent runs. Set `false` to disable. | +| `nativeDrafts` | boolean | `false` | Reserved for future Bot API `sendMessageDraft` support (private chats); not used yet. | + +**Example:** + +```json +{ + "telegram": { + "streaming": "partial", + "typingIndicator": true + } +} +``` + ### Project-Level Settings (`.grok/settings.json`) This file stores **project-specific settings** in your current working directory. It includes: diff --git a/bun.lock b/bun.lock index 6ac3b28..035a2ae 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "commander": "^12.1.0", "diff": "^8.0.3", "dotenv": "^16.6.1", + "grammy": "^1.41.1", "react": "^19.2.4", "zod": "^4.3.6", }, @@ -57,6 +58,8 @@ "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -187,6 +190,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -211,6 +216,8 @@ "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -237,6 +244,10 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -309,6 +320,8 @@ "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -317,6 +330,10 @@ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], diff --git a/package.json b/package.json index 28d6126..02b0d0f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "commander": "^12.1.0", "diff": "^8.0.3", "dotenv": "^16.6.1", + "grammy": "^1.41.1", "react": "^19.2.4", "zod": "^4.3.6" }, diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 511e30d..a7b72e2 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -5,7 +5,7 @@ import { editFile, readFile, writeFile } from "../tools/file"; import type { AgentMode, TaskRequest, ToolResult } from "../types/index"; import type { XaiProvider } from "./client"; -const SEARCH_MODEL = "grok-3-mini-fast"; +const RESPONSES_SEARCH_MODEL = "grok-4-fast-non-reasoning"; interface CreateToolsOptions { runTask?: (request: TaskRequest, abortSignal?: AbortSignal) => Promise; @@ -22,6 +22,32 @@ export function createTools( ) { const cwd = () => bash.getCwd(); + const runResponsesSearch = async ( + query: string, + toolName: "web_search" | "x_search", + ): Promise<{ success: boolean; output: string }> => { + try { + const { text } = await generateText({ + model: provider.responses(RESPONSES_SEARCH_MODEL), + maxOutputTokens: 4096, + prompt: query, + tools: { + ...(toolName === "web_search" ? { web_search: provider.tools.webSearch() } : {}), + ...(toolName === "x_search" ? { x_search: provider.tools.xSearch() } : {}), + }, + }); + + return { + success: true, + output: text || "No search results found.", + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + const label = toolName === "web_search" ? "Web search" : "X search"; + return { success: false, output: `${label} failed: ${msg}` }; + } + }; + const base = { bash: tool({ description: @@ -106,26 +132,7 @@ export function createTools( query: z.string().describe("The search query"), }), execute: async ({ query }) => { - try { - const { text } = await generateText({ - model: provider(SEARCH_MODEL), - maxOutputTokens: 4096, - providerOptions: { - xai: { - searchParameters: { - mode: "on", - returnCitations: true, - sources: [{ type: "web" }], - }, - }, - }, - prompt: query, - }); - return { success: true, output: text }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return { success: false, output: `Web search failed: ${msg}` }; - } + return runResponsesSearch(query, "web_search"); }, }), @@ -136,26 +143,7 @@ export function createTools( query: z.string().describe("The search query"), }), execute: async ({ query }) => { - try { - const { text } = await generateText({ - model: provider(SEARCH_MODEL), - maxOutputTokens: 4096, - providerOptions: { - xai: { - searchParameters: { - mode: "on", - returnCitations: true, - sources: [{ type: "x" }], - }, - }, - }, - prompt: query, - }); - return { success: true, output: text }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return { success: false, output: `X search failed: ${msg}` }; - } + return runResponsesSearch(query, "x_search"); }, }), }; diff --git a/src/index.ts b/src/index.ts index 08e0496..309abc2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -50,7 +50,19 @@ async function startInteractive( process.exit(0); }; - createRoot(renderer).render(createElement(App, { agent, initialMessage, onExit })); + createRoot(renderer).render( + createElement(App, { + agent, + startupConfig: { + apiKey, + baseURL, + model, + maxToolRounds, + }, + initialMessage, + onExit, + }), + ); } async function runHeadless( diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts new file mode 100644 index 0000000..55d813b --- /dev/null +++ b/src/telegram/bridge.ts @@ -0,0 +1,194 @@ +import { Bot } from "grammy"; +import type { Agent } from "../agent/agent"; +import type { ToolCall, ToolResult } from "../types/index"; +import { loadUserSettings, resolveTelegramStreamSettings } from "../utils/settings"; +import { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits"; +import { registerPairingCode } from "./pairing"; +import { runTelegramPartialReply } from "./preview-stream"; +import type { TurnCoordinator } from "./turn-coordinator"; +import { startTypingRefresh } from "./typing-refresh"; + +export { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits"; + +export interface TelegramBridgeOptions { + token: string; + getApprovedUserIds: () => number[]; + coordinator: TurnCoordinator; + getTelegramAgent: (userId: number) => Agent; + onUserMessage?: (event: { turnKey: string; userId: number; content: string }) => void; + onAssistantMessage?: (event: { turnKey: string; userId: number; content: string; done: boolean }) => void; + onToolCalls?: (event: { turnKey: string; userId: number; toolCalls: ToolCall[] }) => void; + onToolResult?: (event: { turnKey: string; userId: number; toolCall: ToolCall; toolResult: ToolResult }) => void; + onError?: (message: string) => void; +} + +export interface TelegramBridgeHandle { + start: () => void; + stop: () => Promise; + sendDm: (userId: number, text: string) => Promise; +} + +export function createTelegramBridge(opts: TelegramBridgeOptions): TelegramBridgeHandle { + const bot = new Bot(opts.token); + let running = false; + + bot.command("start", async (ctx) => { + await ctx.reply("Send /pair to link this chat to Grok CLI, then approve the code in the terminal."); + }); + + bot.command("pair", async (ctx) => { + const userId = ctx.from?.id; + if (userId === undefined) return; + const code = registerPairingCode(userId); + await ctx.reply(`Your pairing code: ${code}\nEnter this code in Grok CLI (/remote-control → Telegram) to approve.`); + }); + + bot.on("message:text", async (ctx) => { + const text = ctx.message.text; + if (text.startsWith("/")) return; + + const userId = ctx.from?.id; + if (userId === undefined) return; + + const approved = opts.getApprovedUserIds(); + if (!approved.includes(userId)) { + await ctx.reply("Not paired yet. Send /pair to get a code, then approve in Grok CLI."); + return; + } + + await opts.coordinator.run(async () => { + try { + const turnKey = `telegram:${ctx.chat.id}:${ctx.message.message_id}`; + opts.onUserMessage?.({ turnKey, userId, content: text }); + const agent = opts.getTelegramAgent(userId); + const stream = resolveTelegramStreamSettings(loadUserSettings().telegram); + + if (stream.streaming === "off") { + const stopTyping = startTypingRefresh( + bot.api, + ctx.chat.id, + ctx.message.message_thread_id, + stream.typingIndicator, + ); + let acc = ""; + try { + for await (const chunk of agent.processMessage(text)) { + switch (chunk.type) { + case "content": + if (chunk.content) { + acc += chunk.content; + } + break; + case "tool_calls": + if (chunk.toolCalls) { + opts.onToolCalls?.({ turnKey, userId, toolCalls: chunk.toolCalls }); + } + break; + case "tool_result": + if (chunk.toolCall && chunk.toolResult) { + opts.onToolResult?.({ + turnKey, + userId, + toolCall: chunk.toolCall, + toolResult: chunk.toolResult, + }); + } + break; + } + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + opts.onAssistantMessage?.({ + turnKey, + userId, + content: `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`, + done: true, + }); + await ctx.reply(`Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`); + return; + } finally { + stopTyping(); + } + const trimmed = acc.trim() || "(no text output)"; + opts.onAssistantMessage?.({ turnKey, userId, content: trimmed, done: true }); + const parts = splitTelegramMessage(trimmed); + for (const part of parts) { + await ctx.reply(part); + } + return; + } + + await runTelegramPartialReply(bot.api, { + chatId: ctx.chat.id, + messageThreadId: ctx.message.message_thread_id, + typingIndicator: stream.typingIndicator, + stream: agent.processMessage(text), + onAssistantMessage: (event) => { + opts.onAssistantMessage?.({ + turnKey, + userId, + content: event.content, + done: event.done, + }); + }, + onToolCalls: (toolCalls) => { + opts.onToolCalls?.({ turnKey, userId, toolCalls }); + }, + onToolResult: (event) => { + opts.onToolResult?.({ + turnKey, + userId, + toolCall: event.toolCall, + toolResult: event.toolResult, + }); + }, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + opts.onAssistantMessage?.({ + turnKey: `telegram:${ctx.chat.id}:${ctx.message.message_id}`, + userId, + content: `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`, + done: true, + }); + try { + await ctx.reply(`Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`); + } catch { + /* user blocked bot or chat forbids messages */ + } + } + }); + }); + + bot.catch((err) => { + opts.onError?.(err instanceof Error ? err.message : String(err)); + }); + + return { + start() { + if (running) return; + running = true; + void bot + .start({ + allowed_updates: ["message"], + drop_pending_updates: true, + }) + .catch((err: unknown) => { + running = false; + opts.onError?.(err instanceof Error ? err.message : String(err)); + }); + }, + + async stop() { + if (!running) return; + await bot.stop(); + running = false; + }, + + async sendDm(userId: number, text: string) { + for (const part of splitTelegramMessage(text)) { + await bot.api.sendMessage(userId, part); + } + }, + }; +} diff --git a/src/telegram/index.ts b/src/telegram/index.ts new file mode 100644 index 0000000..5707f28 --- /dev/null +++ b/src/telegram/index.ts @@ -0,0 +1,3 @@ +export { createTelegramBridge, splitTelegramMessage } from "./bridge"; +export { approvePairingCode, clearPairingStore, registerPairingCode } from "./pairing"; +export { createTurnCoordinator } from "./turn-coordinator"; diff --git a/src/telegram/limits.ts b/src/telegram/limits.ts new file mode 100644 index 0000000..0791cf2 --- /dev/null +++ b/src/telegram/limits.ts @@ -0,0 +1,11 @@ +/** Telegram text message hard limit (characters). */ +export const TELEGRAM_MAX_MESSAGE = 4096; + +export function splitTelegramMessage(text: string): string[] { + if (!text) return []; + const parts: string[] = []; + for (let i = 0; i < text.length; i += TELEGRAM_MAX_MESSAGE) { + parts.push(text.slice(i, i + TELEGRAM_MAX_MESSAGE)); + } + return parts; +} diff --git a/src/telegram/pairing.ts b/src/telegram/pairing.ts new file mode 100644 index 0000000..cf17d78 --- /dev/null +++ b/src/telegram/pairing.ts @@ -0,0 +1,35 @@ +import { randomBytes } from "node:crypto"; + +const PAIRING_TTL_MS = 60 * 60 * 1000; + +/** code (uppercase) -> { userId, expiresAt } */ +const pendingByCode = new Map(); + +function generateCode(): string { + const hex = randomBytes(4).toString("hex").slice(0, 6).toUpperCase(); + return hex; +} + +export function registerPairingCode(userId: number): string { + const code = generateCode(); + pendingByCode.set(code, { userId, expiresAt: Date.now() + PAIRING_TTL_MS }); + return code; +} + +export function approvePairingCode(code: string): { ok: true; userId: number } | { ok: false; error: string } { + const normalized = code.trim().toUpperCase(); + const entry = pendingByCode.get(normalized); + if (!entry) { + return { ok: false, error: "Unknown or expired code." }; + } + if (Date.now() > entry.expiresAt) { + pendingByCode.delete(normalized); + return { ok: false, error: "Code expired. Send /pair again in Telegram." }; + } + pendingByCode.delete(normalized); + return { ok: true, userId: entry.userId }; +} + +export function clearPairingStore(): void { + pendingByCode.clear(); +} diff --git a/src/telegram/preview-stream.ts b/src/telegram/preview-stream.ts new file mode 100644 index 0000000..db512ac --- /dev/null +++ b/src/telegram/preview-stream.ts @@ -0,0 +1,212 @@ +import type { Api } from "grammy"; +import { GrammyError } from "grammy"; +import type { StreamChunk } from "../types/index"; +import { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits"; +import { startTypingRefresh } from "./typing-refresh"; + +const EDIT_THROTTLE_MS = 350; +const EDIT_MIN_CHARS = 48; +const MAX_API_RETRIES = 12; + +/** Bot API allows `message_thread_id` for forum topics; @grammyjs/types may omit it on `editMessageText`. */ +function editThreadOpts(messageThreadId?: number): { message_thread_id?: number } { + return messageThreadId !== undefined ? { message_thread_id: messageThreadId } : {}; +} + +async function withRetry(fn: () => Promise): Promise { + let lastErr: unknown; + for (let i = 0; i < MAX_API_RETRIES; i++) { + try { + return await fn(); + } catch (e) { + lastErr = e; + if (e instanceof GrammyError && e.error_code === 429) { + const retryAfter = Number((e.parameters as { retry_after?: number }).retry_after ?? 1) * 1000; + await new Promise((r) => setTimeout(r, retryAfter)); + continue; + } + throw e; + } + } + throw lastErr; +} + +/** Same text as before — not an error; avoid treating as preview failure. */ +function isMessageNotModified(err: unknown): boolean { + return ( + err instanceof GrammyError && + err.error_code === 400 && + typeof err.description === "string" && + err.description.toLowerCase().includes("message is not modified") + ); +} + +/** Telegram requires non-empty text; use zero-width space so nothing visible appears before real tokens. */ +const EMPTY_PREVIEW_PLACEHOLDER = "\u200b"; + +function previewBody(acc: string): string { + if (!acc) return EMPTY_PREVIEW_PLACEHOLDER; + return acc.length > TELEGRAM_MAX_MESSAGE ? acc.slice(0, TELEGRAM_MAX_MESSAGE) : acc; +} + +export interface TelegramPartialReplyArgs { + chatId: number | string; + messageThreadId?: number; + typingIndicator: boolean; + stream: AsyncIterable; + onAssistantMessage?: (event: { content: string; done: boolean }) => void; + onToolCalls?: (toolCalls: NonNullable) => void; + onToolResult?: (event: { + toolCall: NonNullable; + toolResult: NonNullable; + }) => void; +} + +/** + * Sends a live-updating preview (send + throttled editMessageText), then finalizes. + * Falls back to buffer-then-send if the initial message cannot be sent. + */ +export async function runTelegramPartialReply(api: Api, args: TelegramPartialReplyArgs): Promise { + const { chatId, messageThreadId, typingIndicator, stream, onAssistantMessage, onToolCalls, onToolResult } = args; + + const stopTyping = startTypingRefresh(api, chatId, messageThreadId, typingIndicator); + + const sendParts = async (parts: string[]) => { + for (const part of parts) { + await withRetry(() => api.sendMessage(chatId, part, { message_thread_id: messageThreadId })); + } + }; + + let previewMessageId: number | undefined; + /** First sendMessage failed — finish with sendParts only. */ + let previewCreateFailed = false; + let acc = ""; + let previewBroken = false; + let lastEditAt = 0; + let lastEditLen = 0; + + /** First real message is sent only after assistant text exists so sendChatAction(typing) stays visible until then. */ + const ensurePreviewMessage = async () => { + if (previewMessageId !== undefined || previewCreateFailed || !acc) return; + try { + const sent = await withRetry(() => + api.sendMessage(chatId, previewBody(acc), { message_thread_id: messageThreadId }), + ); + if (!sent.message_id) { + previewCreateFailed = true; + return; + } + previewMessageId = sent.message_id; + } catch { + previewCreateFailed = true; + } + }; + + const flushEdit = async (force: boolean) => { + if (previewMessageId === undefined || previewBroken) return; + const messageId = previewMessageId; + const now = Date.now(); + const delta = acc.length - lastEditLen; + if (!force && now - lastEditAt < EDIT_THROTTLE_MS && delta < EDIT_MIN_CHARS) return; + try { + await withRetry(() => + api.editMessageText(chatId, messageId, previewBody(acc), editThreadOpts(messageThreadId) as never), + ); + lastEditAt = Date.now(); + lastEditLen = acc.length; + } catch (e) { + if (isMessageNotModified(e)) { + lastEditAt = Date.now(); + lastEditLen = acc.length; + return; + } + previewBroken = true; + } + }; + + try { + try { + for await (const chunk of stream) { + switch (chunk.type) { + case "content": + if (chunk.content) { + acc += chunk.content; + onAssistantMessage?.({ content: acc, done: false }); + await ensurePreviewMessage(); + await flushEdit(false); + } + break; + case "tool_calls": + if (chunk.toolCalls) { + onToolCalls?.(chunk.toolCalls); + } + break; + case "tool_result": + if (chunk.toolCall && chunk.toolResult) { + onToolResult?.({ toolCall: chunk.toolCall, toolResult: chunk.toolResult }); + } + break; + } + } + } catch (err: unknown) { + await flushEdit(true); + const msg = err instanceof Error ? err.message : String(err); + const errText = `Error: ${msg.slice(0, TELEGRAM_MAX_MESSAGE)}`; + onAssistantMessage?.({ content: errText, done: true }); + if (previewMessageId !== undefined && !previewBroken) { + const messageId = previewMessageId; + try { + await withRetry(() => + api.editMessageText(chatId, messageId, errText, editThreadOpts(messageThreadId) as never), + ); + } catch { + await sendParts(splitTelegramMessage(errText)); + } + } else { + await sendParts(splitTelegramMessage(errText)); + } + return; + } + + await flushEdit(true); + + const trimmed = acc.trim() || "(no text output)"; + const parts = splitTelegramMessage(trimmed); + onAssistantMessage?.({ content: trimmed, done: true }); + + if (previewMessageId === undefined) { + await sendParts(parts); + return; + } + + if (parts.length === 0) { + if (!previewBroken) { + const messageId = previewMessageId; + await withRetry(() => + api.editMessageText(chatId, messageId, "(no text output)", editThreadOpts(messageThreadId) as never), + ); + } + return; + } + + if (previewBroken) { + await sendParts(parts); + return; + } + + const messageId = previewMessageId; + try { + await withRetry(() => api.editMessageText(chatId, messageId, parts[0], editThreadOpts(messageThreadId) as never)); + } catch (e) { + if (!isMessageNotModified(e)) { + await sendParts(parts); + return; + } + } + if (parts.length > 1) { + await sendParts(parts.slice(1)); + } + } finally { + stopTyping(); + } +} diff --git a/src/telegram/turn-coordinator.ts b/src/telegram/turn-coordinator.ts new file mode 100644 index 0000000..5e07b11 --- /dev/null +++ b/src/telegram/turn-coordinator.ts @@ -0,0 +1,19 @@ +/** + * Serialize async turns (local TUI + Telegram) so only one Agent.processMessage runs at a time. + */ +export function createTurnCoordinator() { + let chain: Promise = Promise.resolve(); + + return { + run(fn: () => Promise): Promise { + const result = chain.then(() => fn()); + chain = result.then( + () => {}, + () => {}, + ); + return result; + }, + }; +} + +export type TurnCoordinator = ReturnType; diff --git a/src/telegram/typing-refresh.ts b/src/telegram/typing-refresh.ts new file mode 100644 index 0000000..21d1e06 --- /dev/null +++ b/src/telegram/typing-refresh.ts @@ -0,0 +1,18 @@ +import type { Api } from "grammy"; + +/** Telegram clears typing after ~5s; refresh before that so the indicator stays visible. */ +const TYPING_REFRESH_MS = 3500; + +/** Repeats `sendChatAction(typing)` until `stop()` is called. No-op when `enabled` is false. */ +export function startTypingRefresh( + api: Api, + chatId: number | string, + messageThreadId: number | undefined, + enabled: boolean, +): () => void { + if (!enabled) return () => {}; + const tick = () => void api.sendChatAction(chatId, "typing", { message_thread_id: messageThreadId }).catch(() => {}); + tick(); + const id = setInterval(tick, TYPING_REFRESH_MS); + return () => clearInterval(id); +} diff --git a/src/types/index.ts b/src/types/index.ts index 0967481..858e894 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -89,6 +89,8 @@ export interface ChatEntry { content: string; timestamp: Date; modeColor?: string; + remoteKey?: string; + sourceLabel?: string; queued?: boolean; toolCalls?: ToolCall[]; toolCall?: ToolCall; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index d7e4d34..c8216e8 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -3,8 +3,11 @@ import { decodePasteBytes, type PasteEvent } from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; import os from "os"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { Agent } from "../agent/agent"; +import { Agent } from "../agent/agent"; import { getModelInfo, MODELS } from "../grok/models"; +import { createTelegramBridge, type TelegramBridgeHandle } from "../telegram/bridge"; +import { approvePairingCode } from "../telegram/pairing"; +import { createTurnCoordinator } from "../telegram/turn-coordinator"; import type { AgentMode, ChatEntry, @@ -14,10 +17,17 @@ import type { PlanQuestion, SubagentStatus, ToolCall, + ToolResult, } from "../types/index"; import { MODES } from "../types/index"; import { copyTextToHostClipboard } from "../utils/host-clipboard"; -import { saveProjectSettings, saveUserSettings } from "../utils/settings"; +import { + getApiKey, + getTelegramBotToken, + loadUserSettings, + saveProjectSettings, + saveUserSettings, +} from "../utils/settings"; import { discoverSkills, formatSkillsForChat } from "../utils/skills"; import { Markdown } from "./markdown"; import { @@ -214,6 +224,7 @@ interface SlashMenuItem { const SLASH_MENU_ITEMS: SlashMenuItem[] = [ { id: "exit", label: "exit", description: "Quit the CLI" }, { id: "help", label: "help", description: "Show available commands" }, + { id: "remote-control", label: "remote-control", description: "Remote control" }, { id: "mcps", label: "mcps", description: "Manage MCP servers" }, { id: "models", label: "models", description: "Select a model" }, { id: "new", label: "new session", description: "Start a new session" }, @@ -221,13 +232,25 @@ const SLASH_MENU_ITEMS: SlashMenuItem[] = [ { id: "skills", label: "skills", description: "Manage skills" }, ]; +const CONNECT_CHANNELS: { id: string; label: string; description: string }[] = [ + { id: "telegram", label: "Telegram", description: "Chat with Grok from Telegram" }, +]; + +export interface AppStartupConfig { + apiKey: string | undefined; + baseURL: string; + model: string; + maxToolRounds: number; +} + interface AppProps { agent: Agent; + startupConfig: AppStartupConfig; initialMessage?: string; onExit?: () => void; } -export function App({ agent, initialMessage, onExit }: AppProps) { +export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) { const t = dark; const renderer = useRenderer(); const initialHasApiKey = agent.hasApiKey(); @@ -274,6 +297,20 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const modeInfoRef = useRef<(typeof MODES)[number]>(MODES[0]); const activeRunIdRef = useRef(0); const interruptedRunIdRef = useRef(null); + const coordinatorRef = useRef(createTurnCoordinator()); + const bridgeRef = useRef(null); + const telegramAgentsRef = useRef>(new Map()); + const [showConnectModal, setShowConnectModal] = useState(false); + const [showTelegramTokenModal, setShowTelegramTokenModal] = useState(false); + const [showTelegramPairModal, setShowTelegramPairModal] = useState(false); + const [telegramTokenError, setTelegramTokenError] = useState(null); + const [telegramPairError, setTelegramPairError] = useState(null); + const [connectModalIndex, setConnectModalIndex] = useState(0); + const telegramTokenInputRef = useRef(null); + const telegramPairInputRef = useRef(null); + const showConnectModalRef = useRef(false); + const showTelegramTokenModalRef = useRef(false); + const showTelegramPairModalRef = useRef(false); const setMode = useCallback( (m: AgentMode) => { @@ -328,6 +365,162 @@ export function App({ agent, initialMessage, onExit }: AppProps) { } }, []); + const getTelegramAgent = useCallback( + (userId: number) => { + const map = telegramAgentsRef.current; + const existing = map.get(userId); + if (existing) return existing; + + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error("Grok API key required. Add it in the CLI or set GROK_API_KEY."); + } + + const u = loadUserSettings(); + const sid = u.telegram?.sessionsByUserId?.[String(userId)]; + const a = new Agent(apiKey, startupConfig.baseURL, startupConfig.model, startupConfig.maxToolRounds, { + session: sid, + }); + if (!sid && a.getSessionId()) { + saveUserSettings({ + telegram: { + ...u.telegram, + sessionsByUserId: { + ...u.telegram?.sessionsByUserId, + [String(userId)]: a.getSessionId()!, + }, + }, + }); + } + map.set(userId, a); + return a; + }, + [startupConfig], + ); + + const appendTelegramUserMessage = useCallback( + (event: { userId: number; content: string }) => { + setMessages((prev) => [ + ...prev, + { + type: "user", + content: event.content, + timestamp: new Date(), + sourceLabel: `Telegram user ${event.userId}`, + }, + ]); + setTimeout(scrollToBottom, 10); + }, + [scrollToBottom], + ); + + const upsertTelegramAssistantMessage = useCallback( + (event: { turnKey: string; userId: number; content: string; done: boolean }) => { + const content = sanitizeContent(event.content); + if (!content && !event.done) return; + + setMessages((prev) => { + const next = [...prev]; + const idx = next.findIndex((entry) => entry.type === "assistant" && entry.remoteKey === event.turnKey); + const entry: ChatEntry = { + type: "assistant", + content: content || "(no text output)", + timestamp: new Date(), + remoteKey: event.turnKey, + sourceLabel: `Telegram Grok • user ${event.userId}`, + }; + + if (idx === -1) { + next.push(entry); + } else { + next[idx] = { + ...next[idx], + content: entry.content, + timestamp: entry.timestamp, + sourceLabel: entry.sourceLabel, + }; + } + return next; + }); + if (event.done) { + setActiveToolCalls([]); + } + setTimeout(scrollToBottom, 10); + }, + [scrollToBottom], + ); + + const showTelegramToolCalls = useCallback( + (event: { toolCalls: ToolCall[] }) => { + setActiveToolCalls(event.toolCalls); + setTimeout(scrollToBottom, 10); + }, + [scrollToBottom], + ); + + const appendTelegramToolResult = useCallback( + (event: { toolCall: ToolCall; toolResult: ToolResult }) => { + setMessages((prev) => [ + ...prev, + { + type: "tool_result", + content: event.toolResult.success ? event.toolResult.output || "Success" : event.toolResult.error || "Error", + timestamp: new Date(), + toolCall: event.toolCall, + toolResult: event.toolResult, + }, + ]); + if (event.toolResult.plan?.questions?.length) { + setActivePlan(event.toolResult.plan); + setPqs(initialPlanQuestionsState()); + } + setActiveToolCalls([]); + setTimeout(scrollToBottom, 10); + }, + [scrollToBottom], + ); + + const startTelegramBridge = useCallback(() => { + const token = getTelegramBotToken(); + if (!token || !getApiKey()) return; + if (bridgeRef.current) return; + + const bridge = createTelegramBridge({ + token, + getApprovedUserIds: () => loadUserSettings().telegram?.approvedUserIds ?? [], + coordinator: coordinatorRef.current, + getTelegramAgent, + onUserMessage: appendTelegramUserMessage, + onAssistantMessage: upsertTelegramAssistantMessage, + onToolCalls: showTelegramToolCalls, + onToolResult: appendTelegramToolResult, + onError: (msg) => { + setMessages((p) => [...p, { type: "assistant", content: `Telegram: ${msg}`, timestamp: new Date() }]); + }, + }); + bridgeRef.current = bridge; + bridge.start(); + }, [ + appendTelegramToolResult, + appendTelegramUserMessage, + getTelegramAgent, + showTelegramToolCalls, + upsertTelegramAssistantMessage, + ]); + + /** Start long polling when a bot token is already saved (pairing UI is optional if already approved). */ + useEffect(() => { + if (!hasApiKey) return; + if (!getTelegramBotToken()) return; + startTelegramBridge(); + }, [hasApiKey, startTelegramBridge]); + + const handleExit = useCallback(() => { + void bridgeRef.current?.stop(); + bridgeRef.current = null; + onExit?.(); + }, [onExit]); + const showCopyBanner = useCallback(() => { setCopyFlashId((n) => n + 1); }, []); @@ -386,7 +579,10 @@ export function App({ agent, initialMessage, onExit }: AppProps) { setApiKeyError(null); setShowApiKeyModal(false); apiKeyInputRef.current?.clear(); - }, [agent]); + if (getTelegramBotToken()) { + startTelegramBridge(); + } + }, [agent, startTelegramBridge]); useEffect(() => { hasApiKeyRef.current = hasApiKey; @@ -396,6 +592,123 @@ export function App({ agent, initialMessage, onExit }: AppProps) { showApiKeyModalRef.current = showApiKeyModal; }, [showApiKeyModal]); + useEffect(() => { + showConnectModalRef.current = showConnectModal; + }, [showConnectModal]); + useEffect(() => { + showTelegramTokenModalRef.current = showTelegramTokenModal; + }, [showTelegramTokenModal]); + useEffect(() => { + showTelegramPairModalRef.current = showTelegramPairModal; + }, [showTelegramPairModal]); + + useEffect(() => { + return () => { + void bridgeRef.current?.stop(); + bridgeRef.current = null; + }; + }, []); + + const submitTelegramToken = useCallback(() => { + const token = (telegramTokenInputRef.current?.plainText || "").trim(); + if (!token) { + setTelegramTokenError("Paste your bot token from @BotFather."); + return; + } + if (!getApiKey()) { + setTelegramTokenError("Add a Grok API key first."); + return; + } + const u = loadUserSettings(); + saveUserSettings({ telegram: { ...u.telegram, botToken: token } }); + telegramTokenInputRef.current?.clear(); + setShowTelegramTokenModal(false); + setTelegramTokenError(null); + startTelegramBridge(); + setShowTelegramPairModal(true); + setTelegramPairError(null); + setMessages((p) => [ + ...p, + { + type: "assistant", + content: + "Telegram polling started. In Telegram, DM your bot and send /pair. Copy the code, then enter it below.", + timestamp: new Date(), + }, + ]); + }, [startTelegramBridge]); + + const submitTelegramPair = useCallback(async () => { + const code = (telegramPairInputRef.current?.plainText || "").trim(); + if (!code) { + setTelegramPairError("Enter the pairing code."); + return; + } + const result = approvePairingCode(code); + if (!result.ok) { + setTelegramPairError(result.error); + return; + } + const u = loadUserSettings(); + const ids = new Set(u.telegram?.approvedUserIds ?? []); + ids.add(result.userId); + saveUserSettings({ telegram: { ...u.telegram, approvedUserIds: [...ids] } }); + telegramPairInputRef.current?.clear(); + setShowTelegramPairModal(false); + setTelegramPairError(null); + setMessages((p) => [ + ...p, + { + type: "assistant", + content: `Telegram user ${result.userId} paired. Keep this CLI open while you use the bot.`, + timestamp: new Date(), + }, + ]); + try { + await bridgeRef.current?.sendDm(result.userId, "Pairing approved. You can message Grok here."); + } catch { + /* optional DM */ + } + }, []); + + const beginTelegramFromConnect = useCallback(() => { + setShowConnectModal(false); + if (!getApiKey()) { + setMessages((p) => [...p, { type: "assistant", content: "Add a Grok API key first.", timestamp: new Date() }]); + openApiKeyModal(); + return; + } + if (!getTelegramBotToken()) { + setShowTelegramTokenModal(true); + setTelegramTokenError(null); + return; + } + startTelegramBridge(); + const alreadyPaired = (loadUserSettings().telegram?.approvedUserIds?.length ?? 0) > 0; + if (!alreadyPaired) { + setShowTelegramPairModal(true); + setTelegramPairError(null); + setMessages((p) => [ + ...p, + { + type: "assistant", + content: + "Telegram polling started. In Telegram, DM your bot and send /pair. Copy the code, then enter it below.", + timestamp: new Date(), + }, + ]); + } else { + setMessages((p) => [ + ...p, + { + type: "assistant", + content: "Telegram polling is running. Your chat is already paired.", + timestamp: new Date(), + }, + ]); + } + }, [openApiKeyModal, startTelegramBridge]); + const invalidateActiveRun = useCallback(() => { activeRunIdRef.current += 1; setActiveToolCalls([]); @@ -428,127 +741,132 @@ export function App({ agent, initialMessage, onExit }: AppProps) { const isStale = () => activeRunIdRef.current !== runId; isProcessingRef.current = true; setIsProcessing(true); - setStreamContent(""); - setStreamReasoning(""); - setActiveToolCalls([]); - setActiveSubagent(null); - contentAccRef.current = ""; - startTimeRef.current = Date.now(); if (!sessionTitle) agent .generateTitle(text.trim()) .then(setSessionTitle) .catch(() => {}); - const color = modeInfoRef.current.color; - setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date(), modeColor: color }]); - setTimeout(scrollToBottom, 50); - try { - for await (const chunk of agent.processMessage(text.trim())) { - if (isStale()) { - break; - } - - switch (chunk.type) { - case "content": - contentAccRef.current += chunk.content || ""; - setStreamContent(sanitizeContent(contentAccRef.current)); - setTimeout(scrollToBottom, 10); - break; - case "reasoning": - setStreamReasoning((p) => p + (chunk.content || "")); - break; - case "tool_calls": - if (chunk.toolCalls) { - const cleaned = sanitizeContent(contentAccRef.current); - if (cleaned) { - setMessages((p) => [ - ...p, - { - type: "assistant", - content: cleaned, - timestamp: new Date(), - modeColor: modeInfoRef.current.color, - }, - ]); - } - contentAccRef.current = ""; - setStreamContent(""); - setActiveToolCalls(chunk.toolCalls); - } - break; - case "tool_result": - if (chunk.toolCall && chunk.toolResult) { - setMessages((p) => [ - ...p, - { - type: "tool_result", - content: chunk.toolResult!.success - ? chunk.toolResult!.output || "Success" - : chunk.toolResult!.error || "Error", - timestamp: new Date(), - modeColor: modeInfoRef.current.color, - toolCall: chunk.toolCall, - toolResult: chunk.toolResult, - }, - ]); - if (chunk.toolResult.plan?.questions?.length) { - setActivePlan(chunk.toolResult.plan); - setPqs(initialPlanQuestionsState()); - } - setActiveToolCalls([]); - setTimeout(scrollToBottom, 10); - } - break; - case "error": - contentAccRef.current += `\n${chunk.content || "Unknown error"}`; - setStreamContent(contentAccRef.current); - break; - case "done": - break; - } - } - } catch { - if (!isStale()) { - contentAccRef.current += "\nAn unexpected error occurred."; - setStreamContent(contentAccRef.current); - } - } - const wasInterrupted = interruptedRunIdRef.current === runId; - const finalContent = sanitizeContent(contentAccRef.current); - if (isStale()) { - contentAccRef.current = ""; - return; - } - - if (!wasInterrupted && finalContent) { - setMessages((p) => [ - ...p, - { type: "assistant", content: finalContent, timestamp: new Date(), modeColor: modeInfoRef.current.color }, - ]); - } - - contentAccRef.current = ""; - if (!isStale()) { + await coordinatorRef.current.run(async () => { setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); setActiveSubagent(null); - } - if (wasInterrupted) { - interruptedRunIdRef.current = null; - } - const nextQueued = queuedMessagesRef.current.shift(); - if (nextQueued) { - setQueuedMessages([...queuedMessagesRef.current]); - isProcessingRef.current = false; - processMessage(nextQueued); - } else { - isProcessingRef.current = false; - if (!isStale()) { - setIsProcessing(false); + contentAccRef.current = ""; + startTimeRef.current = Date.now(); + const color = modeInfoRef.current.color; + setMessages((prev) => [ + ...prev, + { type: "user", content: text.trim(), timestamp: new Date(), modeColor: color }, + ]); + setTimeout(scrollToBottom, 50); + try { + for await (const chunk of agent.processMessage(text.trim())) { + if (isStale()) { + break; + } + + switch (chunk.type) { + case "content": + contentAccRef.current += chunk.content || ""; + setStreamContent(sanitizeContent(contentAccRef.current)); + setTimeout(scrollToBottom, 10); + break; + case "reasoning": + setStreamReasoning((p) => p + (chunk.content || "")); + break; + case "tool_calls": + if (chunk.toolCalls) { + const cleaned = sanitizeContent(contentAccRef.current); + if (cleaned) { + setMessages((p) => [ + ...p, + { + type: "assistant", + content: cleaned, + timestamp: new Date(), + modeColor: modeInfoRef.current.color, + }, + ]); + } + contentAccRef.current = ""; + setStreamContent(""); + setActiveToolCalls(chunk.toolCalls); + } + break; + case "tool_result": + if (chunk.toolCall && chunk.toolResult) { + setMessages((p) => [ + ...p, + { + type: "tool_result", + content: chunk.toolResult!.success + ? chunk.toolResult!.output || "Success" + : chunk.toolResult!.error || "Error", + timestamp: new Date(), + modeColor: modeInfoRef.current.color, + toolCall: chunk.toolCall, + toolResult: chunk.toolResult, + }, + ]); + if (chunk.toolResult.plan?.questions?.length) { + setActivePlan(chunk.toolResult.plan); + setPqs(initialPlanQuestionsState()); + } + setActiveToolCalls([]); + setTimeout(scrollToBottom, 10); + } + break; + case "error": + contentAccRef.current += `\n${chunk.content || "Unknown error"}`; + setStreamContent(contentAccRef.current); + break; + case "done": + break; + } + } + } catch { + if (!isStale()) { + contentAccRef.current += "\nAn unexpected error occurred."; + setStreamContent(contentAccRef.current); + } } - } - setTimeout(scrollToBottom, 50); + const wasInterrupted = interruptedRunIdRef.current === runId; + const finalContent = sanitizeContent(contentAccRef.current); + if (isStale()) { + contentAccRef.current = ""; + return; + } + + if (!wasInterrupted && finalContent) { + setMessages((p) => [ + ...p, + { type: "assistant", content: finalContent, timestamp: new Date(), modeColor: modeInfoRef.current.color }, + ]); + } + + contentAccRef.current = ""; + if (!isStale()) { + setStreamContent(""); + setStreamReasoning(""); + setActiveToolCalls([]); + setActiveSubagent(null); + } + if (wasInterrupted) { + interruptedRunIdRef.current = null; + } + const nextQueued = queuedMessagesRef.current.shift(); + if (nextQueued) { + setQueuedMessages([...queuedMessagesRef.current]); + isProcessingRef.current = false; + processMessage(nextQueued); + } else { + isProcessingRef.current = false; + if (!isStale()) { + setIsProcessing(false); + } + } + setTimeout(scrollToBottom, 50); + }); }, [agent, scrollToBottom, sessionTitle], ); @@ -599,12 +917,18 @@ export function App({ agent, initialMessage, onExit }: AppProps) { setModelSearchQuery(""); return true; } + if (c === "/remote-control") { + setConnectModalIndex(0); + setShowConnectModal(true); + return true; + } if (c === "/quit" || c === "/exit" || c === "/q") { - onExit ? onExit() : process.exit(0); + handleExit(); + return true; } return false; }, - [onExit, resetToNewSession], + [handleExit, resetToNewSession], ); const handleSlashMenuSelect = useCallback( @@ -620,8 +944,12 @@ export function App({ agent, initialMessage, onExit }: AppProps) { setModelPickerIndex(0); setModelSearchQuery(""); break; + case "remote-control": + setConnectModalIndex(0); + setShowConnectModal(true); + break; case "exit": - onExit ? onExit() : process.exit(0); + handleExit(); break; case "help": setMessages((p) => [ @@ -657,9 +985,11 @@ export function App({ agent, initialMessage, onExit }: AppProps) { break; } }, - [agent, onExit, resetToNewSession], + [agent, handleExit, resetToNewSession], ); + const blockPrompt = showConnectModal || showTelegramTokenModal || showTelegramPairModal; + const showPlanPanel = !!activePlan?.questions?.length; const planQuestions = activePlan?.questions ?? []; const isSinglePlan = planQuestions.length === 1 && planQuestions[0]?.type !== "multiselect"; @@ -848,6 +1178,48 @@ export function App({ agent, initialMessage, onExit }: AppProps) { return; } + if (showTelegramTokenModalRef.current) { + if (key.name === "escape") { + setShowTelegramTokenModal(false); + setTelegramTokenError(null); + return; + } + if (key.name === "return") { + submitTelegramToken(); + } + return; + } + if (showTelegramPairModalRef.current) { + if (key.name === "escape") { + setShowTelegramPairModal(false); + setTelegramPairError(null); + return; + } + if (key.name === "return") { + void submitTelegramPair(); + } + return; + } + if (showConnectModalRef.current) { + if (key.name === "escape") { + setShowConnectModal(false); + return; + } + if (key.name === "up") { + setConnectModalIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === "down") { + setConnectModalIndex((i) => Math.min(CONNECT_CHANNELS.length - 1, i + 1)); + return; + } + if (key.name === "return") { + const ch = CONNECT_CHANNELS[connectModalIndex]; + if (ch?.id === "telegram") beginTelegramFromConnect(); + return; + } + return; + } if (showApiKeyModalRef.current) { if (key.name === "escape") { closeApiKeyModal(); @@ -984,7 +1356,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { inputRef.current?.clear(); setPasteBlocks([]); } else { - onExit ? onExit() : process.exit(0); + handleExit(); } return; } @@ -995,11 +1367,14 @@ export function App({ agent, initialMessage, onExit }: AppProps) { }, [ agent, + beginTelegramFromConnect, closeApiKeyModal, + connectModalIndex, cycleMode, dismissPlan, filteredModelIds, filteredSlashItems, + handleExit, handlePlanSelect, handleSlashMenuSelect, invalidateActiveRun, @@ -1008,7 +1383,8 @@ export function App({ agent, initialMessage, onExit }: AppProps) { isSinglePlan, modelPickerIndex, openApiKeyModal, - onExit, + submitTelegramPair, + submitTelegramToken, planQuestions, planTabCount, pqs, @@ -1158,6 +1534,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} showApiKeyModal={showApiKeyModal} + blockPrompt={blockPrompt} onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks} @@ -1188,6 +1565,7 @@ export function App({ agent, initialMessage, onExit }: AppProps) { showSlashMenu={showSlashMenu} showPlanQuestions={showPlanPanel} showApiKeyModal={showApiKeyModal} + blockPrompt={blockPrompt} onSubmit={handleSubmit} onPaste={handlePaste} pasteBlocks={pasteBlocks} @@ -1239,6 +1617,35 @@ export function App({ agent, initialMessage, onExit }: AppProps) { filteredModels={filteredModels} /> )} + {showConnectModal && ( + + )} + {showTelegramTokenModal && ( + + )} + {showTelegramPairModal && ( + void submitTelegramPair()} + /> + )} ); } @@ -1320,6 +1727,7 @@ function PromptBox({ showSlashMenu, showPlanQuestions, showApiKeyModal, + blockPrompt, onSubmit, onPaste, pasteBlocks: _pasteBlocks, @@ -1338,6 +1746,7 @@ function PromptBox({ showSlashMenu: boolean; showPlanQuestions: boolean; showApiKeyModal: boolean; + blockPrompt?: boolean; onSubmit: () => void; onPaste: (event: PasteEvent) => void; pasteBlocks: { id: number; content: string; lines: number }[]; @@ -1400,7 +1809,7 @@ function PromptBox({