diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js index b865bc95..2d55efd4 100755 --- a/apps/cli/bin/app.js +++ b/apps/cli/bin/app.js @@ -1,7 +1,8 @@ #!/usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { app, modelConfig, updateState, importExample, listExamples, exportWorkflow } from '../dist/app.js'; +import { app, modelConfig, importExample, listExamples, exportWorkflow } from '../dist/app.js'; +import { runTui } from '../dist/tui/index.js'; yargs(hideBin(process.argv)) @@ -36,6 +37,20 @@ yargs(hideBin(process.argv)) }); } ) + .command( + "ui", + "Launch the interactive Rowboat dashboard", + (y) => y + .option("server-url", { + type: "string", + description: "Rowboat server base URL", + }), + (argv) => { + runTui({ + serverUrl: argv.serverUrl, + }); + } + ) .command( "import", "Import an example workflow (--example) or custom workflow from file (--file)", diff --git a/apps/cli/package-lock.json b/apps/cli/package-lock.json index 90c2818a..c528c8fd 100644 --- a/apps/cli/package-lock.json +++ b/apps/cli/package-lock.json @@ -20,11 +20,17 @@ "@openrouter/ai-sdk-provider": "^1.2.6", "ai": "^5.0.102", "awilix": "^12.0.5", + "eventsource-parser": "^1.1.2", "hono": "^4.10.7", "hono-openapi": "^1.1.1", + "ink": "^5.1.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", "ollama-ai-provider-v2": "^1.5.4", + "react": "^18.3.1", "yargs": "^18.0.0", "zod": "^4.1.12" }, @@ -33,6 +39,7 @@ }, "devDependencies": { "@types/node": "^24.9.1", + "@types/react": "^18.3.12", "typescript": "^5.9.3" } }, @@ -146,6 +153,28 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils/node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/@hono/node-server": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", @@ -205,6 +234,15 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -385,6 +423,24 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@vercel/oidc": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", @@ -458,6 +514,21 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -482,6 +553,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/awilix": { "version": "12.0.5", "resolved": "https://registry.npmjs.org/awilix/-/awilix-12.0.5.tgz", @@ -579,6 +662,89 @@ "tslib": "^2.0.3" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -593,6 +759,18 @@ "node": ">=20" } }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -615,6 +793,15 @@ "node": ">= 0.6" } }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -660,6 +847,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -721,6 +915,18 @@ "node": ">= 0.8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -751,6 +957,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -766,6 +982,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -788,6 +1013,15 @@ } }, "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/eventsource/node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", @@ -901,6 +1135,21 @@ "reusify": "^1.0.4" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1134,12 +1383,122 @@ "url": "https://opencollective.com/express" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-select-input": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "license": "MIT", + "dependencies": { + "figures": "^6.1.0", + "to-rotated": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1158,6 +1517,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1170,6 +1541,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1185,6 +1571,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1200,6 +1598,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -1221,6 +1625,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -1307,6 +1723,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1408,6 +1833,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -1434,6 +1874,15 @@ "tslib": "^2.0.3" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1563,6 +2012,34 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1572,6 +2049,22 @@ "node": ">=0.10.0" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -1627,6 +2120,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", @@ -1763,6 +2265,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1816,6 +2367,18 @@ "node": ">=8.0" } }, + "node_modules/to-rotated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1831,6 +2394,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1899,6 +2474,21 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -1922,6 +2512,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1957,6 +2568,12 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", diff --git a/apps/cli/package.json b/apps/cli/package.json index 7b42ffb9..235bbfca 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,6 +21,7 @@ "description": "", "devDependencies": { "@types/node": "^24.9.1", + "@types/react": "^18.3.12", "typescript": "^5.9.3" }, "dependencies": { @@ -35,11 +36,17 @@ "@openrouter/ai-sdk-provider": "^1.2.6", "ai": "^5.0.102", "awilix": "^12.0.5", + "eventsource-parser": "^1.1.2", "hono": "^4.10.7", "hono-openapi": "^1.1.1", + "ink": "^5.1.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", "ollama-ai-provider-v2": "^1.5.4", + "react": "^18.3.1", "yargs": "^18.0.0", "zod": "^4.1.12" } diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 205ee899..a2391f0e 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -9,8 +9,7 @@ import { RunEvent } from "./entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; import { ToolCallPart } from "./entities/message.js"; import { Agent } from "./agents/agents.js"; -import { McpServerConfig } from "./mcp/mcp.js"; -import { McpServerDefinition } from "./mcp/mcp.js"; +import { McpServerConfig, McpServerDefinition } from "./mcp/schema.js"; import { Example } from "./entities/example.js"; import { z } from "zod"; import { Flavor } from "./models/models.js"; diff --git a/apps/cli/src/application/lib/builtin-tools.ts b/apps/cli/src/application/lib/builtin-tools.ts index b7839079..0bf11cdc 100644 --- a/apps/cli/src/application/lib/builtin-tools.ts +++ b/apps/cli/src/application/lib/builtin-tools.ts @@ -7,7 +7,7 @@ import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; import container from "../../di/container.js"; import { IMcpConfigRepo } from "../..//mcp/repo.js"; -import { McpServerDefinition } from "../../mcp/mcp.js"; +import { McpServerDefinition } from "../../mcp/schema.js"; const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), diff --git a/apps/cli/src/entities/example.ts b/apps/cli/src/entities/example.ts index 92e2f3f7..779ffe75 100644 --- a/apps/cli/src/entities/example.ts +++ b/apps/cli/src/entities/example.ts @@ -1,6 +1,6 @@ import z from "zod" import { Agent } from "../agents/agents.js" -import { McpServerDefinition } from "../mcp/mcp.js"; +import { McpServerDefinition } from "../mcp/schema.js"; export const Example = z.object({ id: z.string(), @@ -9,4 +9,4 @@ export const Example = z.object({ entryAgent: z.string().optional(), agents: z.array(Agent).optional(), mcpServers: z.record(z.string(), McpServerDefinition).optional(), -}); \ No newline at end of file +}); diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 6e38bd98..7131de12 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -6,63 +6,12 @@ import z from "zod"; import { IMcpConfigRepo } from "./repo.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; - -export const StdioMcpServerConfig = z.object({ - type: z.literal("stdio").optional(), - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), -}); - -export const HttpMcpServerConfig = z.object({ - type: z.literal("http").optional(), - url: z.string(), - headers: z.record(z.string(), z.string()).optional(), -}); - -export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]); - -export const McpServerConfig = z.object({ - mcpServers: z.record(z.string(), McpServerDefinition), -}); - -const connectionState = z.enum(["disconnected", "connected", "error"]); - -export const McpServerList = z.object({ - mcpServers: z.record(z.string(), z.object({ - config: McpServerDefinition, - state: connectionState, - error: z.string().nullable(), - })), -}); - -/* - inputSchema: { - [x: string]: unknown; - type: "object"; - properties?: Record | undefined; - required?: string[] | undefined; - }; -*/ -export const Tool = z.object({ - name: z.string(), - description: z.string().optional(), - inputSchema: z.object({ - type: z.literal("object"), - properties: z.record(z.string(), z.any()).optional(), - required: z.array(z.string()).optional(), - }), - outputSchema: z.object({ - type: z.literal("object"), - properties: z.record(z.string(), z.any()).optional(), - required: z.array(z.string()).optional(), - }).optional(), -}) - -export const ListToolsResponse = z.object({ - tools: z.array(Tool), - nextCursor: z.string().optional(), -}); +import { + connectionState, + ListToolsResponse, + McpServerDefinition, + McpServerList, +} from "./schema.js"; type mcpState = { state: z.infer, @@ -171,4 +120,4 @@ export async function executeTool(serverName: string, toolName: string, input: a arguments: input, }); return result; -} \ No newline at end of file +} diff --git a/apps/cli/src/mcp/repo.ts b/apps/cli/src/mcp/repo.ts index d43569af..fbb4106e 100644 --- a/apps/cli/src/mcp/repo.ts +++ b/apps/cli/src/mcp/repo.ts @@ -1,6 +1,5 @@ import { WorkDir } from "../config/config.js"; -import { McpServerConfig } from "./mcp.js"; -import { McpServerDefinition } from "./mcp.js"; +import { McpServerConfig, McpServerDefinition } from "./schema.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; @@ -42,4 +41,4 @@ export class FSMcpConfigRepo implements IMcpConfigRepo { delete conf.mcpServers[serverName]; await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); } -} \ No newline at end of file +} diff --git a/apps/cli/src/mcp/schema.ts b/apps/cli/src/mcp/schema.ts new file mode 100644 index 00000000..2637397f --- /dev/null +++ b/apps/cli/src/mcp/schema.ts @@ -0,0 +1,50 @@ +import z from "zod"; + +export const StdioMcpServerConfig = z.object({ + type: z.literal("stdio").optional(), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +export const HttpMcpServerConfig = z.object({ + type: z.literal("http").optional(), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), +}); + +export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]); + +export const McpServerConfig = z.object({ + mcpServers: z.record(z.string(), McpServerDefinition), +}); + +export const connectionState = z.enum(["disconnected", "connected", "error"]); + +export const McpServerList = z.object({ + mcpServers: z.record(z.string(), z.object({ + config: McpServerDefinition, + state: connectionState, + error: z.string().nullable(), + })), +}); + +export const Tool = z.object({ + name: z.string(), + description: z.string().optional(), + inputSchema: z.object({ + type: z.literal("object"), + properties: z.record(z.string(), z.any()).optional(), + required: z.array(z.string()).optional(), + }), + outputSchema: z.object({ + type: z.literal("object"), + properties: z.record(z.string(), z.any()).optional(), + required: z.array(z.string()).optional(), + }).optional(), +}); + +export const ListToolsResponse = z.object({ + tools: z.array(Tool), + nextCursor: z.string().optional(), +}); diff --git a/apps/cli/src/server.ts b/apps/cli/src/server.ts index fde66419..e7ff4a53 100644 --- a/apps/cli/src/server.ts +++ b/apps/cli/src/server.ts @@ -4,8 +4,8 @@ import { streamSSE } from 'hono/streaming' import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi" import z from 'zod'; import container from './di/container.js'; -import { executeTool, listServers, listTools, ListToolsResponse, McpServerList } from "./mcp/mcp.js"; -import { McpServerDefinition } from "./mcp/mcp.js"; +import { executeTool, listServers, listTools } from "./mcp/mcp.js"; +import { ListToolsResponse, McpServerDefinition, McpServerList } from "./mcp/schema.js"; import { IMcpConfigRepo } from './mcp/repo.js'; import { IModelConfigRepo } from './models/repo.js'; import { ModelConfig, Provider } from "./models/models.js"; @@ -665,4 +665,4 @@ serve({ // PUT /skills/ // DELETE /skills/ -// GET /sse \ No newline at end of file +// GET /sse diff --git a/apps/cli/src/tui/api.ts b/apps/cli/src/tui/api.ts new file mode 100644 index 00000000..b54534ac --- /dev/null +++ b/apps/cli/src/tui/api.ts @@ -0,0 +1,190 @@ +import { createParser } from "eventsource-parser"; +import { Agent } from "../agents/agents.js"; +import { AskHumanResponsePayload, Run, ToolPermissionAuthorizePayload } from "../runs/runs.js"; +import { ListRunsResponse } from "../runs/repo.js"; +import { ModelConfig } from "../models/models.js"; +import { RunEvent } from "../entities/run-events.js"; +import z from "zod"; + +const HealthSchema = z.object({ + status: z.literal("ok"), +}); + +const MessageResponse = z.object({ + messageId: z.string(), +}); + +const SuccessSchema = z.object({ + success: z.literal(true), +}); + +type RunEventType = z.infer; + +export interface RowboatApiOptions { + baseUrl?: string; +} + +export class RowboatApi { + readonly baseUrl: string; + constructor({ baseUrl }: RowboatApiOptions = {}) { + this.baseUrl = baseUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000"; + } + + private buildUrl(pathname: string): string { + return new URL(pathname, this.baseUrl).toString(); + } + + private async request(pathname: string, init?: RequestInit): Promise { + const headers: Record = { + Accept: "application/json", + }; + if (init?.headers instanceof Headers) { + init.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(init?.headers)) { + for (const [key, value] of init.headers) { + headers[key] = value; + } + } else if (init?.headers) { + Object.assign(headers, init.headers as Record); + } + if (init?.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + const response = await fetch(this.buildUrl(pathname), { + method: "GET", + ...init, + headers, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Request to ${pathname} failed (${response.status}): ${text || response.statusText}`); + } + if (response.status === 204) { + return undefined as T; + } + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; + } + + async getHealth(): Promise> { + const payload = await this.request("/health"); + return HealthSchema.parse(payload); + } + + async getModelConfig(): Promise> { + const payload = await this.request("/models"); + return ModelConfig.parse(payload); + } + + async listAgents(): Promise[]> { + const payload = await this.request("/agents"); + return Agent.array().parse(payload); + } + + async listRuns(cursor?: string): Promise> { + const searchParams = new URLSearchParams(); + if (cursor) { + searchParams.set("cursor", cursor); + } + const payload = await this.request(`/runs${searchParams.size ? `?${searchParams.toString()}` : ""}`); + return ListRunsResponse.parse(payload); + } + + async getRun(runId: string): Promise> { + const payload = await this.request(`/runs/${encodeURIComponent(runId)}`); + return Run.parse(payload); + } + + async createRun(agentId: string): Promise> { + const payload = await this.request("/runs/new", { + method: "POST", + body: JSON.stringify({ agentId }), + }); + return Run.parse(payload); + } + + async sendMessage(runId: string, message: string): Promise> { + const payload = await this.request(`/runs/${encodeURIComponent(runId)}/messages/new`, { + method: "POST", + body: JSON.stringify({ message }), + }); + return MessageResponse.parse(payload); + } + + async authorizeTool(runId: string, payload: z.infer): Promise { + const response = await this.request(`/runs/${encodeURIComponent(runId)}/permissions/authorize`, { + method: "POST", + body: JSON.stringify(payload), + }); + SuccessSchema.parse(response); + } + + async replyToHuman(runId: string, requestId: string, payload: z.infer): Promise { + const response = await this.request(`/runs/${encodeURIComponent(runId)}/human-input-requests/${encodeURIComponent(requestId)}/reply`, { + method: "POST", + body: JSON.stringify(payload), + }); + SuccessSchema.parse(response); + } + + async stopRun(runId: string): Promise { + const response = await this.request(`/runs/${encodeURIComponent(runId)}/stop`, { + method: "POST", + }); + SuccessSchema.parse(response); + } + + async subscribeToEvents(onEvent: (event: RunEventType) => void, onError?: (error: Error) => void): Promise<() => void> { + const controller = new AbortController(); + const response = await fetch(this.buildUrl("/stream"), { + method: "GET", + headers: { + Accept: "text/event-stream", + }, + signal: controller.signal, + }); + if (!response.ok || !response.body) { + throw new Error(`Failed to subscribe to event stream (${response.status})`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const parser = createParser((event) => { + if (event.type !== "event" || !event.data) { + return; + } + try { + const parsed = RunEvent.parse(JSON.parse(event.data)); + onEvent(parsed); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } + }); + + (async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + parser.feed(decoder.decode(value, { stream: true })); + } + } catch (error) { + if (controller.signal.aborted) { + return; + } + onError?.(error instanceof Error ? error : new Error(String(error))); + } + })(); + + return () => { + controller.abort(); + reader.cancel().catch(() => undefined); + }; + } +} diff --git a/apps/cli/src/tui/index.tsx b/apps/cli/src/tui/index.tsx new file mode 100644 index 00000000..7e3dd3c0 --- /dev/null +++ b/apps/cli/src/tui/index.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { render } from "ink"; +import { RowboatTui } from "./ui.js"; + +export function runTui({ serverUrl }: { serverUrl?: string }) { + const baseUrl = serverUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000"; + render(); +} diff --git a/apps/cli/src/tui/ui.tsx b/apps/cli/src/tui/ui.tsx new file mode 100644 index 00000000..860db411 --- /dev/null +++ b/apps/cli/src/tui/ui.tsx @@ -0,0 +1,1174 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import Spinner from "ink-spinner"; +import SelectInput from "ink-select-input"; +import TextInput from "ink-text-input"; +import z from "zod"; +import { RowboatApi } from "./api.js"; +import { ModelConfig } from "../models/models.js"; +import { Agent } from "../agents/agents.js"; +import { ListRunsResponse } from "../runs/repo.js"; +import { Run } from "../runs/runs.js"; +import { RunEvent } from "../entities/run-events.js"; + +type AgentType = z.infer; +type ModelConfigType = z.infer; +type RunSummary = z.infer["runs"][number]; +type RunType = z.infer; +type RunEventType = z.infer; + +type Toast = { + type: "info" | "error" | "success"; + text: string; +}; + +type ChatLine = { + text: string; + color?: string; + variant?: "user" | "assistant" | "streaming" | "thinking" | "system" | "tool" | "other"; +}; + +type ModalState = + | { type: "agent-picker" } + | { + type: "human-response"; + runId: string; + requestId: string; + subflow: string[]; + prompt: string; + value: string; + submitting: boolean; + }; + +type ConnectionState = "connecting" | "ready" | "error"; +type FocusTarget = "chat" | "sidebar"; + +type PendingPermission = { + toolCallId: string; + toolName: string; + args: unknown; + subflow: string[]; +}; + +type PendingHuman = { + toolCallId: string; + query: string; + subflow: string[]; +}; + +type SidebarItem = + | { kind: "action"; action: "new-copilot" | "new-agent"; label: string; hint?: string } + | { kind: "run"; run: RunSummary; status: { label: string; color: string } }; + +export function RowboatTui({ serverUrl }: { serverUrl: string }) { + const api = useMemo(() => new RowboatApi({ baseUrl: serverUrl }), [serverUrl]); + const { exit } = useApp(); + const { stdout } = useStdout(); + + const [connectionState, setConnectionState] = useState("connecting"); + const [connectionError, setConnectionError] = useState(null); + const [modelConfig, setModelConfig] = useState(null); + const [agents, setAgents] = useState([]); + const [runs, setRuns] = useState([]); + const [runsCursor, setRunsCursor] = useState(); + const [runsLoading, setRunsLoading] = useState(false); + const [runDetails, setRunDetails] = useState>({}); + const [activeRunId, setActiveRunId] = useState(null); + const [draftAgent, setDraftAgent] = useState("copilot"); + const [composerValue, setComposerValue] = useState(""); + const [composerBusy, setComposerBusy] = useState(false); + const [focusTarget, setFocusTarget] = useState("chat"); + const [sidebarIndex, setSidebarIndex] = useState(0); + const [toast, setToast] = useState(null); + const [modal, setModal] = useState(null); + const [streamError, setStreamError] = useState(null); + const [eventStreamActive, setEventStreamActive] = useState(false); + const [chatScrollOffset, setChatScrollOffset] = useState(0); + + const selectedRun = activeRunId ? runDetails[activeRunId] : undefined; + const pendingPermissions = useMemo(() => derivePendingPermissions(selectedRun), [selectedRun]); + const pendingHuman = useMemo(() => derivePendingHuman(selectedRun), [selectedRun]); + + const defaultCopilot = useMemo(() => { + return "copilot"; + }, [agents]); + + useEffect(() => { + if (!agents.length) { + return; + } + setDraftAgent((prev) => prev || defaultCopilot); + }, [agents, defaultCopilot]); + + const runStatusMap = useMemo(() => { + const map: Record = {}; + for (const summary of runs) { + map[summary.id] = getRunStatus(runDetails[summary.id]); + } + return map; + }, [runs, runDetails]); + + const sidebarItems: SidebarItem[] = useMemo(() => { + const items: SidebarItem[] = [ + { + kind: "action", + action: "new-copilot", + label: `+ New chat (${defaultCopilot})`, + hint: "Ctrl+N", + }, + { + kind: "action", + action: "new-agent", + label: "+ New chat (choose agent)", + hint: "Ctrl+G", + }, + ]; + for (const run of runs) { + items.push({ + kind: "run", + run, + status: runStatusMap[run.id] ?? { label: "loading…", color: "gray" }, + }); + } + return items; + }, [defaultCopilot, runStatusMap, runs]); + + useEffect(() => { + setSidebarIndex((idx) => { + if (sidebarItems.length === 0) { + return 0; + } + return Math.min(idx, sidebarItems.length - 1); + }); + }, [sidebarItems.length]); + + const showToast = useCallback((next: Toast) => { + setToast(next); + }, []); + + useEffect(() => { + if (!toast) { + return; + } + const timer = setTimeout(() => { + setToast(null); + }, 4000); + return () => clearTimeout(timer); + }, [toast]); + + const loadInitial = useCallback(async () => { + setConnectionState("connecting"); + setConnectionError(null); + try { + const [health, config, agentList, runsResponse] = await Promise.all([ + api.getHealth(), + api.getModelConfig(), + api.listAgents(), + api.listRuns(), + ]); + if (health.status !== "ok") { + throw new Error("Server is not healthy"); + } + setModelConfig(config); + setAgents(agentList); + setRuns(runsResponse.runs); + setRunsCursor(runsResponse.nextCursor); + setConnectionState("ready"); + } catch (error) { + setConnectionState("error"); + setConnectionError(error instanceof Error ? error.message : String(error)); + } + }, [api]); + + useEffect(() => { + loadInitial(); + }, [loadInitial]); + + useEffect(() => { + if (!activeRunId) { + return; + } + if (runDetails[activeRunId]) { + return; + } + let cancelled = false; + (async () => { + try { + const run = await api.getRun(activeRunId); + if (!cancelled) { + setRunDetails((prev) => ({ + ...prev, + [run.id]: run, + })); + } + } catch (error) { + if (!cancelled) { + showToast({ + type: "error", + text: `Failed to load run: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [activeRunId, api, runDetails, showToast]); + + const refreshRuns = useCallback(async () => { + setRunsLoading(true); + try { + const response = await api.listRuns(); + setRuns(response.runs); + setRunsCursor(response.nextCursor); + } catch (error) { + showToast({ + type: "error", + text: `Failed to refresh runs: ${error instanceof Error ? error.message : String(error)}`, + }); + } finally { + setRunsLoading(false); + } + }, [api, showToast]); + + useEffect(() => { + if (connectionState !== "ready") { + return; + } + let unsub: (() => void) | null = null; + let cancelled = false; + setStreamError(null); + setEventStreamActive(false); + (async () => { + try { + unsub = await api.subscribeToEvents((event) => { + if (cancelled) { + return; + } + setEventStreamActive(true); + if (event.type === "start") { + setRuns((prev) => { + const next = [...prev]; + const idx = next.findIndex((r) => r.id === event.runId); + const summary: RunSummary = { + id: event.runId, + agentId: event.agentName, + createdAt: event.ts ?? new Date().toISOString(), + }; + if (idx >= 0) { + next[idx] = summary; + return next; + } + return [summary, ...next]; + }); + } + setRunDetails((prev) => { + const existing = prev[event.runId]; + if (!existing) { + return prev; + } + return { + ...prev, + [event.runId]: { + ...existing, + log: [...existing.log, event], + }, + }; + }); + }, (error) => { + setStreamError(error.message); + }); + } catch (error) { + if (!cancelled) { + setStreamError(error instanceof Error ? error.message : String(error)); + } + } + })(); + return () => { + cancelled = true; + unsub?.(); + }; + }, [api, connectionState]); + + const startDraftChat = useCallback((agentName: string) => { + setActiveRunId(null); + setDraftAgent(agentName); + setComposerValue(""); + setFocusTarget("chat"); + setSidebarIndex(0); + }, []); + + const composeMessage = useCallback(async (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + setComposerBusy(true); + try { + let runId = activeRunId; + if (!runId) { + const agentName = draftAgent || defaultCopilot; + const run = await api.createRun(agentName); + runId = run.id; + setRuns((prev) => { + const without = prev.filter((r) => r.id !== run.id); + return [ + { + id: run.id, + createdAt: run.createdAt, + agentId: run.agentId, + }, + ...without, + ]; + }); + setRunDetails((prev) => ({ + ...prev, + [run.id]: run, + })); + setActiveRunId(run.id); + } + await api.sendMessage(runId, trimmed); + setComposerValue(""); + showToast({ + type: "success", + text: "Message queued", + }); + } catch (error) { + showToast({ + type: "error", + text: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, + }); + } finally { + setComposerBusy(false); + } + }, [activeRunId, api, defaultCopilot, draftAgent, showToast]); + + const handleApprovePermission = useCallback(async () => { + const run = selectedRun; + const pending = pendingPermissions[0]; + if (!run || !pending) { + showToast({ type: "info", text: "No pending tool permissions" }); + return; + } + try { + await api.authorizeTool(run.id, { + toolCallId: pending.toolCallId, + response: "approve", + subflow: pending.subflow, + }); + showToast({ type: "success", text: `Approved ${pending.toolName}` }); + } catch (error) { + showToast({ + type: "error", + text: `Failed to approve: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, [api, pendingPermissions, selectedRun, showToast]); + + const handleDenyPermission = useCallback(async () => { + const run = selectedRun; + const pending = pendingPermissions[0]; + if (!run || !pending) { + showToast({ type: "info", text: "No pending tool permissions" }); + return; + } + try { + await api.authorizeTool(run.id, { + toolCallId: pending.toolCallId, + response: "deny", + subflow: pending.subflow, + }); + showToast({ type: "success", text: `Denied ${pending.toolName}` }); + } catch (error) { + showToast({ + type: "error", + text: `Failed to deny: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, [api, pendingPermissions, selectedRun, showToast]); + + const handleStopRun = useCallback(async () => { + if (!selectedRun) { + showToast({ type: "info", text: "No run selected" }); + return; + } + try { + await api.stopRun(selectedRun.id); + showToast({ type: "success", text: `Stop requested for ${selectedRun.id}` }); + } catch (error) { + showToast({ + type: "error", + text: `Failed to stop: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, [api, selectedRun, showToast]); + + const handleReplyHuman = useCallback(async (value: string, context: PendingHuman | undefined) => { + if (!selectedRun || !context) { + showToast({ type: "info", text: "No pending human requests" }); + return; + } + try { + await api.replyToHuman(selectedRun.id, context.toolCallId, { + toolCallId: context.toolCallId, + response: value, + subflow: context.subflow, + }); + showToast({ type: "success", text: "Reply sent" }); + } catch (error) { + showToast({ + type: "error", + text: `Failed to send reply: ${error instanceof Error ? error.message : String(error)}`, + }); + throw error; + } + }, [api, selectedRun, showToast]); + + const currentHumanRequest = pendingHuman[0]; + const maxVisibleEvents = Math.max(8, (stdout?.rows ?? 40) - 14); + + const chatTimeline = useMemo(() => { + if (!selectedRun) { + return { + visibleEvents: [] as ChatLine[], + maxOffset: 0, + total: 0, + }; + } + const lines: ChatLine[] = []; + let streamingText = ""; + let streamingActive = false; + let reasoningText = ""; + let reasoningActive = false; + for (const event of selectedRun.log) { + if (event.type === "llm-stream-event") { + const step = event.event; + switch (step.type) { + case "text-start": + streamingActive = true; + streamingText = ""; + break; + case "text-delta": + streamingActive = true; + streamingText += step.delta; + break; + case "text-end": + case "finish-step": + streamingActive = false; + break; + case "reasoning-start": + reasoningActive = true; + reasoningText = ""; + break; + case "reasoning-delta": + reasoningActive = true; + reasoningText += step.delta; + break; + case "reasoning-end": + reasoningActive = false; + break; + default: + break; + } + continue; + } + const formatted = formatEvent(event); + if (formatted) { + lines.push(formatted); + } + } + if (reasoningActive && reasoningText) { + lines.push({ + text: `assistant (thinking): ${reasoningText}`, + color: "black", + variant: "thinking", + }); + } + if (streamingActive && streamingText) { + lines.push({ + text: `assistant (streaming): ${streamingText}`, + color: "black", + variant: "streaming", + }); + } + const total = lines.length; + const maxOffset = Math.max(0, total - maxVisibleEvents); + const clampedOffset = Math.min(chatScrollOffset, maxOffset); + const end = total - clampedOffset; + const start = Math.max(0, end - maxVisibleEvents); + return { + visibleEvents: lines.slice(start, end), + maxOffset, + total, + }; + }, [chatScrollOffset, maxVisibleEvents, selectedRun]); + + useEffect(() => { + setChatScrollOffset(0); + }, [selectedRun?.id]); + + useEffect(() => { + setChatScrollOffset((offset) => Math.min(offset, chatTimeline.maxOffset)); + }, [chatTimeline.maxOffset]); + + useInput((input, key) => { + if (modal) { + if (key.escape) { + setModal(null); + } + return; + } + if (key.tab) { + setFocusTarget((prev) => (prev === "chat" ? "sidebar" : "chat")); + return; + } + if (key.ctrl && input === "q") { + exit(); + return; + } + if (key.ctrl && input === "n") { + startDraftChat(defaultCopilot); + return; + } + if (key.ctrl && input === "g") { + if (agents.length === 0) { + showToast({ type: "error", text: "No agents available" }); + return; + } + setModal({ type: "agent-picker" }); + return; + } + if (key.ctrl && input === "l") { + refreshRuns(); + return; + } + if (key.ctrl && input === "a") { + handleApprovePermission(); + return; + } + if (key.ctrl && input === "d") { + handleDenyPermission(); + return; + } + if (key.ctrl && input === "s") { + handleStopRun(); + return; + } + if (key.ctrl && input === "h") { + if (!currentHumanRequest) { + showToast({ type: "info", text: "No pending human input requests" }); + return; + } + if (!selectedRun) { + showToast({ type: "info", text: "Select a run to respond" }); + return; + } + setModal({ + type: "human-response", + runId: selectedRun.id, + requestId: currentHumanRequest.toolCallId, + subflow: currentHumanRequest.subflow, + prompt: currentHumanRequest.query, + value: "", + submitting: false, + }); + return; + } + if (focusTarget === "sidebar") { + if (key.upArrow) { + setSidebarIndex((idx) => Math.max(0, idx - 1)); + return; + } + if (key.downArrow) { + setSidebarIndex((idx) => Math.min(sidebarItems.length - 1, idx + 1)); + return; + } + if (key.return) { + const item = sidebarItems[sidebarIndex]; + if (!item) { + return; + } + if (item.kind === "action") { + if (item.action === "new-copilot") { + startDraftChat(defaultCopilot); + } else { + if (agents.length === 0) { + showToast({ type: "error", text: "No agents available" }); + } else { + setModal({ type: "agent-picker" }); + } + } + } else { + setActiveRunId(item.run.id); + setFocusTarget("chat"); + } + } + } + if (focusTarget === "chat") { + const scrollStep = Math.max(3, Math.floor(maxVisibleEvents / 2)); + if (key.pageUp) { + setChatScrollOffset((offset) => Math.min(chatTimeline.maxOffset, offset + scrollStep)); + return; + } + if (key.pageDown) { + setChatScrollOffset((offset) => Math.max(0, offset - scrollStep)); + return; + } + } + }); + + return ( + +
+ + + + 0} + scrollHint={chatTimeline.maxOffset > 0} + /> + + + + + Tab toggles focus · Ctrl+N new Copilot chat · Ctrl+G choose agent · Ctrl+L refresh chats · Ctrl+Q quit + + + + {toast && ( + + + {toast.text} + + + )} + + {modal && ( + + {modal.type === "agent-picker" && ( + { + setModal(null); + startDraftChat(agent); + }} + onCancel={() => setModal(null)} + /> + )} + {modal.type === "human-response" && ( + setModal({ ...modal, value })} + onSubmit={async (value) => { + const ctx: PendingHuman = { + toolCallId: modal.requestId, + query: modal.prompt, + subflow: modal.subflow, + }; + setModal({ ...modal, submitting: true }); + try { + await handleReplyHuman(value.trim(), ctx); + setModal(null); + } catch { + setModal({ ...modal, submitting: false }); + } + }} + onCancel={() => setModal(null)} + /> + )} + + )} + + ); +} + +function Header({ + serverUrl, + state, + error, + modelConfig, + agentsCount, + runsCount, + runsCursor, + streamError, + listening, +}: { + serverUrl: string; + state: ConnectionState; + error: string | null; + modelConfig: ModelConfigType | null; + agentsCount: number; + runsCount: number; + runsCursor: string | undefined; + streamError: string | null; + listening: boolean; +}) { + return ( + + + RowboatX chat · Server {serverUrl} + + + {state === "connecting" && ( + <> + + + {" "} + Connecting… + + )} + {state === "ready" && ( + + Connected · default {modelConfig?.defaults?.provider ?? "n/a"}/{modelConfig?.defaults?.model ?? "n/a"} + + )} + {state === "error" && ( + + Offline: {error ?? "Unknown error"} · Ctrl+L to retry + + )} + + + Agents: {agentsCount} · Chats loaded: {runsCount} + {runsCursor ? " (+ more)" : ""} + + {streamError && ( + Event stream issue: {streamError} + )} + {state === "ready" && listening === false && ( + Listening for run events… + )} + + ); +} + +function Sidebar({ + items, + focus, + index, + activeRunId, + runsLoading, +}: { + items: SidebarItem[]; + focus: boolean; + index: number; + activeRunId: string | null; + runsLoading: boolean; +}) { + return ( + + Chats + {focus ? "↑/↓ move · Enter select · Esc to leave" : "Tab to focus sidebar"} + + {runsLoading && ( + + refreshing… + + )} + {items.length === 0 && No chats yet.} + {items.map((item, idx) => { + let divider: React.ReactNode = null; + const isCursor = focus && idx === index; + if (item.kind === "action") { + return ( + + {isCursor ? "❯" : " "} {item.label} {item.hint ? `(${item.hint})` : ""} + + ); + } + const previousRuns = items.slice(0, idx).some((entry) => entry.kind === "run"); + if (!previousRuns) { + divider = ( + + ── recent chats ── + + ); + } + const isActiveRun = item.run.id === activeRunId; + return ( + + {divider} + + + {isCursor ? "❯" : isActiveRun ? "●" : " "} + {" "} + {item.run.agentId}{" "} + {item.run.id}{" "} + {item.status.label}{" "} + {timeAgo(item.run.createdAt)} + + + ); + })} + + + ); +} + +function ChatPanel({ + focus, + draftAgent, + run, + events, + composerValue, + composerBusy, + onChangeComposer, + onSubmitComposer, + pendingPermissions, + pendingHuman, + showHumanHint, + showPermissionHint, + scrollHint, +}: { + focus: boolean; + draftAgent: string; + run: RunType | undefined; + events: ChatLine[]; + composerValue: string; + composerBusy: boolean; + onChangeComposer: (value: string) => void; + onSubmitComposer: (value: string) => void; + pendingPermissions: PendingPermission[]; + pendingHuman: PendingHuman[]; + showHumanHint: boolean; + showPermissionHint: boolean; + scrollHint: boolean; +}) { + return ( + + + + {run ? run.agentId : draftAgent} + {" "} + {run ? ( + <> + · Run {run.id} · started {formatTimestamp(run.createdAt)} ({timeAgo(run.createdAt)}) + + ) : ( + · new chat + )} + + {!run && ( + Type a prompt and press enter to spin up a new {draftAgent} chat. + )} + {showPermissionHint && ( + Tool approval pending · Ctrl+A approve · Ctrl+D deny + )} + {showHumanHint && ( + Agent asked for help · Ctrl+H to reply + )} + + {run && events.length === 0 && ( + Loading chat log… + )} + {!run && ( + No messages yet. + )} + {events.map((event, idx) => ( + + ))} + + + + {focus + ? `Enter to send · Ctrl+N new chat${scrollHint ? " · PgUp/PgDn scroll" : ""}` + : "Tab to focus composer"} + + onSubmitComposer(value)} + focus={focus && !composerBusy} + placeholder="Send a message…" + /> + {composerBusy && ( + + Sending… + + )} + + + ); +} + +function ModalSurface({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function AgentPickerModal({ + agents, + onSelect, + onCancel, +}: { + agents: AgentType[]; + onSelect: (agentName: string) => void; + onCancel: () => void; +}) { + const items = agents.map((agent) => ({ + label: `${agent.name} – ${truncate(agent.description, 40)}`, + value: agent.name, + })); + return ( + + Select an agent (esc to cancel) + {items.length === 0 ? ( + No agents configured. + ) : ( + + items={items} + onSelect={(item) => onSelect(item.value)} + /> + )} + {items.length} agents available. + + ); +} + +function MessageModal({ + typeLabel, + prompt, + value, + submitting, + onChange, + onSubmit, + onCancel, +}: { + typeLabel: string; + prompt?: string; + value: string; + submitting: boolean; + onChange: (value: string) => void; + onSubmit: (value: string) => Promise; + onCancel: () => void; +}) { + return ( + + {typeLabel} (esc to cancel) + {prompt && ( + {truncate(prompt, 120)} + )} + { + if (!text.trim()) { + return; + } + onSubmit(text); + }} + focus={!submitting} + placeholder="Type your response…" + /> + {submitting ? ( + + Sending… + + ) : ( + Enter to submit · esc to cancel + )} + + ); +} + +function derivePendingPermissions(run: RunType | undefined): PendingPermission[] { + if (!run) { + return []; + } + const responded = new Set( + run.log + .filter((event) => event.type === "tool-permission-response") + .map((event) => event.toolCallId), + ); + const pending: PendingPermission[] = []; + for (const event of run.log) { + if (event.type === "tool-permission-request") { + const id = event.toolCall.toolCallId; + if (!responded.has(id)) { + pending.push({ + toolCallId: id, + toolName: event.toolCall.toolName, + args: event.toolCall.arguments, + subflow: event.subflow, + }); + } + } + } + return pending; +} + +function derivePendingHuman(run: RunType | undefined): PendingHuman[] { + if (!run) { + return []; + } + const responded = new Set( + run.log + .filter((event) => event.type === "ask-human-response") + .map((event) => event.toolCallId), + ); + const pending: PendingHuman[] = []; + for (const event of run.log) { + if (event.type === "ask-human-request" && !responded.has(event.toolCallId)) { + pending.push({ + toolCallId: event.toolCallId, + query: event.query, + subflow: event.subflow, + }); + } + } + return pending; +} + +function getRunStatus(run: RunType | undefined): { label: string; color: string } { + if (!run) { + return { label: "loading…", color: "gray" }; + } + const last = run.log[run.log.length - 1]; + if (last?.type === "error") { + return { label: "error", color: "red" }; + } + if (derivePendingHuman(run).length > 0) { + return { label: "awaiting human", color: "magenta" }; + } + if (derivePendingPermissions(run).length > 0) { + return { label: "needs approval", color: "yellow" }; + } + return { label: "running", color: "green" }; +} + +function MessageBubble({ event }: { event: ChatLine }) { + const isUser = event.variant === "user"; + const isAssistant = event.variant === "assistant" || event.variant === "streaming"; + const align = isUser ? "flex-end" : "flex-start"; + const bubbleColor = isUser ? "blue" : undefined; + const textColor = isUser ? "white" : event.color; + return ( + + + + {event.text} + + + + ); +} + +function formatEvent(event: RunEventType): ChatLine | null { + switch (event.type) { + case "start": + return { text: `▶ Start · ${event.agentName}`, color: "green", variant: "system" }; + case "message": { + const content = typeof event.message.content === "string" + ? event.message.content + : event.message.content + .map((part) => { + if (part.type === "text" || part.type === "reasoning") { + return part.text; + } + if (part.type === "tool-call") { + return `[tool:${part.toolName}] ${JSON.stringify(part.arguments)}`; + } + return ""; + }) + .join("\n"); + return { + text: `${event.message.role}: ${content}`, + color: event.message.role === "user" ? "black" : event.message.role === "assistant" ? "black" : "white", + variant: event.message.role === "user" + ? "user" + : event.message.role === "assistant" + ? "assistant" + : "system", + }; + } + case "tool-invocation": + return { text: `🔧 Invoking ${event.toolName} ${JSON.stringify(event.input)}`, color: "yellow", variant: "tool" }; + case "tool-result": + return { text: `✅ ${event.toolName} → ${truncate(JSON.stringify(event.result), 120)}`, color: "green", variant: "tool" }; + case "tool-permission-request": + return { text: `⚠️ Permission needed for ${event.toolCall.toolName}`, color: "yellow", variant: "system" }; + case "tool-permission-response": + return { text: `Permission ${event.response} for ${event.toolCallId}`, color: event.response === "approve" ? "green" : "red", variant: "system" }; + case "ask-human-request": + return { text: `🧑 Agent asks: ${truncate(event.query, 120)}`, color: "magenta", variant: "system" }; + case "ask-human-response": + return { text: `🙋 Human replied`, color: "magenta", variant: "system" }; + case "llm-stream-event": + return { text: `… ${event.event.type}`, color: "gray" }; + case "error": + return { text: `✖ ${event.error}`, color: "red", variant: "system" }; + case "spawn-subflow": + return { text: `↳ Spawned ${event.agentName}`, color: "cyan", variant: "system" }; + default: + return { text: "unknown event", color: "white", variant: "other" }; + } +} + +function truncate(input: string, len = 60): string { + if (input.length <= len) { + return input; + } + return `${input.slice(0, len - 1)}…`; +} + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + return date.toLocaleString(); +} + +function timeAgo(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + const diff = Date.now() - date.getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1fad84d4..a0cffd24 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "skipLibCheck": true, "sourceMap": true, + "jsx": "react-jsx", "paths": { "@/*": [ "./src/*"