From 8232c857dc3da06f45f347fe8d8d7ec84c8ce2e2 Mon Sep 17 00:00:00 2001 From: Vj Date: Fri, 30 Jan 2026 06:16:36 +0000 Subject: [PATCH] feat: add modern TUI (mtui) using React and Ink --- package.json | 8 + pnpm-lock.yaml | 438 +++++++++++++++++++++------ src/cli/mtui-cli.ts | 50 +++ src/cli/program/register.subclis.ts | 8 + src/mtui/App.tsx | 199 ++++++++++++ src/mtui/components/InputBar.tsx | 34 +++ src/mtui/components/MessageView.tsx | 101 ++++++ src/mtui/components/Selector.tsx | 50 +++ src/mtui/context/GatewayContext.tsx | 22 ++ src/mtui/context/SettingsContext.tsx | 31 ++ src/mtui/hooks/useChat.ts | 205 +++++++++++++ src/mtui/hooks/useCommands.ts | 76 +++++ src/mtui/mtui.tsx | 9 + tsconfig.json | 3 +- 14 files changed, 1141 insertions(+), 93 deletions(-) create mode 100644 src/cli/mtui-cli.ts create mode 100644 src/mtui/App.tsx create mode 100644 src/mtui/components/InputBar.tsx create mode 100644 src/mtui/components/MessageView.tsx create mode 100644 src/mtui/components/Selector.tsx create mode 100644 src/mtui/context/GatewayContext.tsx create mode 100644 src/mtui/context/SettingsContext.tsx create mode 100644 src/mtui/hooks/useChat.ts create mode 100644 src/mtui/hooks/useCommands.ts create mode 100644 src/mtui/mtui.tsx diff --git a/package.json b/package.json index d7f7cbcd9..00e5cce7c 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,10 @@ "file-type": "^21.3.0", "grammy": "^1.39.3", "hono": "4.11.4", + "ink": "^6.6.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", @@ -198,6 +202,8 @@ "playwright-core": "1.58.0", "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "sharp": "^0.34.5", "sqlite-vec": "0.1.7-alpha.2", "tar": "7.5.4", @@ -222,6 +228,8 @@ "@types/node": "^25.0.10", "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260124.1", "@vitest/coverage-v8": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..7368cb363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,18 @@ importers: hono: specifier: 4.11.4 version: 4.11.4 + ink: + specifier: ^6.6.0 + version: 6.6.0(@types/react@19.2.10)(react@19.2.4) + ink-select-input: + specifier: ^6.2.0 + version: 6.2.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4) + ink-text-input: + specifier: ^6.0.0 + version: 6.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -148,6 +160,12 @@ importers: qrcode-terminal: specifier: ^0.12.0 version: 0.12.0 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -203,6 +221,12 @@ importers: '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 + '@types/react': + specifier: ^19.2.10 + version: 19.2.10 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.10) '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -383,12 +407,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -519,6 +543,10 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.2.4': + resolution: {integrity: sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ==} + engines: {node: '>=18'} + '@anthropic-ai/sdk@0.71.2': resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} hasBin: true @@ -2758,6 +2786,14 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.10': + resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} @@ -2976,6 +3012,10 @@ packages: resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} engines: {node: '>=14.16'} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3065,6 +3105,10 @@ packages: resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} engines: {node: '>=14'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} @@ -3214,10 +3258,13 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} @@ -3232,6 +3279,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3248,6 +3299,10 @@ packages: engines: {node: '>= 14.15.0'} hasBin: true + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + codec-parser@2.5.0: resolution: {integrity: sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ==} @@ -3304,6 +3359,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -3345,6 +3404,9 @@ packages: cssom@0.5.0: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} @@ -3474,6 +3536,10 @@ packages: resolution: {integrity: sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==} engines: {node: '>=10'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3493,6 +3559,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -3505,6 +3574,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3585,6 +3658,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-type@21.3.0: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} @@ -3859,12 +3936,50 @@ packages: import-in-the-middle@2.0.5: resolution: {integrity: sha512-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA==} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink-select-input@6.2.0: + resolution: {integrity: sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=5.0.0' + react: '>=18.0.0' + + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + + ink-text-input@6.0.0: + resolution: {integrity: sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=5' + react: '>=18' + + ink@6.6.0: + resolution: {integrity: sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: ^6.1.2 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3900,6 +4015,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -4318,6 +4438,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -4515,6 +4639,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -4636,6 +4764,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4838,6 +4970,21 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -4897,6 +5044,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -4950,6 +5101,9 @@ packages: sanitize-html@2.17.0: resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -5100,6 +5254,10 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5141,6 +5299,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.1.1: + resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -5193,6 +5355,7 @@ packages: tar@7.5.4: resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} @@ -5230,6 +5393,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-rotated@1.0.0: + resolution: {integrity: sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==} + engines: {node: '>=18'} + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -5278,6 +5445,10 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -5488,6 +5659,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + win-guid@0.1.3: resolution: {integrity: sha512-Ah25z4XnblBGFgrcVS5QK7qLkvsFFA35+G+AnHkELUPHXPYWFOSVNMuAxf1zW0B+4X911IBLD+TvB771om0gmg==} @@ -5511,6 +5686,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5562,6 +5741,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -5582,6 +5764,11 @@ snapshots: dependencies: zod: 4.3.6 + '@alcalzone/ansi-tokenize@0.2.4': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -8538,6 +8725,14 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.10)': + dependencies: + '@types/react': 19.2.10 + + '@types/react@19.2.10': + dependencies: + csstype: 3.2.3 + '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 @@ -8832,6 +9027,10 @@ snapshots: ansi-escapes@6.2.1: optional: true + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -8925,6 +9124,8 @@ snapshots: audio-type@2.2.1: optional: true + auto-bind@5.0.1: {} + aws-sign2@0.7.0: {} aws4@1.13.2: {} @@ -9098,83 +9299,11 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate + restore-cursor: 4.0.0 cli-cursor@5.0.0: dependencies: @@ -9190,8 +9319,12 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 - cli-spinners@2.9.2: - optional: true + cli-spinners@2.9.2: {} + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.2 + string-width: 8.1.1 cliui@7.0.4: dependencies: @@ -9225,6 +9358,10 @@ snapshots: - supports-color optional: true + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + codec-parser@2.5.0: optional: true @@ -9275,6 +9412,8 @@ snapshots: content-type@1.0.5: {} + convert-to-spaces@2.0.1: {} + cookie-signature@1.0.7: {} cookie-signature@1.2.2: {} @@ -9313,6 +9452,8 @@ snapshots: cssom@0.5.0: {} + csstype@3.2.3: {} + curve25519-js@0.0.4: {} dashdash@1.14.1: @@ -9398,8 +9539,7 @@ snapshots: ee-first@1.1.1: {} - emoji-regex@10.6.0: - optional: true + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -9414,6 +9554,8 @@ snapshots: env-var@7.5.0: optional: true + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9431,6 +9573,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.44.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -9464,6 +9608,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} estree-walker@3.0.3: @@ -9589,6 +9735,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-type@21.3.0: dependencies: '@tokenizer/inflate': 0.4.1 @@ -9935,11 +10085,65 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: optional: true + ink-select-input@6.2.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4): + dependencies: + figures: 6.1.0 + ink: 6.6.0(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + to-rotated: 1.0.0 + + ink-spinner@5.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4): + dependencies: + cli-spinners: 2.9.2 + ink: 6.6.0(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + + ink-text-input@6.0.0(ink@6.6.0(@types/react@19.2.10)(react@19.2.4))(react@19.2.4): + dependencies: + chalk: 5.6.2 + ink: 6.6.0(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + type-fest: 4.41.0 + + ink@6.6.0(@types/react@19.2.10)(react@19.2.4): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.4 + ansi-escapes: 7.2.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.44.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.4 + react-reconciler: 0.33.0(react@19.2.4) + signal-exit: 3.0.7 + slice-ansi: 7.1.2 + stack-utils: 2.0.6 + string-width: 8.1.1 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + ws: 8.19.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ipaddr.js@1.9.1: {} ipull@3.9.3: @@ -9993,12 +10197,13 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 - optional: true is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ci@2.0.0: {} + is-interactive@2.0.0: optional: true @@ -10017,8 +10222,7 @@ snapshots: is-unicode-supported@1.3.0: optional: true - is-unicode-supported@2.1.0: - optional: true + is-unicode-supported@2.1.0: {} is-url@1.2.4: {} @@ -10382,6 +10586,8 @@ snapshots: mime@1.6.0: {} + mimic-fn@2.1.0: {} + mimic-function@5.0.1: optional: true @@ -10625,6 +10831,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -10748,6 +10958,8 @@ snapshots: partial-json@0.1.7: {} + patch-console@2.0.0: {} + path-key@3.1.1: {} path-scurry@1.11.1: @@ -10979,6 +11191,18 @@ snapshots: strip-json-comments: 2.0.1 optional: true + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -11063,6 +11287,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -11160,6 +11389,8 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.6 + scheduler@0.27.0: {} + selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -11329,7 +11560,6 @@ snapshots: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - optional: true sonic-boom@4.2.0: dependencies: @@ -11381,6 +11611,10 @@ snapshots: safer-buffer: 2.1.2 tweetnacl: 0.14.5 + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -11424,7 +11658,11 @@ snapshots: emoji-regex: 10.6.0 get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 - optional: true + + string-width@8.1.1: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 string_decoder@1.1.1: dependencies: @@ -11509,6 +11747,8 @@ snapshots: dependencies: is-number: 7.0.0 + to-rotated@1.0.0: {} + toad-cache@3.7.0: optional: true @@ -11550,6 +11790,8 @@ snapshots: tweetnacl@0.14.5: {} + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -11719,6 +11961,10 @@ snapshots: string-width: 4.2.3 optional: true + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + win-guid@0.1.3: {} wireit@0.14.12: @@ -11745,6 +11991,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} ws@8.19.0: {} @@ -11783,6 +12035,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/cli/mtui-cli.ts b/src/cli/mtui-cli.ts new file mode 100644 index 000000000..339bd565b --- /dev/null +++ b/src/cli/mtui-cli.ts @@ -0,0 +1,50 @@ +import type { Command } from "commander"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; +import { runMtui } from "../mtui/mtui.js"; +import { parseTimeoutMs } from "./parse-timeout.js"; + +export function registerMtuiCli(program: Command) { + program + .command("mtui") + .description("Open a modern terminal UI (React/Ink) connected to the Gateway") + .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (if required)") + .option("--session ", 'Session key (default: "main", or "global" when scope is global)') + .option("--deliver", "Deliver assistant replies", false) + .option("--thinking ", "Thinking level override") + .option("--message ", "Send an initial message after connecting") + .option("--timeout-ms ", "Agent timeout in ms (defaults to agents.defaults.timeoutSeconds)") + .option("--history-limit ", "History entries to load", "200") + .addHelpText( + "after", + () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/mtui", "docs.molt.bot/cli/mtui")}\n`, + ) + .action(async (opts) => { + try { + const timeoutMs = parseTimeoutMs(opts.timeoutMs); + if (opts.timeoutMs !== undefined && timeoutMs === undefined) { + defaultRuntime.error( + `warning: invalid --timeout-ms "${String(opts.timeoutMs)}"; ignoring`, + ); + } + const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10); + await runMtui({ + url: opts.url as string | undefined, + token: opts.token as string | undefined, + password: opts.password as string | undefined, + session: opts.session as string | undefined, + deliver: Boolean(opts.deliver), + thinking: opts.thinking as string | undefined, + message: opts.message as string | undefined, + timeoutMs, + historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit, + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 3aae9b361..488da4b28 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -124,6 +124,14 @@ const entries: SubCliEntry[] = [ mod.registerTuiCli(program); }, }, + { + name: "mtui", + description: "Modern Terminal UI (React/Ink)", + register: async (program) => { + const mod = await import("../mtui-cli.js"); + mod.registerMtuiCli(program); + }, + }, { name: "cron", description: "Cron scheduler", diff --git a/src/mtui/App.tsx b/src/mtui/App.tsx new file mode 100644 index 000000000..710a576dc --- /dev/null +++ b/src/mtui/App.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect } from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import Spinner from "ink-spinner"; +import { GatewayProvider, useGateway } from "./context/GatewayContext.js"; +import { SettingsProvider, useSettings } from "./context/SettingsContext.js"; +import { useChat } from "./hooks/useChat.js"; +import { useCommands } from "./hooks/useCommands.js"; +import { MessageView } from "./components/MessageView.js"; +import { InputBar } from "./components/InputBar.js"; +import { Selector } from "./components/Selector.js"; +import type { TuiOptions } from "../tui/tui-types.js"; +import { formatContextUsageLine } from "../tui/tui-formatters.js"; + +type AppProps = { + options: TuiOptions; +}; + +const ChatApp: React.FC<{ options: TuiOptions }> = ({ options }) => { + const { exit } = useApp(); + const gateway = useGateway(); + const { showThinking, setShowThinking } = useSettings(); + const [connectionStatus, setConnectionStatus] = useState< + "connecting" | "connected" | "disconnected" + >("connecting"); + const [error, setError] = useState(null); + const [overlay, setOverlay] = useState<{ type: "model" | "agent"; items: any[] } | null>(null); + + const { + messages, + status, + sendMessage, + addMessage, + sessionInfo, + sessionKey, + refreshSessionInfo, + loadHistory, + } = useChat(options.session || "main"); + + const { handleLocalShell, handleSlashCommand } = useCommands( + sessionKey, + addMessage, + refreshSessionInfo, + ); + + useEffect(() => { + gateway.onConnected = () => { + setConnectionStatus("connected"); + setError(null); + void loadHistory(); + }; + + gateway.onDisconnected = (reason) => { + setConnectionStatus("disconnected"); + setError(reason || "Connection closed"); + }; + + gateway.start(); + + return () => { + gateway.stop(); + }; + }, [gateway, loadHistory]); + + const handleSubmit = async (value: string) => { + if (value.startsWith("!")) { + await handleLocalShell(value); + } else if (value.startsWith("/")) { + if (value === "/model") { + const models = await gateway.listModels(); + setOverlay({ + type: "model", + items: models.map((m) => ({ + label: `${m.provider}/${m.id}`, + value: `${m.provider}/${m.id}`, + })), + }); + } else { + await handleSlashCommand(value); + } + } else { + await sendMessage(value); + } + }; + + useInput((input, key) => { + if (key.ctrl && input === "c") { + exit(); + } + if (key.escape && overlay) { + setOverlay(null); + } + if (key.ctrl && input === "t") { + setShowThinking(!showThinking); + } + }); + + const usageLine = formatContextUsageLine({ + total: sessionInfo.totalTokens, + context: sessionInfo.contextTokens, + remaining: (sessionInfo.contextTokens ?? 0) - (sessionInfo.totalTokens ?? 0), + percent: + sessionInfo.totalTokens && sessionInfo.contextTokens + ? (sessionInfo.totalTokens / sessionInfo.contextTokens) * 100 + : null, + }); + + return ( + + + + Moltbot MTUI + + + + {sessionInfo.model || "no model"} + + + {connectionStatus === "connecting" && } {connectionStatus} + + + + {overlay ? ( + + { + if (overlay.type === "model") { + await gateway.patchSession({ key: sessionKey, model: item.value }); + addMessage({ + id: Math.random().toString(), + role: "system", + content: `Model set to ${item.value}`, + }); + await refreshSessionInfo(); + } + setOverlay(null); + }} + onCancel={() => setOverlay(null)} + /> + + ) : ( + + + {messages.slice(-10).map((msg, i) => ( + + ))} + + + )} + + {status === "running" && !overlay && ( + + + Assistant is thinking... + + + )} + + {error && !overlay && ( + + Error: {error} + + )} + + {!overlay && ( + <> + + {usageLine} + + + + + + Ctrl+C exit | Ctrl+T think | /model | /reset | !ls + + + )} + + ); +}; + +export const App: React.FC = ({ options }) => { + return ( + + + + + + ); +}; diff --git a/src/mtui/components/InputBar.tsx b/src/mtui/components/InputBar.tsx new file mode 100644 index 000000000..66cc6db3a --- /dev/null +++ b/src/mtui/components/InputBar.tsx @@ -0,0 +1,34 @@ +import React, { useState } from "react"; +import { Box, Text } from "ink"; +import TextInput from "ink-text-input"; + +type InputBarProps = { + onSubmit: (value: string) => void; + status: string; +}; + +export const InputBar: React.FC = ({ onSubmit, status }) => { + const [value, setValue] = useState(""); + + const handleSubmit = (val: string) => { + if (!val.trim()) return; + onSubmit(val); + setValue(""); + }; + + return ( + + + + moltbot {">"}{" "} + + + + + ); +}; diff --git a/src/mtui/components/MessageView.tsx b/src/mtui/components/MessageView.tsx new file mode 100644 index 000000000..937bda6b9 --- /dev/null +++ b/src/mtui/components/MessageView.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { highlight } from "cli-highlight"; +import type { Message } from "../hooks/useChat.js"; +import { useSettings } from "../context/SettingsContext.js"; + +type MessageViewProps = { + message: Message; +}; + +export const MessageView: React.FC = ({ message }) => { + const isUser = message.role === "user"; + const isSystem = message.role === "system"; + const { showThinking } = useSettings(); + + return ( + + + + {isUser ? "You" : isSystem ? "System" : "Assistant"} + + + + {message.thinking && ( + + + + {showThinking ? "▼ Thinking" : "▶ Thinking (hidden)"} + + + {showThinking && ( + + + {message.thinking} + + + )} + + )} + + {renderContent(message.content)} + + {message.tools && message.tools.length > 0 && ( + + {message.tools.map((tool) => ( + + + Tool: {tool.name} + + Args: {JSON.stringify(tool.args)} + {tool.isStreaming && Running...} + {tool.result && ( + + + Result:{" "} + {typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)} + + + )} + + ))} + + )} + + + ); +}; + +function renderContent(content: string) { + const parts = content.split(/(```[\s\S]*?```)/g); + return parts.map((part, i) => { + if (part.startsWith("```")) { + const match = part.match(/```(\w*)\n([\s\S]*?)```/); + if (match) { + const lang = match[1] || "text"; + const code = match[2]; + try { + return ( + + {highlight(code, { language: lang })} + + ); + } catch { + return ( + + {code} + + ); + } + } + } + return {part}; + }); +} diff --git a/src/mtui/components/Selector.tsx b/src/mtui/components/Selector.tsx new file mode 100644 index 000000000..29f65eef5 --- /dev/null +++ b/src/mtui/components/Selector.tsx @@ -0,0 +1,50 @@ +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import SelectInput from "ink-select-input"; + +type Item = { + label: string; + value: string; +}; + +type SelectorProps = { + title: string; + items: Item[]; + onSelect: (item: Item) => void; + onCancel: () => void; +}; + +export const Selector: React.FC = ({ title, items, onSelect, onCancel }) => { + const [query, setQuery] = useState(""); + + const filteredItems = items.filter( + (item) => + item.label.toLowerCase().includes(query.toLowerCase()) || + item.value.toLowerCase().includes(query.toLowerCase()), + ); + + useInput((input, key) => { + if (key.escape) { + onCancel(); + } + if (!key.ctrl && !key.meta && input.length === 1 && !key.return) { + setQuery((q) => q + input); + } + if (key.backspace || key.delete) { + setQuery((q) => q.slice(0, -1)); + } + }); + + return ( + + {title} + + Search: {query} + + + + Esc to cancel + + + ); +}; diff --git a/src/mtui/context/GatewayContext.tsx b/src/mtui/context/GatewayContext.tsx new file mode 100644 index 000000000..e36349de5 --- /dev/null +++ b/src/mtui/context/GatewayContext.tsx @@ -0,0 +1,22 @@ +import React, { createContext, useContext, useMemo } from "react"; +import { GatewayChatClient } from "../../tui/gateway-chat.js"; +import type { TuiOptions } from "../../tui/tui-types.js"; + +const GatewayContext = createContext(null); + +export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({ + options, + children, +}) => { + const client = useMemo(() => new GatewayChatClient(options), [options]); + + return {children}; +}; + +export const useGateway = () => { + const context = useContext(GatewayContext); + if (!context) { + throw new Error("useGateway must be used within a GatewayProvider"); + } + return context; +}; diff --git a/src/mtui/context/SettingsContext.tsx b/src/mtui/context/SettingsContext.tsx new file mode 100644 index 000000000..82e687673 --- /dev/null +++ b/src/mtui/context/SettingsContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, useState } from "react"; + +type Settings = { + showThinking: boolean; + setShowThinking: (show: boolean) => void; + toolsExpanded: boolean; + setToolsExpanded: (expanded: boolean) => void; +}; + +const SettingsContext = createContext(null); + +export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [showThinking, setShowThinking] = useState(false); + const [toolsExpanded, setToolsExpanded] = useState(false); + + return ( + + {children} + + ); +}; + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return context; +}; diff --git a/src/mtui/hooks/useChat.ts b/src/mtui/hooks/useChat.ts new file mode 100644 index 000000000..ab8db9de1 --- /dev/null +++ b/src/mtui/hooks/useChat.ts @@ -0,0 +1,205 @@ +import { useState, useEffect, useCallback } from "react"; +import { useGateway } from "../context/GatewayContext.js"; +import { TuiStreamAssembler } from "../../tui/tui-stream-assembler.js"; +import { extractThinkingFromMessage, extractContentFromMessage } from "../../tui/tui-formatters.js"; +import type { ChatEvent, AgentEvent, SessionInfo } from "../../tui/tui-types.js"; + +export type Message = { + id: string; + role: "user" | "assistant" | "system"; + content: string; + thinking?: string; + isFinal?: boolean; + tools?: ToolCall[]; +}; + +export type ToolCall = { + id: string; + name: string; + args?: any; + result?: any; + isError?: boolean; + isStreaming?: boolean; +}; + +export const useChat = (initialSessionKey: string) => { + const gateway = useGateway(); + const [messages, setMessages] = useState([]); + const [sessionKey, setSessionKey] = useState(initialSessionKey); + const [activeRunId, setActiveRunId] = useState(null); + const [status, setStatus] = useState<"idle" | "running" | "streaming" | "error">("idle"); + const [sessionInfo, setSessionInfo] = useState({}); + + const refreshSessionInfo = useCallback(async () => { + try { + const statusRes = (await gateway.getStatus()) as any; + if (statusRes?.sessions?.recent) { + const current = statusRes.sessions.recent.find((s: any) => s.key === sessionKey); + if (current) { + setSessionInfo(current); + } + } + } catch (err) { + console.error("Failed to refresh session info", err); + } + }, [gateway, sessionKey]); + + useEffect(() => { + const assembler = new TuiStreamAssembler(); + + gateway.onEvent = (evt) => { + if (evt.event === "chat.event") { + const payload = evt.payload as ChatEvent; + if (payload.sessionKey !== sessionKey) return; + + if (payload.state === "delta") { + assembler.ingestDelta(payload.runId, payload.message, true); + const thinking = extractThinkingFromMessage(payload.message); + const content = extractContentFromMessage(payload.message); + + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.id === payload.runId) { + return [ + ...prev.slice(0, -1), + { + ...last, + content: last.content + (content || ""), + thinking: last.thinking + (thinking || ""), + }, + ]; + } + return [ + ...prev, + { + id: payload.runId, + role: "assistant", + content: content || "", + thinking: thinking || "", + }, + ]; + }); + setStatus("streaming"); + } else if (payload.state === "final") { + assembler.finalize(payload.runId, payload.message, true); + const thinking = extractThinkingFromMessage(payload.message); + const content = extractContentFromMessage(payload.message); + + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.id === payload.runId) { + return [ + ...prev.slice(0, -1), + { + ...last, + content: content || last.content, + thinking: thinking || last.thinking, + isFinal: true, + }, + ]; + } + return [ + ...prev, + { + id: payload.runId, + role: "assistant", + content: content || "", + thinking: thinking || "", + isFinal: true, + }, + ]; + }); + setActiveRunId(null); + setStatus("idle"); + void refreshSessionInfo(); + } else if (payload.state === "error") { + setStatus("error"); + setActiveRunId(null); + void refreshSessionInfo(); + } + } else if (evt.event === "agent.event") { + const payload = evt.payload as AgentEvent; + if (payload.stream === "tool") { + const data = payload.data as any; + const { phase, toolCallId, name: toolName } = data; + + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (!last || last.role !== "assistant") return prev; + + const tools = last.tools || []; + let nextTools = [...tools]; + + if (phase === "start") { + nextTools.push({ + id: toolCallId, + name: toolName, + args: data.args, + isStreaming: true, + }); + } else if (phase === "update") { + nextTools = nextTools.map((t) => + t.id === toolCallId ? { ...t, result: data.partialResult } : t, + ); + } else if (phase === "result") { + nextTools = nextTools.map((t) => + t.id === toolCallId + ? { ...t, result: data.result, isError: data.isError, isStreaming: false } + : t, + ); + } + + return [...prev.slice(0, -1), { ...last, tools: nextTools }]; + }); + } + } + }; + + void refreshSessionInfo(); + }, [gateway, sessionKey, refreshSessionInfo]); + + const addMessage = useCallback((msg: Message) => { + setMessages((prev) => [...prev, msg]); + }, []); + + const loadHistory = useCallback(async () => { + try { + const history = (await gateway.loadHistory({ sessionKey, limit: 100 })) as any; + if (Array.isArray(history?.messages)) { + const msgs = history.messages.map((m: any) => ({ + id: m.id || Math.random().toString(), + role: m.role, + content: extractContentFromMessage(m) || "", + thinking: extractThinkingFromMessage(m) || "", + isFinal: true, + })); + setMessages(msgs); + } + } catch (err) { + console.error("Failed to load history", err); + } + }, [gateway, sessionKey]); + + const sendMessage = useCallback( + async (text: string) => { + addMessage({ id: Math.random().toString(), role: "user", content: text }); + setStatus("running"); + const { runId } = await gateway.sendChat({ sessionKey, message: text }); + setActiveRunId(runId); + }, + [gateway, sessionKey, addMessage], + ); + + return { + messages, + status, + sendMessage, + addMessage, + sessionInfo, + sessionKey, + setSessionKey, + refreshSessionInfo, + loadHistory, + activeRunId, + }; +}; diff --git a/src/mtui/hooks/useCommands.ts b/src/mtui/hooks/useCommands.ts new file mode 100644 index 000000000..d3e32e49c --- /dev/null +++ b/src/mtui/hooks/useCommands.ts @@ -0,0 +1,76 @@ +import { useCallback } from "react"; +import { useGateway } from "../context/GatewayContext.js"; +import type { Message } from "./useChat.js"; +import { spawn } from "node:child_process"; + +export const useCommands = ( + sessionKey: string, + addMessage: (msg: Message) => void, + refreshSessionInfo: () => Promise, +) => { + const gateway = useGateway(); + + const handleLocalShell = useCallback( + async (line: string) => { + const cmd = line.slice(1); + addMessage({ id: Math.random().toString(), role: "system", content: `[local] $ ${cmd}` }); + + return new Promise((resolve) => { + const child = spawn(cmd, { shell: true }); + let output = ""; + + child.stdout.on("data", (data) => { + output += data.toString(); + }); + child.stderr.on("data", (data) => { + output += data.toString(); + }); + + child.on("close", (code) => { + addMessage({ + id: Math.random().toString(), + role: "system", + content: output.trim() || `Exit code: ${code}`, + }); + resolve(); + }); + }); + }, + [addMessage], + ); + + const handleSlashCommand = useCallback( + async (text: string) => { + const parts = text.slice(1).split(" "); + const command = parts[0]; + const args = parts.slice(1).join(" "); + + switch (command) { + case "reset": + await gateway.resetSession(sessionKey); + addMessage({ id: Math.random().toString(), role: "system", content: "Session reset." }); + break; + case "model": + if (args) { + await gateway.patchSession({ key: sessionKey, model: args }); + addMessage({ + id: Math.random().toString(), + role: "system", + content: `Model set to ${args}`, + }); + await refreshSessionInfo(); + } + break; + default: + addMessage({ + id: Math.random().toString(), + role: "system", + content: `Unknown command: /${command}`, + }); + } + }, + [gateway, sessionKey, addMessage, refreshSessionInfo], + ); + + return { handleLocalShell, handleSlashCommand }; +}; diff --git a/src/mtui/mtui.tsx b/src/mtui/mtui.tsx new file mode 100644 index 000000000..52a3040d5 --- /dev/null +++ b/src/mtui/mtui.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import { render } from "ink"; +import { App } from "./App.js"; +import type { TuiOptions } from "../tui/tui-types.js"; + +export async function runMtui(opts: TuiOptions) { + const { waitUntilExit } = render(); + await waitUntilExit(); +} diff --git a/tsconfig.json b/tsconfig.json index 8f82c611d..72175ef0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "skipLibCheck": true, "resolveJsonModule": true, "noEmitOnError": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": [