feat: add modern TUI (mtui) using React and Ink

This commit is contained in:
Vj 2026-01-30 06:16:36 +00:00
parent b9f3c36ce2
commit 8232c857dc
14 changed files with 1141 additions and 93 deletions

View File

@ -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",

438
pnpm-lock.yaml generated
View File

@ -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

50
src/cli/mtui-cli.ts Normal file
View File

@ -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 <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option("--session <key>", 'Session key (default: "main", or "global" when scope is global)')
.option("--deliver", "Deliver assistant replies", false)
.option("--thinking <level>", "Thinking level override")
.option("--message <text>", "Send an initial message after connecting")
.option("--timeout-ms <ms>", "Agent timeout in ms (defaults to agents.defaults.timeoutSeconds)")
.option("--history-limit <n>", "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);
}
});
}

View File

@ -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",

199
src/mtui/App.tsx Normal file
View File

@ -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<string | null>(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 (
<Box flexDirection="column" padding={1} minHeight={20}>
<Box borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
<Text bold color="white">
Moltbot MTUI
</Text>
<Box flexGrow={1} />
<Box paddingX={2}>
<Text dimColor>{sessionInfo.model || "no model"}</Text>
</Box>
<Text
color={
connectionStatus === "connected"
? "green"
: connectionStatus === "connecting"
? "yellow"
: "red"
}
>
{connectionStatus === "connecting" && <Spinner type="dots" />} {connectionStatus}
</Text>
</Box>
{overlay ? (
<Box flexGrow={1} justifyContent="center" alignItems="center">
<Selector
title={`Select ${overlay.type}`}
items={overlay.items}
onSelect={async (item) => {
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)}
/>
</Box>
) : (
<Box flexGrow={1} flexDirection="column">
<Box flexDirection="column">
{messages.slice(-10).map((msg, i) => (
<MessageView key={i} message={msg} />
))}
</Box>
</Box>
)}
{status === "running" && !overlay && (
<Box paddingX={1} marginBottom={1}>
<Text color="yellow">
<Spinner type="dots" /> Assistant is thinking...
</Text>
</Box>
)}
{error && !overlay && (
<Box paddingX={1} marginBottom={1}>
<Text color="red">Error: {error}</Text>
</Box>
)}
{!overlay && (
<>
<Box paddingX={1} marginBottom={1}>
<Text dimColor>{usageLine}</Text>
</Box>
<InputBar onSubmit={handleSubmit} status={status} />
<Box paddingX={1} marginTop={1}>
<Text dimColor>Ctrl+C exit | Ctrl+T think | /model | /reset | !ls</Text>
</Box>
</>
)}
</Box>
);
};
export const App: React.FC<AppProps> = ({ options }) => {
return (
<SettingsProvider>
<GatewayProvider options={options}>
<ChatApp options={options} />
</GatewayProvider>
</SettingsProvider>
);
};

View File

@ -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<InputBarProps> = ({ onSubmit, status }) => {
const [value, setValue] = useState("");
const handleSubmit = (val: string) => {
if (!val.trim()) return;
onSubmit(val);
setValue("");
};
return (
<Box flexDirection="column">
<Box paddingX={1} borderStyle="single" borderColor={status === "idle" ? "cyan" : "yellow"}>
<Text bold color="cyan">
moltbot {">"}{" "}
</Text>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="Type a message or / for commands..."
/>
</Box>
</Box>
);
};

View File

@ -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<MessageViewProps> = ({ message }) => {
const isUser = message.role === "user";
const isSystem = message.role === "system";
const { showThinking } = useSettings();
return (
<Box flexDirection="column" marginBottom={1}>
<Box marginBottom={0}>
<Text bold color={isUser ? "blue" : isSystem ? "yellow" : "green"}>
{isUser ? "You" : isSystem ? "System" : "Assistant"}
</Text>
</Box>
<Box paddingLeft={2} flexDirection="column">
{message.thinking && (
<Box flexDirection="column" marginY={1}>
<Box>
<Text dimColor italic>
{showThinking ? "▼ Thinking" : "▶ Thinking (hidden)"}
</Text>
</Box>
{showThinking && (
<Box paddingLeft={2} borderStyle="single" borderColor="gray">
<Text italic dimColor>
{message.thinking}
</Text>
</Box>
)}
</Box>
)}
{renderContent(message.content)}
{message.tools && message.tools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{message.tools.map((tool) => (
<Box
key={tool.id}
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={1}
marginBottom={1}
>
<Text bold color="cyan">
Tool: {tool.name}
</Text>
<Text dimColor>Args: {JSON.stringify(tool.args)}</Text>
{tool.isStreaming && <Text color="yellow">Running...</Text>}
{tool.result && (
<Box marginTop={1}>
<Text color={tool.isError ? "red" : "gray"}>
Result:{" "}
{typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result)}
</Text>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
</Box>
);
};
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 (
<Box key={i} marginY={1} paddingX={1} borderStyle="single" borderColor="gray">
<Text>{highlight(code, { language: lang })}</Text>
</Box>
);
} catch {
return (
<Box key={i} marginY={1} paddingX={1} borderStyle="single" borderColor="gray">
<Text>{code}</Text>
</Box>
);
}
}
}
return <Text key={i}>{part}</Text>;
});
}

View File

@ -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<SelectorProps> = ({ 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 (
<Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
<Text bold>{title}</Text>
<Box marginY={1}>
<Text>Search: {query}</Text>
</Box>
<SelectInput items={filteredItems} onSelect={onSelect} />
<Box marginTop={1}>
<Text dimColor>Esc to cancel</Text>
</Box>
</Box>
);
};

View File

@ -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<GatewayChatClient | null>(null);
export const GatewayProvider: React.FC<{ options: TuiOptions; children: React.ReactNode }> = ({
options,
children,
}) => {
const client = useMemo(() => new GatewayChatClient(options), [options]);
return <GatewayContext.Provider value={client}>{children}</GatewayContext.Provider>;
};
export const useGateway = () => {
const context = useContext(GatewayContext);
if (!context) {
throw new Error("useGateway must be used within a GatewayProvider");
}
return context;
};

View File

@ -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<Settings | null>(null);
export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [showThinking, setShowThinking] = useState(false);
const [toolsExpanded, setToolsExpanded] = useState(false);
return (
<SettingsContext.Provider
value={{ showThinking, setShowThinking, toolsExpanded, setToolsExpanded }}
>
{children}
</SettingsContext.Provider>
);
};
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error("useSettings must be used within a SettingsProvider");
}
return context;
};

205
src/mtui/hooks/useChat.ts Normal file
View File

@ -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<Message[]>([]);
const [sessionKey, setSessionKey] = useState(initialSessionKey);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "running" | "streaming" | "error">("idle");
const [sessionInfo, setSessionInfo] = useState<SessionInfo>({});
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,
};
};

View File

@ -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<void>,
) => {
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<void>((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 };
};

9
src/mtui/mtui.tsx Normal file
View File

@ -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(<App options={opts} />);
await waitUntilExit();
}

View File

@ -11,7 +11,8 @@
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmitOnError": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": [