From d41ed94d74084981e663215cad9f72c56979debc Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Tue, 17 Feb 2026 17:04:34 -0800 Subject: [PATCH 1/2] Migrate REPL to React/Ink TUI with streaming support Replace readline-based REPL with a full React/Ink TUI that keeps the input box persistent and interactive while the agent streams responses. Add message queueing so users can type multiple messages during streaming. Includes scrollable terminal history, input history with up/down arrows, and Emacs-style line editing (Ctrl+A/E, Ctrl+U/K/W). Co-Authored-By: Claude Haiku 4.5 --- agent.ts | 27 +++--- agents/general.ts | 6 +- bun.lock | 103 ++++++++++++++++++-- classes/config.ts | 4 + classes/logger.ts | 187 ++++++----------------------------- classes/wrappedAgent.ts | 82 ++-------------- components/App.tsx | 193 +++++++++++++++++++++++++++++++++++++ components/InputBox.tsx | 93 ++++++++++++++++++ components/Message.tsx | 27 ++++++ components/MessageArea.tsx | 45 +++++++++ components/TextInput.tsx | 178 ++++++++++++++++++++++++++++++++++ package.json | 9 +- tests/config.test.ts | 23 +++++ tests/logger.test.ts | 117 +++++++++------------- 14 files changed, 765 insertions(+), 329 deletions(-) create mode 100644 components/App.tsx create mode 100644 components/InputBox.tsx create mode 100644 components/Message.tsx create mode 100644 components/MessageArea.tsx create mode 100644 components/TextInput.tsx diff --git a/agent.ts b/agent.ts index e3675c9..b0ba2ad 100755 --- a/agent.ts +++ b/agent.ts @@ -1,5 +1,7 @@ #!/usr/bin/env bun +import React from "react"; +import { render } from "ink"; import { program } from "@commander-js/extra-typings"; import * as pkg from "./package.json"; import { Config } from "./classes/config"; @@ -9,6 +11,7 @@ import { setOpenAIClient } from "./utils/client"; import { createMcpServer } from "./utils/tools"; import { GeneralAgent } from "./agents/general"; +import { App } from "./components/App"; const config = new Config(); const logger = new Logger(config); @@ -25,13 +28,11 @@ async function cleanup() { } process.on("SIGINT", async () => { - console.log("SIGINT: ๐Ÿ‘‹ Bye!"); await cleanup(); process.exit(0); }); process.on("SIGTERM", async () => { - console.log("SIGTERM: ๐Ÿ‘‹ Bye!"); await cleanup(); process.exit(0); }); @@ -60,15 +61,19 @@ program await mcpServer.connect(); const agent = new GeneralAgent(config, logger); - await agent.interactiveChat( - async (input: string) => { - await agent.chat(input, [mcpServer!]); - }, - message, - async () => { - await cleanup(); - process.exit(0); - }, + + render( + React.createElement(App, { + agent, + mcpServer: mcpServer!, + logger, + config, + initialMessage: message, + onExit: async () => { + await cleanup(); + process.exit(0); + }, + }), ); }); diff --git a/agents/general.ts b/agents/general.ts index 58a701c..6f9a839 100644 --- a/agents/general.ts +++ b/agents/general.ts @@ -2,7 +2,6 @@ import { WrappedAgent } from "../classes/wrappedAgent"; import type { Config } from "../classes/config"; import type { Logger } from "../classes/logger"; import type { MCPServerStreamableHttp } from "@openai/agents"; -import chalk from "chalk"; export class GeneralAgent extends WrappedAgent { constructor(config: Config, logger: Logger) { @@ -17,9 +16,6 @@ You are in a terminal window, and the size of the terminal is ${Bun.env.COLUMNS} } async chat(prompt: string, mcpServers: MCPServerStreamableHttp[] = []) { - this.logger.startSpan(chalk.gray(`Thinking...`)); - const stream = await this.run(prompt, mcpServers); - this.logger.endSpan(); - return stream; + return await this.run(prompt, mcpServers); } } diff --git a/bun.lock b/bun.lock index 08e3dc7..9869c79 100644 --- a/bun.lock +++ b/bun.lock @@ -9,11 +9,16 @@ "@openai/agents": "^0.4.11", "chalk": "^5.4.1", "commander": "^14.0.0", + "ink": "^6.7.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "openai": "^6.20.0", "ora": "^8.2.0", + "react": "^19.2.4", }, "devDependencies": { "@types/bun": "latest", + "@types/react": "^19.2.14", "prettier": "^3.6.2", }, "peerDependencies": { @@ -22,6 +27,8 @@ }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -40,7 +47,7 @@ "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -50,8 +57,14 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], @@ -64,16 +77,24 @@ "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -82,7 +103,7 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], @@ -96,14 +117,20 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -126,7 +153,7 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -144,12 +171,24 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@6.7.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-dhB16KfdTO8yYwF2K0E4wPXpL88tdrjjB6w44AZ0ljSktYoUQQcxccq9KL1vpRhk8JIa0A7B7zvjajHqI42teA=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -176,6 +215,8 @@ "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -190,7 +231,7 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="], @@ -198,6 +239,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], @@ -214,9 +257,13 @@ "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -224,6 +271,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], @@ -242,18 +291,28 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], @@ -266,10 +325,16 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "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=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "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=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -282,10 +347,32 @@ "body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ink-text-input/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "body-parser/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "ora/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + + "wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "body-parser/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], } } diff --git a/classes/config.ts b/classes/config.ts index ff20946..5e7ad24 100644 --- a/classes/config.ts +++ b/classes/config.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import type { LogLevel } from "./logger"; export class Config { @@ -7,6 +8,7 @@ export class Config { public readonly log_color: boolean = true; public readonly log_timestamps: boolean = true; public arcade_gateway_url: string | undefined; + public readonly context_dir: string; constructor() { const openai_api_key = Bun.env.OPENAI_API_KEY; @@ -28,5 +30,7 @@ export class Config { this.log_level = log_level as LogLevel; this.arcade_gateway_url = Bun.env.ARCADE_GATEWAY_URL; + this.context_dir = + Bun.env.CONTEXT_DIR || path.join(process.cwd(), ".context", "arcade"); } } diff --git a/classes/logger.ts b/classes/logger.ts index 6621a7e..3e726c6 100644 --- a/classes/logger.ts +++ b/classes/logger.ts @@ -1,7 +1,5 @@ -import chalk from "chalk"; -import ora, { type Ora } from "ora"; +import { EventEmitter } from "events"; import type { Config } from "./config"; -import { Writable } from "stream"; export enum LogLevel { DEBUG = "debug", @@ -10,48 +8,22 @@ export enum LogLevel { ERROR = "error", } -const SPAN_UPDATE_INTERVAL = 200; - -class LoggerStream extends Writable { - public appendedString: string = ""; - private logger: Logger; - private lastUpdatedAt: number = 0; - - constructor(logger: Logger) { - super(); - this.logger = logger; - } - - _write( - chunk: any, - encoding: string, - callback: (error?: Error | null) => void, - ) { - const stringChunk = String(chunk); - this.appendedString += stringChunk; - if (Date.now() - this.lastUpdatedAt > SPAN_UPDATE_INTERVAL) { - this.logger.streamToSpan(this.appendedString); - this.lastUpdatedAt = Date.now(); - } - callback(); - } +export interface LogEvent { + level: LogLevel; + message: string; + timestamp: string; } -export class Logger { +export class Logger extends EventEmitter { private level: LogLevel; private color: boolean; private includeTimestamps: boolean; - private spanStartTime: number | undefined = undefined; - private spinner: Ora | undefined = undefined; - private toolCallCount: number = 0; - private updateInterval: NodeJS.Timeout | undefined = undefined; - public stream: LoggerStream; constructor(config: Config) { + super(); this.includeTimestamps = config.log_timestamps; this.level = config.log_level; this.color = config.log_color; - this.stream = new LoggerStream(this); } public getTimestamp() { @@ -62,156 +34,59 @@ export class Logger { second: "2-digit", }); - return this.includeTimestamps - ? this.color - ? chalk.gray(`[${timestamp}]`) - : `[${timestamp}]` - : ""; + return this.includeTimestamps ? `[${timestamp}]` : ""; } - private getSpanMarker() { - return this.spanStartTime !== undefined ? " โ”œโ”€" : ""; - } - - private getDuration() { - return Math.round((Date.now() - (this.spanStartTime ?? Date.now())) / 1000); - } - - private getToolCallStats() { - const duration = this.getDuration(); - const toolCallText = ` ๐Ÿ• duration: ${duration}s | ๐Ÿ› ๏ธ tool calls: ${this.toolCallCount}`; - return this.color ? chalk.dim(toolCallText) : toolCallText; - } - - private formatMessage(message: string, color: (text: string) => string) { - return this.color ? color(message) : message; - } - - private logToConsole( - message: string, - level: LogLevel, - color: (text: string) => string, - skipTimestamp: boolean = false, - ) { - // Check if we should skip logging based on current log level - const shouldSkip = + private shouldSkip(level: LogLevel) { + return ( (this.level === LogLevel.ERROR && level !== LogLevel.ERROR) || (this.level === LogLevel.WARN && (level === LogLevel.INFO || level === LogLevel.DEBUG)) || - (this.level === LogLevel.INFO && level === LogLevel.DEBUG); - if (shouldSkip) return; - - const timestamp = skipTimestamp ? "" : this.getTimestamp(); - const spanMarker = this.getSpanMarker(); - const formattedMessage = this.formatMessage(message, color); - const output = `${timestamp}${spanMarker} ${formattedMessage}`; - - if (level === LogLevel.ERROR || level === LogLevel.WARN) { - console.error(output); - } else if (level === LogLevel.DEBUG) { - console.debug(output); - } else { - console.log(output); - } + (this.level === LogLevel.INFO && level === LogLevel.DEBUG) + ); } - result(message: string | undefined) { - if (!message) return; - this.logToConsole(message, LogLevel.INFO, chalk.white, true); + private log(message: string, level: LogLevel) { + if (this.shouldSkip(level)) return; + const event: LogEvent = { + level, + message, + timestamp: this.getTimestamp(), + }; + this.emit("log", event); } info(message: string | undefined) { if (!message) return; - this.logToConsole(message, LogLevel.INFO, chalk.white); + this.log(message, LogLevel.INFO); } warn(message: string | undefined) { if (!message) return; - this.logToConsole(message, LogLevel.WARN, chalk.yellow); + this.log(message, LogLevel.WARN); } error(message: string | undefined) { if (!message) return; - this.logToConsole(message, LogLevel.ERROR, chalk.red); + this.log(message, LogLevel.ERROR); } debug(message: string | undefined) { if (!message) return; - this.logToConsole(message, LogLevel.DEBUG, chalk.gray); + this.log(message, LogLevel.DEBUG); } incrementToolCalls() { - this.toolCallCount++; - this.updateSpanDisplay(); + this.emit("toolCall"); } - private updateSpanDisplay() { - if (!this.spinner) return; - const mainMessage = this.spinner.text.split("\n")[0]; - this.spinner.text = `${mainMessage}\n${this.getToolCallStats()}`; + onLog(callback: (event: LogEvent) => void) { + this.on("log", callback); + return () => this.off("log", callback); } - startSpan(message: string) { - this.info(message); - this.spanStartTime = Date.now(); - this.toolCallCount = 0; - this.stream.appendedString = ""; - this.spinner = ora(this.formatMessage(message, chalk.cyan)).start(); - - this.updateInterval = setInterval(() => this.updateSpanDisplay(), 1000); - } - - updateSpan(message: string, emoji: string) { - if (!this.spinner) return; - - const originalText = this.spinner.text; - const timestamp = this.getTimestamp(); - const spanMarker = this.getSpanMarker(); - const formattedMessage = this.formatMessage(message, chalk.white); - - this.spinner.stopAndPersist({ - text: formattedMessage, - symbol: `${timestamp}${spanMarker} ${emoji}`, - }); - - this.spinner.start(this.formatMessage(originalText, chalk.cyan)); - this.updateSpanDisplay(); - } - - streamToSpan(message: string) { - if (!this.spinner) return; - - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = undefined; - } - - this.spinner.text = `Streaming: \r\n` + message; - } - - endSpan(message?: string) { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = undefined; - } - - this.stream.appendedString = ""; - - const doneMessage = "Done!"; - const timestamp = this.getTimestamp(); - const duration = this.getDuration(); - - this.spinner?.stopAndPersist({ - text: this.formatMessage(`${doneMessage} (${duration}s)`, chalk.cyan), - symbol: `${timestamp} โœ…`, - }); - - this.spinner = undefined; - this.spanStartTime = undefined; - this.toolCallCount = 0; - - if (message) { - this.result(`\r\n${message}\r\n`); - } + onToolCall(callback: () => void) { + this.on("toolCall", callback); + return () => this.off("toolCall", callback); } } diff --git a/classes/wrappedAgent.ts b/classes/wrappedAgent.ts index fbfeb96..fdfae77 100644 --- a/classes/wrappedAgent.ts +++ b/classes/wrappedAgent.ts @@ -9,8 +9,6 @@ import { import { Config } from "./config"; import type { Logger } from "./logger"; -import * as readline from "readline"; -import chalk from "chalk"; export abstract class WrappedAgent { history: AgentInputItem[] = []; @@ -57,85 +55,17 @@ export abstract class WrappedAgent { stream: true, }); - stream - .toTextStream({ compatibleWithNodeStreams: true }) - .pipe(this.logger.stream); - await stream.completed; + return stream; + } - this.logger.endSpan(stream.finalOutput); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async finalizeStream(stream: any) { + await stream.completed; if (stream.history.length > 0) { this.history = stream.history; } - if (stream.finalOutput) { - this.logger.debug(stream.finalOutput); - } - - return stream; - } - - public async interactiveChat( - execMethod: (input: string) => Promise, - initialMessage?: string, - onExit: () => void = () => process.exit(0), - ) { - this.logger.info( - `๐Ÿค– Starting chat session with your agent (${this.config.openai_model})`, - ); - this.logger.info("๐Ÿ’ก Type 'quit', 'exit', or 'bye' to end the session"); - this.logger.info("๐Ÿ’ก Type 'clear' to clear the conversation history"); - - const askQuestion = async ( - questionText: string = `${this.logger.getTimestamp()} ` + - chalk.green("?> "), - ) => { - await new Promise((resolve) => { - // Create a custom output stream that colors user input green - const mutableStdout = new (require("stream").Writable)({ - write: (chunk: any, encoding: any, callback: any) => { - // Color the input text green - process.stdout.write(chalk.green(chunk.toString()), callback); - }, - }); - mutableStdout.columns = process.stdout.columns; - mutableStdout.rows = process.stdout.rows; - - const rl = readline.createInterface({ - input: process.stdin, - output: mutableStdout, - terminal: true, - }); - - rl.question(questionText, async (answer) => { - process.stdout.write("\n"); // Add newline after green input - await handleInput(answer.trim()); - rl.close(); - resolve(true); - }); - }); - - await askQuestion(); - }; - - const handleInput = async (input: string) => { - if (input.toLowerCase() === "quit" || input.toLowerCase() === "exit") { - console.log("๐Ÿ‘‹ Goodbye!"); - onExit?.(); - } - - if (input === "clear") { - this.history = []; - this.logger.info("๐Ÿงน Conversation history cleared!"); - return await execMethod("Hello - we are starting a new conversation."); - } - - return await execMethod(input); - }; - - if (initialMessage) { - await handleInput(initialMessage); - } - await askQuestion(); + return stream.finalOutput; } } diff --git a/components/App.tsx b/components/App.tsx new file mode 100644 index 0000000..c253d7d --- /dev/null +++ b/components/App.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Box, useApp } from "ink"; +import type { MCPServerStreamableHttp } from "@openai/agents"; +import type { GeneralAgent } from "../agents/general.js"; +import type { Logger, LogEvent } from "../classes/logger.js"; +import type { Config } from "../classes/config.js"; +import { MessageArea } from "./MessageArea.js"; +import { InputBox } from "./InputBox.js"; +import type { MessageData } from "./Message.js"; + +interface AppProps { + agent: GeneralAgent; + mcpServer: MCPServerStreamableHttp; + logger: Logger; + config: Config; + initialMessage?: string; + onExit: () => Promise; +} + +export function App({ + agent, + mcpServer, + logger, + config, + initialMessage, + onExit, +}: AppProps) { + const { exit } = useApp(); + const [messages, setMessages] = useState([]); + const [streamingText, setStreamingText] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [toolCallCount, setToolCallCount] = useState(0); + const [startTime, setStartTime] = useState(null); + const processingRef = useRef(false); + const queueRef = useRef([]); + const [queueCount, setQueueCount] = useState(0); + const initialProcessed = useRef(false); + + // Subscribe to logger events + useEffect(() => { + const unsubLog = logger.onLog((event: LogEvent) => { + setMessages((prev) => [ + ...prev, + { + role: "system", + content: event.message, + timestamp: event.timestamp, + }, + ]); + }); + + const unsubTool = logger.onToolCall(() => { + setToolCallCount((prev) => prev + 1); + }); + + return () => { + unsubLog(); + unsubTool(); + }; + }, [logger]); + + const processOne = useCallback( + async (input: string) => { + // Add user message + setMessages((prev) => [ + ...prev, + { + role: "user", + content: input, + timestamp: logger.getTimestamp(), + }, + ]); + + setIsStreaming(true); + setStreamingText(""); + setToolCallCount(0); + setStartTime(Date.now()); + + try { + const stream = await agent.chat(input, [mcpServer]); + const textStream = stream.toTextStream({ + compatibleWithNodeStreams: true, + }); + + let accumulated = ""; + for await (const chunk of textStream) { + accumulated += chunk; + setStreamingText(accumulated); + } + + const finalOutput = await agent.finalizeStream(stream); + const content = finalOutput || accumulated; + + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content, + timestamp: logger.getTimestamp(), + }, + ]); + } catch (err) { + setMessages((prev) => [ + ...prev, + { + role: "system", + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + timestamp: logger.getTimestamp(), + }, + ]); + } finally { + setIsStreaming(false); + setStreamingText(""); + setStartTime(null); + setToolCallCount(0); + } + }, + [agent, mcpServer, logger], + ); + + const drainQueue = useCallback(async () => { + if (processingRef.current) return; + processingRef.current = true; + + while (queueRef.current.length > 0) { + const next = queueRef.current.shift()!; + setQueueCount(queueRef.current.length); + await processOne(next); + } + + processingRef.current = false; + }, [processOne]); + + const enqueueInput = useCallback( + (input: string) => { + queueRef.current.push(input); + setQueueCount(queueRef.current.length); + drainQueue(); + }, + [drainQueue], + ); + + const handleSubmit = useCallback( + (input: string) => { + const lower = input.toLowerCase(); + if (lower === "quit" || lower === "exit" || lower === "bye") { + onExit().then(() => exit()); + return; + } + + if (lower === "clear") { + setMessages([]); + agent.history = []; + setMessages([ + { + role: "system", + content: "Conversation history cleared!", + timestamp: logger.getTimestamp(), + }, + ]); + return; + } + + enqueueInput(input); + }, + [enqueueInput, onExit, exit, agent, logger], + ); + + // Handle initial message + useEffect(() => { + if (initialMessage && !initialProcessed.current) { + initialProcessed.current = true; + enqueueInput(initialMessage); + } + }, [initialMessage, enqueueInput]); + + return ( + + + + + ); +} diff --git a/components/InputBox.tsx b/components/InputBox.tsx new file mode 100644 index 0000000..bf6e565 --- /dev/null +++ b/components/InputBox.tsx @@ -0,0 +1,93 @@ +import { useState, useCallback, useRef } from "react"; +import { Box, Text, useInput } from "ink"; +import TextInput from "./TextInput.js"; +import * as fs from "fs"; +import * as path from "path"; + +interface InputBoxProps { + onSubmit: (input: string) => void; + contextDir: string; + queueCount: number; +} + +const DELIM = "\0"; + +function loadHistory(historyFile: string): string[] { + try { + const content = fs.readFileSync(historyFile, "utf-8"); + return content.split(DELIM).filter((entry) => entry.length > 0); + } catch { + return []; + } +} + +function saveToHistory(historyFile: string, entry: string) { + fs.mkdirSync(path.dirname(historyFile), { recursive: true }); + fs.appendFileSync(historyFile, entry + DELIM); +} + +export function InputBox({ onSubmit, contextDir, queueCount }: InputBoxProps) { + const [value, setValue] = useState(""); + const historyFile = path.join(contextDir, "chat_history.txt"); + const historyRef = useRef(loadHistory(historyFile)); + const indexRef = useRef(-1); + const draftRef = useRef(""); + + const handleSubmit = useCallback( + (input: string) => { + const trimmed = input.trim(); + if (!trimmed) return; + + historyRef.current.push(trimmed); + saveToHistory(historyFile, trimmed); + indexRef.current = -1; + draftRef.current = ""; + setValue(""); + onSubmit(trimmed); + }, + [onSubmit, historyFile], + ); + + useInput((_input, key) => { + const history = historyRef.current; + if (key.upArrow && history.length > 0) { + if (indexRef.current === -1) { + draftRef.current = value; + indexRef.current = history.length - 1; + } else if (indexRef.current > 0) { + indexRef.current--; + } + setValue(history[indexRef.current] ?? ""); + } else if (key.downArrow) { + if (indexRef.current === -1) return; + if (indexRef.current < history.length - 1) { + indexRef.current++; + setValue(history[indexRef.current] ?? ""); + } else { + indexRef.current = -1; + setValue(draftRef.current); + } + } + }); + + return ( + + + + + {queueCount > 0 && ( + + {" "} + {queueCount} message{queueCount > 1 ? "s" : ""} queued + + )} + + ); +} diff --git a/components/Message.tsx b/components/Message.tsx new file mode 100644 index 0000000..8b64082 --- /dev/null +++ b/components/Message.tsx @@ -0,0 +1,27 @@ +import { Box, Text } from "ink"; + +export type MessageRole = "user" | "assistant" | "system"; + +export interface MessageData { + role: MessageRole; + content: string; + timestamp: string; +} + +export function Message({ role, content, timestamp }: MessageData) { + const color = + role === "user" ? "green" : role === "system" ? "gray" : "white"; + const prefix = role === "user" ? "?> " : ""; + + return ( + + + {timestamp} + + {prefix} + {content} + + + + ); +} diff --git a/components/MessageArea.tsx b/components/MessageArea.tsx new file mode 100644 index 0000000..7164ae5 --- /dev/null +++ b/components/MessageArea.tsx @@ -0,0 +1,45 @@ +import { Box, Text } from "ink"; +import Spinner from "ink-spinner"; +import { Message, type MessageData } from "./Message.js"; + +interface MessageAreaProps { + messages: MessageData[]; + streamingText: string; + isStreaming: boolean; + toolCallCount: number; + startTime: number | null; +} + +export function MessageArea({ + messages, + streamingText, + isStreaming, + toolCallCount, + startTime, +}: MessageAreaProps) { + return ( + + {messages.map((msg, i) => ( + + ))} + + {isStreaming && ( + + + + {" "} + + + Thinking... + {startTime && + ` ๐Ÿ• ${Math.round((Date.now() - startTime) / 1000)}s`} + {toolCallCount > 0 && + ` | ๐Ÿ› ๏ธ ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`} + + + {streamingText && {streamingText}} + + )} + + ); +} diff --git a/components/TextInput.tsx b/components/TextInput.tsx new file mode 100644 index 0000000..5c0ea55 --- /dev/null +++ b/components/TextInput.tsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from "react"; +import { Text, useInput } from "ink"; +import chalk from "chalk"; + +interface TextInputProps { + value: string; + onChange: (value: string) => void; + onSubmit?: (value: string) => void; + placeholder?: string; + focus?: boolean; + mask?: string; + showCursor?: boolean; + highlightPastedText?: boolean; +} + +export default function TextInput({ + value: originalValue, + placeholder = "", + focus = true, + mask, + highlightPastedText = false, + showCursor = true, + onChange, + onSubmit, +}: TextInputProps) { + const [state, setState] = useState({ + cursorOffset: (originalValue || "").length, + cursorWidth: 0, + }); + + const { cursorOffset, cursorWidth } = state; + + useEffect(() => { + setState((previousState) => { + if (!focus || !showCursor) { + return previousState; + } + const newValue = originalValue || ""; + if (previousState.cursorOffset > newValue.length - 1) { + return { + cursorOffset: newValue.length, + cursorWidth: 0, + }; + } + return previousState; + }); + }, [originalValue, focus, showCursor]); + + const cursorActualWidth = highlightPastedText ? cursorWidth : 0; + const value = mask ? mask.repeat(originalValue.length) : originalValue; + let renderedValue = value; + let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined; + + if (showCursor && focus) { + renderedPlaceholder = + placeholder.length > 0 + ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) + : chalk.inverse(" "); + renderedValue = value.length > 0 ? "" : chalk.inverse(" "); + let i = 0; + for (const char of value) { + renderedValue += + i >= cursorOffset - cursorActualWidth && i <= cursorOffset + ? chalk.inverse(char) + : char; + i++; + } + if (value.length > 0 && cursorOffset === value.length) { + renderedValue += chalk.inverse(" "); + } + } + + useInput( + (input, key) => { + if ( + key.upArrow || + key.downArrow || + (key.ctrl && input === "c") || + key.tab || + (key.shift && key.tab) + ) { + return; + } + + if (key.return) { + if (onSubmit) { + onSubmit(originalValue); + } + return; + } + + let nextCursorOffset = cursorOffset; + let nextValue = originalValue; + let nextCursorWidth = 0; + + if (key.ctrl && input === "a") { + // Ctrl+A: move cursor to beginning + setState({ cursorOffset: 0, cursorWidth: 0 }); + return; + } else if (key.ctrl && input === "e") { + // Ctrl+E: move cursor to end + setState({ cursorOffset: originalValue.length, cursorWidth: 0 }); + return; + } else if (key.ctrl && input === "u") { + // Ctrl+U: delete from cursor to beginning + nextValue = originalValue.slice(cursorOffset); + nextCursorOffset = 0; + } else if (key.ctrl && input === "k") { + // Ctrl+K: delete from cursor to end + nextValue = originalValue.slice(0, cursorOffset); + } else if (key.ctrl && input === "w") { + // Ctrl+W: delete word before cursor + const before = originalValue.slice(0, cursorOffset); + const trimmed = before.replace(/\s+$/, ""); + const lastSpace = trimmed.lastIndexOf(" "); + const deleteFrom = lastSpace === -1 ? 0 : lastSpace + 1; + nextValue = + originalValue.slice(0, deleteFrom) + + originalValue.slice(cursorOffset); + nextCursorOffset = deleteFrom; + } else if (key.leftArrow) { + if (showCursor) { + nextCursorOffset--; + } + } else if (key.rightArrow) { + if (showCursor) { + nextCursorOffset++; + } + } else if (key.backspace || key.delete) { + if (cursorOffset > 0) { + nextValue = + originalValue.slice(0, cursorOffset - 1) + + originalValue.slice(cursorOffset, originalValue.length); + nextCursorOffset--; + } + } else if (key.ctrl) { + // Ignore other ctrl combos so they don't insert characters + return; + } else { + nextValue = + originalValue.slice(0, cursorOffset) + + input + + originalValue.slice(cursorOffset, originalValue.length); + nextCursorOffset += input.length; + if (input.length > 1) { + nextCursorWidth = input.length; + } + } + + if (nextCursorOffset < 0) { + nextCursorOffset = 0; + } + if (nextCursorOffset > nextValue.length) { + nextCursorOffset = nextValue.length; + } + + setState({ + cursorOffset: nextCursorOffset, + cursorWidth: nextCursorWidth, + }); + + if (nextValue !== originalValue) { + onChange(nextValue); + } + }, + { isActive: focus }, + ); + + return ( + + {placeholder + ? value.length > 0 + ? renderedValue + : renderedPlaceholder + : renderedValue} + + ); +} diff --git a/package.json b/package.json index 7bc7849..d5d8fe5 100644 --- a/package.json +++ b/package.json @@ -15,18 +15,23 @@ "format": "prettier --write .", "lint": "prettier --check .", "compile": "bun build agent.ts --compile --outfile agent", - "clear": "rm -rf .context/arcade" + "clear": "rm -rf ${CONTEXT_DIR:-.context/arcade}" }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@openai/agents": "^0.4.11", "chalk": "^5.4.1", "commander": "^14.0.0", + "ink": "^6.7.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "openai": "^6.20.0", - "ora": "^8.2.0" + "ora": "^8.2.0", + "react": "^19.2.4" }, "devDependencies": { "@types/bun": "latest", + "@types/react": "^19.2.14", "prettier": "^3.6.2" } } diff --git a/tests/config.test.ts b/tests/config.test.ts index b177487..714e51c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -84,6 +84,27 @@ describe("Config", () => { expect(config.arcade_gateway_url).toBeUndefined(); }); + it("should default context_dir to .context/arcade", async () => { + Bun.env.OPENAI_API_KEY = "test-openai-key"; + Bun.env.OPENAI_MODEL = "gpt-4-turbo"; + Bun.env.LOG_LEVEL = "info"; + + const { Config } = await import("../classes/config"); + const config = new Config(); + expect(config.context_dir).toContain(".context/arcade"); + }); + + it("should allow CONTEXT_DIR override", async () => { + Bun.env.OPENAI_API_KEY = "test-openai-key"; + Bun.env.OPENAI_MODEL = "gpt-4-turbo"; + Bun.env.LOG_LEVEL = "info"; + Bun.env.CONTEXT_DIR = "/tmp/custom-context"; + + const { Config } = await import("../classes/config"); + const config = new Config(); + expect(config.context_dir).toBe("/tmp/custom-context"); + }); + it("should have default values for optional properties", async () => { // Set up all required environment variables Bun.env.OPENAI_API_KEY = "test-openai-key"; @@ -164,6 +185,7 @@ describe("Config", () => { expect(config).toHaveProperty("log_color"); expect(config).toHaveProperty("log_timestamps"); expect(config).toHaveProperty("arcade_gateway_url"); + expect(config).toHaveProperty("context_dir"); }); it("should have correct property types", () => { @@ -173,6 +195,7 @@ describe("Config", () => { expect(typeof config.log_color).toBe("boolean"); expect(typeof config.log_timestamps).toBe("boolean"); expect(typeof config.arcade_gateway_url).toBe("string"); + expect(typeof config.context_dir).toBe("string"); }); }); }); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 05281a0..5edb88f 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -2,11 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; describe("Logger", () => { let originalEnv: NodeJS.ProcessEnv; - let originalConsole: any; beforeEach(() => { originalEnv = { ...process.env }; - originalConsole = { ...console }; // Set up required environment variables process.env.OPENAI_API_KEY = "test-openai-key"; @@ -14,18 +12,10 @@ describe("Logger", () => { process.env.LOG_LEVEL = "info"; process.env.ARCADE_API_KEY = "test-arcade-key"; process.env.USER_ID = "test-user-id"; - - // Mock console methods to capture output - console.log = () => {}; - console.error = () => {}; - console.debug = () => {}; }); afterEach(() => { process.env = originalEnv; - console.log = originalConsole.log; - console.error = originalConsole.error; - console.debug = originalConsole.debug; }); describe("constructor", () => { @@ -41,18 +31,6 @@ describe("Logger", () => { expect(logger).toHaveProperty("debug"); expect(logger).toHaveProperty("error"); expect(logger).toHaveProperty("warn"); - expect(logger).toHaveProperty("stream"); - }); - - it("should create LoggerStream", async () => { - const { Config } = await import("../classes/config"); - const { Logger } = await import("../classes/logger"); - - const config = new Config(); - const logger = new Logger(config); - - expect(logger.stream).toBeDefined(); - expect(typeof logger.stream.write).toBe("function"); }); }); @@ -93,6 +71,33 @@ describe("Logger", () => { expect(() => logger.error(undefined)).not.toThrow(); expect(() => logger.warn(undefined)).not.toThrow(); }); + + it("should emit log events", async () => { + const events: any[] = []; + logger.onLog((event: any) => events.push(event)); + + logger.info("test info"); + logger.warn("test warn"); + + expect(events).toHaveLength(2); + expect(events[0]?.message).toBe("test info"); + expect(events[0]?.level).toBe("info"); + expect(events[1]?.message).toBe("test warn"); + expect(events[1]?.level).toBe("warn"); + }); + + it("should emit events with correct structure", () => { + const events: any[] = []; + logger.onLog((event: any) => events.push(event)); + + logger.info("test message"); + + expect(events).toHaveLength(1); + expect(events[0]).toHaveProperty("level"); + expect(events[0]).toHaveProperty("message"); + expect(events[0]).toHaveProperty("timestamp"); + expect(events[0]?.message).toBe("test message"); + }); }); describe("getTimestamp method", () => { @@ -113,21 +118,16 @@ describe("Logger", () => { it("should return a timestamp string", () => { const timestamp = logger.getTimestamp(); expect(typeof timestamp).toBe("string"); - // Should match the format [HH:MM:SS] with optional ANSI color codes - expect(timestamp).toMatch( - /^(\u001b\[\d+m)?\[\d{2}:\d{2}:\d{2}\](\u001b\[\d+m)?$/, - ); + expect(timestamp).toMatch(/^\[\d{2}:\d{2}:\d{2}\]$/); }); it("should include timestamp when timestamps are enabled", () => { const timestamp = logger.getTimestamp(); - expect(timestamp).toMatch( - /^(\u001b\[\d+m)?\[\d{2}:\d{2}:\d{2}\](\u001b\[\d+m)?$/, - ); + expect(timestamp).toMatch(/^\[\d{2}:\d{2}:\d{2}\]$/); }); }); - describe("stream property", () => { + describe("tool call events", () => { let logger: any; beforeEach(async () => { @@ -138,54 +138,30 @@ describe("Logger", () => { logger = new Logger(config); }); - it("should have a stream property", () => { - expect(logger).toHaveProperty("stream"); - expect(logger.stream).toBeDefined(); - }); - - it("should have write method on stream", () => { - expect(typeof logger.stream.write).toBe("function"); - }); - - it("should handle stream writes", () => { - expect(() => logger.stream.write("test data")).not.toThrow(); + it("should have incrementToolCalls method", () => { + expect(typeof logger.incrementToolCalls).toBe("function"); + expect(() => logger.incrementToolCalls()).not.toThrow(); }); - }); - - describe("span methods", () => { - let logger: any; - - beforeEach(async () => { - const { Config } = await import("../classes/config"); - const { Logger } = await import("../classes/logger"); - const config = new Config(); - logger = new Logger(config); - }); + it("should emit toolCall events", () => { + let called = 0; + logger.onToolCall(() => called++); - it("should have startSpan method", () => { - expect(typeof logger.startSpan).toBe("function"); - expect(() => logger.startSpan("test span")).not.toThrow(); - }); + logger.incrementToolCalls(); + logger.incrementToolCalls(); - it("should have updateSpan method", () => { - expect(typeof logger.updateSpan).toBe("function"); - expect(() => logger.updateSpan("test update", "๐Ÿ”„")).not.toThrow(); + expect(called).toBe(2); }); - it("should have endSpan method", () => { - expect(typeof logger.endSpan).toBe("function"); - expect(() => logger.endSpan("test output")).not.toThrow(); - }); + it("should allow unsubscribing from events", () => { + let called = 0; + const unsub = logger.onToolCall(() => called++); - it("should have streamToSpan method", () => { - expect(typeof logger.streamToSpan).toBe("function"); - expect(() => logger.streamToSpan("test stream")).not.toThrow(); - }); + logger.incrementToolCalls(); + unsub(); + logger.incrementToolCalls(); - it("should have incrementToolCalls method", () => { - expect(typeof logger.incrementToolCalls).toBe("function"); - expect(() => logger.incrementToolCalls()).not.toThrow(); + expect(called).toBe(1); }); }); @@ -199,7 +175,6 @@ describe("Logger", () => { const config = new Config(); const logger = new Logger(config); - // Should not throw for any log level expect(() => logger.debug("debug message")).not.toThrow(); expect(() => logger.info("info message")).not.toThrow(); expect(() => logger.warn("warn message")).not.toThrow(); From 9ea71d84acb8903ad07a664227cc30c124a721a6 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Tue, 17 Feb 2026 17:06:49 -0800 Subject: [PATCH 2/2] Fix compile by excluding react-devtools-core from bundle Ink has an optional import of react-devtools-core that Bun's bundler can't resolve. Mark it as external. Also remove unused ora dependency. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index d5d8fe5..82d7cc2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "format": "prettier --write .", "lint": "prettier --check .", - "compile": "bun build agent.ts --compile --outfile agent", + "compile": "bun build agent.ts --compile --outfile agent --external react-devtools-core", "clear": "rm -rf ${CONTEXT_DIR:-.context/arcade}" }, "dependencies": { @@ -26,7 +26,6 @@ "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "openai": "^6.20.0", - "ora": "^8.2.0", "react": "^19.2.4" }, "devDependencies": {