From 49e30296b987627533d2a37736b7ace987d3bf6b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 2 Mar 2026 20:33:41 -0800 Subject: [PATCH] add TUI --- tui/.gitignore | 2 + tui/README.md | 48 ++ tui/package-lock.json | 616 ++++++++++++++++++ tui/package.json | 21 + tui/src/api.ts | 272 ++++++++ tui/src/config.ts | 48 ++ tui/src/index.ts | 1403 +++++++++++++++++++++++++++++++++++++++++ tui/src/types.ts | 140 ++++ tui/tsconfig.json | 16 + 9 files changed, 2566 insertions(+) create mode 100644 tui/.gitignore create mode 100644 tui/README.md create mode 100644 tui/package-lock.json create mode 100644 tui/package.json create mode 100644 tui/src/api.ts create mode 100644 tui/src/config.ts create mode 100644 tui/src/index.ts create mode 100644 tui/src/types.ts create mode 100644 tui/tsconfig.json diff --git a/tui/.gitignore b/tui/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/tui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000..cd8a532 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,48 @@ +# Sybil TUI + +Terminal UI client for Sybil with a sidebar + workspace flow similar to the web app. + +## Setup + +```bash +cd tui +npm install +npm run dev +``` + +Build/start: + +```bash +npm run build +npm run start +``` + +## Environment Variables + +Configuration is environment-only (no in-app settings). + +- `SYBIL_TUI_API_BASE_URL`: API base URL. Default: `http://127.0.0.1:8787` +- `SYBIL_TUI_ADMIN_TOKEN`: optional bearer token for token-mode servers +- `SYBIL_TUI_DEFAULT_PROVIDER`: `openai` | `anthropic` | `xai` (default: `openai`) +- `SYBIL_TUI_DEFAULT_MODEL`: optional default model name +- `SYBIL_TUI_SEARCH_NUM_RESULTS`: results per search run (default: `10`) + +Compatibility aliases: + +- `SYBIL_API_BASE_URL` (fallback for API URL) +- `SYBIL_ADMIN_TOKEN` (fallback for token) + +## Key Bindings + +- `Tab` / `Shift+Tab`: move focus between sidebar, transcript, and composer +- `Esc` (in composer): exit input mode and focus sidebar +- `Up` / `Down` (in sidebar): move highlight +- `Enter` in sidebar: load highlighted conversation/search +- `Enter` in composer: send message/search +- `n`: new chat draft +- `/`: new search draft +- `d`: delete selected chat/search +- `p`: cycle provider (chat mode) +- `m`: cycle model (chat mode) +- `r`: refresh collections + models +- `q` or `Ctrl+C`: quit diff --git a/tui/package-lock.json b/tui/package-lock.json new file mode 100644 index 0000000..7b0bf03 --- /dev/null +++ b/tui/package-lock.json @@ -0,0 +1,616 @@ +{ + "name": "sybil-tui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sybil-tui", + "version": "0.1.0", + "dependencies": { + "blessed": "^0.1.81" + }, + "devDependencies": { + "@types/blessed": "^0.1.25", + "@types/node": "^25.0.10", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/blessed": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz", + "integrity": "sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tui/package.json b/tui/package.json new file mode 100644 index 0000000..b118fe1 --- /dev/null +++ b/tui/package.json @@ -0,0 +1,21 @@ +{ + "name": "sybil-tui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "blessed": "^0.1.81" + }, + "devDependencies": { + "@types/blessed": "^0.1.25", + "@types/node": "^25.0.10", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/tui/src/api.ts b/tui/src/api.ts new file mode 100644 index 0000000..fe7698a --- /dev/null +++ b/tui/src/api.ts @@ -0,0 +1,272 @@ +import type { + ChatDetail, + ChatSummary, + CompletionRequestMessage, + CompletionStreamHandlers, + ModelCatalogResponse, + Provider, + SearchDetail, + SearchRunRequest, + SearchStreamHandlers, + SearchSummary, + SessionStatus, +} from "./types.js"; + +type RequestOptions = { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: unknown; + signal?: AbortSignal; + headers?: Record; +}; + +export class SybilApiClient { + private readonly baseUrl: string; + private readonly token: string | null; + + constructor(baseUrl: string, token: string | null) { + this.baseUrl = baseUrl; + this.token = token; + } + + async verifySession() { + return this.request("/v1/auth/session"); + } + + async listModels() { + return this.request("/v1/models"); + } + + async listChats() { + const data = await this.request<{ chats: ChatSummary[] }>("/v1/chats"); + return data.chats; + } + + async createChat(title?: string) { + const data = await this.request<{ chat: ChatSummary }>("/v1/chats", { + method: "POST", + body: { title }, + }); + return data.chat; + } + + async getChat(chatId: string) { + const data = await this.request<{ chat: ChatDetail }>(`/v1/chats/${chatId}`); + return data.chat; + } + + async suggestChatTitle(body: { chatId: string; content: string }) { + const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", { + method: "POST", + body, + }); + return data.chat; + } + + async deleteChat(chatId: string) { + await this.request<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" }); + } + + async listSearches() { + const data = await this.request<{ searches: SearchSummary[] }>("/v1/searches"); + return data.searches; + } + + async createSearch(body?: { title?: string; query?: string }) { + const data = await this.request<{ search: SearchSummary }>("/v1/searches", { + method: "POST", + body: body ?? {}, + }); + return data.search; + } + + async getSearch(searchId: string) { + const data = await this.request<{ search: SearchDetail }>(`/v1/searches/${searchId}`); + return data.search; + } + + async deleteSearch(searchId: string) { + await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); + } + + async runCompletionStream( + body: { + chatId: string; + provider: Provider; + model: string; + messages: CompletionRequestMessage[]; + }, + handlers: CompletionStreamHandlers, + options?: { signal?: AbortSignal } + ) { + await this.runSse( + "/v1/chat-completions/stream", + body, + { + meta: handlers.onMeta, + tool_call: handlers.onToolCall, + delta: handlers.onDelta, + done: handlers.onDone, + error: handlers.onError, + }, + options + ); + } + + async runSearchStream( + searchId: string, + body: SearchRunRequest, + handlers: SearchStreamHandlers, + options?: { signal?: AbortSignal } + ) { + await this.runSse( + `/v1/searches/${searchId}/run/stream`, + body, + { + search_results: handlers.onSearchResults, + search_error: handlers.onSearchError, + answer: handlers.onAnswer, + answer_error: handlers.onAnswerError, + done: handlers.onDone, + error: handlers.onError, + }, + options + ); + } + + private async request(path: string, options?: RequestOptions): Promise { + const headers = new Headers(options?.headers ?? {}); + const hasBody = options?.body !== undefined; + if (hasBody && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (this.token) { + headers.set("Authorization", `Bearer ${this.token}`); + } + + const init: RequestInit = { + method: options?.method ?? "GET", + headers, + }; + if (hasBody) { + init.body = JSON.stringify(options?.body); + } + if (options?.signal) { + init.signal = options.signal; + } + + const response = await fetch(`${this.baseUrl}${path}`, init); + + if (!response.ok) { + throw new Error(await this.readErrorMessage(response)); + } + + return (await response.json()) as T; + } + + private async runSse( + path: string, + body: unknown, + handlers: Record void) | undefined>, + options?: { signal?: AbortSignal } + ) { + const headers = new Headers({ + Accept: "text/event-stream", + "Content-Type": "application/json", + }); + if (this.token) { + headers.set("Authorization", `Bearer ${this.token}`); + } + + const init: RequestInit = { + method: "POST", + headers, + body: JSON.stringify(body), + }; + if (options?.signal) { + init.signal = options.signal; + } + + const response = await fetch(`${this.baseUrl}${path}`, init); + + if (!response.ok) { + throw new Error(await this.readErrorMessage(response)); + } + + if (!response.body) { + throw new Error("No response stream"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let eventName = "message"; + let dataLines: string[] = []; + + const flushEvent = () => { + if (!dataLines.length) { + eventName = "message"; + return; + } + + const dataText = dataLines.join("\n"); + let payload: any = null; + try { + payload = JSON.parse(dataText); + } catch { + payload = { message: dataText }; + } + + handlers[eventName]?.(payload); + dataLines = []; + eventName = "message"; + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let newlineIndex = buffer.indexOf("\n"); + + while (newlineIndex >= 0) { + const rawLine = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + if (!line) { + flushEvent(); + } else if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + + newlineIndex = buffer.indexOf("\n"); + } + } + + buffer += decoder.decode(); + if (buffer.length) { + const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + flushEvent(); + } + + private async readErrorMessage(response: Response) { + const fallback = `${response.status} ${response.statusText}`; + try { + const body = (await response.json()) as { message?: string }; + if (typeof body.message === "string" && body.message.trim()) { + return body.message; + } + return fallback; + } catch { + return fallback; + } + } +} diff --git a/tui/src/config.ts b/tui/src/config.ts new file mode 100644 index 0000000..f1084b0 --- /dev/null +++ b/tui/src/config.ts @@ -0,0 +1,48 @@ +import type { Provider } from "./types.js"; + +const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; + +function normalizeBaseUrl(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("SYBIL_TUI_API_BASE_URL cannot be empty"); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error(`Invalid SYBIL_TUI_API_BASE_URL: ${trimmed}`); + } + + const normalizedPath = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = normalizedPath || "/"; + return parsed.toString().replace(/\/$/, ""); +} + +function parseProvider(value: string | undefined): Provider { + const trimmed = value?.trim().toLowerCase(); + if (!trimmed) return "openai"; + if (PROVIDERS.includes(trimmed as Provider)) return trimmed as Provider; + throw new Error(`Invalid SYBIL_TUI_DEFAULT_PROVIDER: ${value}`); +} + +function parsePositiveInt(value: string | undefined, fallback: number) { + if (!value?.trim()) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid positive integer value: ${value}`); + } + return parsed; +} + +const apiBaseUrlValue = + process.env.SYBIL_TUI_API_BASE_URL?.trim() || process.env.SYBIL_API_BASE_URL?.trim() || "http://127.0.0.1:8787"; + +export const config = { + apiBaseUrl: normalizeBaseUrl(apiBaseUrlValue), + adminToken: process.env.SYBIL_TUI_ADMIN_TOKEN?.trim() || process.env.SYBIL_ADMIN_TOKEN?.trim() || null, + defaultProvider: parseProvider(process.env.SYBIL_TUI_DEFAULT_PROVIDER), + defaultModel: process.env.SYBIL_TUI_DEFAULT_MODEL?.trim() || null, + searchNumResults: parsePositiveInt(process.env.SYBIL_TUI_SEARCH_NUM_RESULTS, 10), +}; diff --git a/tui/src/index.ts b/tui/src/index.ts new file mode 100644 index 0000000..880862e --- /dev/null +++ b/tui/src/index.ts @@ -0,0 +1,1403 @@ +import blessed from "blessed"; +import { SybilApiClient } from "./api.js"; +import { config } from "./config.js"; +import type { + ChatDetail, + ChatSummary, + CompletionRequestMessage, + Message, + ModelCatalogResponse, + Provider, + SearchDetail, + SearchSummary, + ToolCallEvent, +} from "./types.js"; + +type SidebarSelection = { kind: "chat" | "search"; id: string }; +type DraftSelectionKind = "chat" | "search"; +type SidebarItem = SidebarSelection & { + title: string; + updatedAt: string; + createdAt: string; + initiatedProvider: Provider | null; + initiatedModel: string | null; + lastUsedProvider: Provider | null; + lastUsedModel: string | null; +}; + +type ToolLogMetadata = { + kind: "tool_call"; + toolCallId?: string; + toolName?: string; + status?: "completed" | "failed"; + summary?: string; + args?: Record; + startedAt?: string; + completedAt?: string; + durationMs?: number; + error?: string | null; + resultPreview?: string | null; +}; + +const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; +const PROVIDER_FALLBACK_MODELS: Record = { + openai: ["gpt-4.1-mini"], + anthropic: ["claude-3-5-sonnet-latest"], + xai: ["grok-3-mini"], +}; + +const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { + openai: { models: [], loadedAt: null, error: null }, + anthropic: { models: [], loadedAt: null, error: null }, + xai: { models: [], loadedAt: null, error: null }, +}; + +function escapeTags(value: string) { + return value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}"); +} + +function truncate(value: string, maxLength: number) { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function getProviderLabel(provider: Provider | null | undefined) { + if (provider === "openai") return "OpenAI"; + if (provider === "anthropic") return "Anthropic"; + if (provider === "xai") return "xAI"; + return ""; +} + +function getChatTitle(chat: Pick, messages?: ChatDetail["messages"]) { + if (chat.title?.trim()) return chat.title.trim(); + const firstUserMessage = messages?.find((message) => message.role === "user")?.content.trim(); + if (firstUserMessage) return firstUserMessage.slice(0, 48); + return "New chat"; +} + +function getSearchTitle(search: Pick) { + if (search.title?.trim()) return search.title.trim(); + if (search.query?.trim()) return search.query.trim().slice(0, 64); + return "New search"; +} + +function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] { + const items: SidebarItem[] = [ + ...chats.map((chat) => ({ + kind: "chat" as const, + id: chat.id, + title: getChatTitle(chat), + updatedAt: chat.updatedAt, + createdAt: chat.createdAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, + })), + ...searches.map((search) => ({ + kind: "search" as const, + id: search.id, + title: getSearchTitle(search), + updatedAt: search.updatedAt, + createdAt: search.createdAt, + initiatedProvider: null, + initiatedModel: null, + lastUsedProvider: null, + lastUsedModel: null, + })), + ]; + + return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +} + +function asToolLogMetadata(value: unknown): ToolLogMetadata | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.kind !== "tool_call") return null; + return record as ToolLogMetadata; +} + +function isToolCallLogMessage(message: Message) { + return asToolLogMetadata(message.metadata) !== null; +} + +function buildOptimisticToolMessage(event: ToolCallEvent): Message { + return { + id: `temp-tool-${event.toolCallId}`, + createdAt: event.completedAt ?? new Date().toISOString(), + role: "tool", + content: event.summary, + name: event.name, + metadata: { + kind: "tool_call", + toolCallId: event.toolCallId, + toolName: event.name, + status: event.status, + summary: event.summary, + args: event.args, + startedAt: event.startedAt, + completedAt: event.completedAt, + durationMs: event.durationMs, + error: event.error ?? null, + resultPreview: event.resultPreview ?? null, + } satisfies ToolLogMetadata, + }; +} + +function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { + const providerModels = catalog[provider]?.models ?? []; + if (providerModels.length) return providerModels; + return PROVIDER_FALLBACK_MODELS[provider]; +} + +function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { + if (fallback && options.includes(fallback)) return fallback; + if (preferred && options.includes(preferred)) return preferred; + return options[0] ?? ""; +} + +function selectionKey(selection: SidebarSelection | null) { + if (!selection) return null; + return `${selection.kind}:${selection.id}`; +} + +function formatHost(url: string) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +function isTextInputFocused(screen: blessed.Widgets.Screen, composer: blessed.Widgets.TextboxElement) { + return screen.focused === composer; +} + +async function main() { + const api = new SybilApiClient(config.apiBaseUrl, config.adminToken); + + let authMode: "open" | "token" | null = null; + let chats: ChatSummary[] = []; + let searches: SearchSummary[] = []; + let selectedItem: SidebarSelection | null = null; + let selectedChat: ChatDetail | null = null; + let selectedSearch: SearchDetail | null = null; + let draftKind: DraftSelectionKind | null = null; + let isLoadingCollections = false; + let isLoadingSelection = false; + let isSending = false; + let pendingChatState: { chatId: string | null; messages: Message[] } | null = null; + let provider: Provider = config.defaultProvider; + let modelCatalog: ModelCatalogResponse["providers"] = EMPTY_MODEL_CATALOG; + const providerModelPreferences: Record = { + openai: null, + anthropic: null, + xai: null, + }; + let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); + let errorMessage: string | null = null; + let forceScrollToBottom = true; + let searchRunController: AbortController | null = null; + let searchRunCounter = 0; + const pendingTitleGeneration = new Set(); + let renderedSidebarSelectedIndex = -1; + let highlightedSidebarKey: string | null = null; + let renderedSidebarItems: SidebarItem[] = []; + let renderedSidebarLines: string[] = []; + let suppressedSidebarSelectEvents = 0; + + const screen = blessed.screen({ + smartCSR: true, + fullUnicode: true, + title: "Sybil TUI", + }); + + const sidebar = blessed.list({ + parent: screen, + label: " Conversations ", + border: "line", + tags: true, + top: 0, + left: 0, + bottom: 0, + width: "32%", + keys: true, + vi: true, + mouse: true, + style: { + border: { fg: "magenta" }, + }, + scrollbar: { + ch: " ", + }, + scrollable: true, + }); + + const sidebarStyle = sidebar.style as any; + sidebarStyle.item = { + fg: "white", + bg: (item: unknown) => { + const sidebarItem = getRenderedSidebarItemByElement(item); + if (!sidebarItem) return undefined; + return selectionKey(sidebarItem) === selectionKey(selectedItem) ? "brightblack" : undefined; + }, + }; + sidebarStyle.selected = { + fg: "white", + bg: (item: unknown) => { + const isSidebarFocused = screen.focused === sidebar; + if (isSidebarFocused) return "blue"; + const sidebarItem = getRenderedSidebarItemByElement(item); + if (!sidebarItem) return undefined; + return selectionKey(sidebarItem) === selectionKey(selectedItem) ? "brightblack" : undefined; + }, + bold: () => screen.focused === sidebar, + }; + + const header = blessed.box({ + parent: screen, + label: " Workspace ", + border: "line", + tags: true, + top: 0, + left: "32%", + width: "68%", + height: 6, + style: { + border: { fg: "magenta" }, + label: { fg: "magenta" }, + }, + }); + + const transcript = blessed.box({ + parent: screen, + label: " Transcript ", + border: "line", + tags: true, + top: 6, + left: "32%", + width: "68%", + bottom: 3, + keys: true, + vi: true, + mouse: true, + scrollable: true, + alwaysScroll: true, + scrollbar: { + ch: " ", + }, + style: { + border: { fg: "magenta" }, + label: { fg: "magenta" }, + }, + }); + + const composer = blessed.textbox({ + parent: screen, + label: " Message Sybil ", + border: "line", + tags: true, + inputOnFocus: true, + keys: true, + mouse: true, + top: undefined, + bottom: 0, + left: "32%", + width: "68%", + height: 3, + style: { + border: { fg: "magenta" }, + label: { fg: "magenta" }, + fg: "white", + }, + }); + + const focusables = [sidebar, transcript, composer] as const; + + function withSuppressedSidebarSelectEvents(fn: () => void) { + suppressedSidebarSelectEvents += 1; + try { + fn(); + } finally { + suppressedSidebarSelectEvents -= 1; + } + } + + function getRenderedSidebarItemByElement(item: unknown) { + const listItems = ((sidebar as any).items as unknown[] | undefined) ?? []; + const index = listItems.indexOf(item); + if (index < 0) return null; + return renderedSidebarItems[index] ?? null; + } + + function getSidebarItems() { + return buildSidebarItems(chats, searches); + } + + function getSelectedChatSummary() { + if (!selectedItem || selectedItem.kind !== "chat") return null; + const selectedId = selectedItem.id; + return chats.find((chat) => chat.id === selectedId) ?? null; + } + + function getSelectedSearchSummary() { + if (!selectedItem || selectedItem.kind !== "search") return null; + const selectedId = selectedItem.id; + return searches.find((search) => search.id === selectedId) ?? null; + } + + function getIsSearchMode() { + if (draftKind) return draftKind === "search"; + return selectedItem?.kind === "search"; + } + + function getDisplayMessages() { + const canonicalMessages = selectedChat?.messages ?? []; + if (!pendingChatState) return canonicalMessages; + + if (pendingChatState.chatId) { + if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { + return pendingChatState.messages; + } + return canonicalMessages; + } + + if (getIsSearchMode()) return canonicalMessages; + return pendingChatState.messages; + } + + function syncModelForProvider() { + const options = getModelOptions(modelCatalog, provider); + model = pickProviderModel(options, providerModelPreferences[provider], model); + } + + function updateProviderModelFromSelectedChat() { + if (draftKind || selectedItem?.kind !== "chat") return; + + const detailSelection = + selectedChat?.id === selectedItem.id && selectedChat.lastUsedProvider && selectedChat.lastUsedModel?.trim() + ? { + provider: selectedChat.lastUsedProvider, + model: selectedChat.lastUsedModel.trim(), + } + : null; + + const summary = getSelectedChatSummary(); + const summarySelection = + summary?.lastUsedProvider && summary.lastUsedModel?.trim() + ? { + provider: summary.lastUsedProvider, + model: summary.lastUsedModel.trim(), + } + : null; + + const next = detailSelection ?? summarySelection; + if (!next) return; + provider = next.provider; + model = next.model; + } + + function getSelectedTitle() { + if (draftKind === "chat") return "New chat"; + if (draftKind === "search") return "New search"; + if (!selectedItem) return "Sybil"; + + if (selectedItem.kind === "chat") { + if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); + const summary = getSelectedChatSummary(); + return summary ? getChatTitle(summary) : "New chat"; + } + + if (selectedSearch) return getSearchTitle(selectedSearch); + const summary = getSelectedSearchSummary(); + return summary ? getSearchTitle(summary) : "New search"; + } + + function renderSidebar() { + const items = getSidebarItems(); + const showingEmptyState = items.length === 0 && !isLoadingCollections; + renderedSidebarItems = showingEmptyState ? [] : items; + + const lines = showingEmptyState + ? ["No chats/searches yet. Press n or /. "] + : items.map((item) => { + const kind = item.kind === "chat" ? "C" : "S"; + const title = truncate(item.title, 36); + const initiatedLabel = + item.kind === "chat" && item.initiatedModel + ? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}` + : ""; + return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`; + }); + + const linesChanged = + lines.length !== renderedSidebarLines.length || lines.some((line, index) => line !== renderedSidebarLines[index]); + if (linesChanged) { + withSuppressedSidebarSelectEvents(() => { + sidebar.setItems(lines); + }); + renderedSidebarLines = lines.slice(); + renderedSidebarSelectedIndex = -1; + } + + if (showingEmptyState) { + highlightedSidebarKey = null; + if (renderedSidebarSelectedIndex !== 0) { + withSuppressedSidebarSelectEvents(() => { + sidebar.select(0); + }); + renderedSidebarSelectedIndex = 0; + } + sidebar.setLabel(isLoadingCollections ? " Conversations (loading...) " : " Conversations "); + return; + } + + const activeKey = selectionKey(selectedItem); + const activeIndex = activeKey ? items.findIndex((item) => selectionKey(item) === activeKey) : -1; + const highlightedIndex = highlightedSidebarKey ? items.findIndex((item) => selectionKey(item) === highlightedSidebarKey) : -1; + const selectedIndex = highlightedIndex >= 0 ? highlightedIndex : activeIndex >= 0 ? activeIndex : items.length > 0 ? 0 : -1; + + if (selectedIndex >= 0 && renderedSidebarSelectedIndex !== selectedIndex) { + withSuppressedSidebarSelectEvents(() => { + sidebar.select(selectedIndex); + }); + renderedSidebarSelectedIndex = selectedIndex; + } + if (selectedIndex >= 0) { + const highlightedItem = items[selectedIndex]; + highlightedSidebarKey = highlightedItem ? selectionKey(highlightedItem) : null; + } else { + highlightedSidebarKey = null; + } + + sidebar.setLabel(isLoadingCollections ? " Conversations (loading...) " : " Conversations "); + } + + function buildChatTranscriptContent() { + const isSendingActiveChat = + isSending && + !getIsSearchMode() && + !!pendingChatState && + !!pendingChatState.chatId && + selectedItem?.kind === "chat" && + selectedItem.id === pendingChatState.chatId; + + const messages = getDisplayMessages(); + const parts: string[] = []; + + if (isLoadingSelection && messages.length === 0) { + parts.push("{gray-fg}Loading messages...{/gray-fg}"); + } + + if (!isLoadingSelection && messages.length === 0) { + parts.push("{gray-fg}No messages yet. Send a prompt to start.{/gray-fg}"); + } + + for (const message of messages) { + const toolMeta = asToolLogMetadata(message.metadata); + if (message.role === "tool" && toolMeta) { + const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}"; + const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed."; + parts.push(`${prefix} ${escapeTags(summary)}`); + continue; + } + + if (message.role === "user") { + parts.push(`{bold}{magenta-fg}You{/magenta-fg}{/bold}\n${escapeTags(message.content)}`); + continue; + } + + if (message.role === "assistant") { + const body = message.content.trim().length + ? escapeTags(message.content) + : isSendingActiveChat && message.id.startsWith("temp-assistant-") + ? "{gray-fg}Sybil is typing...{/gray-fg}" + : ""; + parts.push(`{bold}{cyan-fg}Sybil{/cyan-fg}{/bold}\n${body}`); + continue; + } + + parts.push(`{bold}${escapeTags(message.role)}{/bold}\n${escapeTags(message.content)}`); + } + + if (isSendingActiveChat && !messages.some((message) => message.id.startsWith("temp-assistant-"))) { + parts.push("{bold}{cyan-fg}Sybil{/cyan-fg}{/bold}\n{gray-fg}Sybil is typing...{/gray-fg}"); + } + + return parts.join("\n\n"); + } + + function buildSearchContent() { + const parts: string[] = []; + const search = selectedSearch; + const isSearchRunning = isSending && getIsSearchMode(); + + if (search?.query?.trim()) { + parts.push(`{bold}Results for{/bold} ${escapeTags(search.query.trim())}`); + parts.push( + `{gray-fg}${search.results.length} result${search.results.length === 1 ? "" : "s"}${search.latencyMs ? ` • ${search.latencyMs}ms` : ""}{/gray-fg}` + ); + } + + if (isSearchRunning || search?.answerText || search?.answerError) { + parts.push("{bold}{magenta-fg}Answer{/magenta-fg}{/bold}"); + if (isSearchRunning && !search?.answerText) { + parts.push("{gray-fg}Generating answer...{/gray-fg}"); + } else if (search?.answerText) { + parts.push(escapeTags(search.answerText)); + } + + if (search?.answerError) { + parts.push(`{red-fg}${escapeTags(search.answerError)}{/red-fg}`); + } + + const citations = (search?.answerCitations ?? []).filter((citation) => citation.url || citation.id); + if (citations.length > 0) { + const rendered = citations.slice(0, 8).map((citation, index) => { + const href = citation.url || citation.id || ""; + const label = citation.title?.trim() || formatHost(href); + return `[${index + 1}] ${escapeTags(label)} - ${escapeTags(href)}`; + }); + parts.push("{gray-fg}Citations{/gray-fg}\n" + rendered.join("\n")); + } + } + + if ((isLoadingSelection || isSearchRunning) && !search?.results.length) { + parts.push(`{gray-fg}${isSearchRunning ? "Searching Exa..." : "Loading search..."}{/gray-fg}`); + } + + if (!isLoadingSelection && !isSearchRunning && search?.query && search.results.length === 0) { + parts.push("{gray-fg}No results found.{/gray-fg}"); + } + + if (search?.results.length) { + parts.push("{bold}Results{/bold}"); + for (let index = 0; index < search.results.length; index += 1) { + const result = search.results[index]; + if (!result) continue; + parts.push( + `${index + 1}. {cyan-fg}${escapeTags(result.title || result.url)}{/cyan-fg}\n` + + `{gray-fg}${escapeTags(formatHost(result.url))}{/gray-fg}\n` + + `${escapeTags(result.url)}` + ); + } + } + + if (search?.error) { + parts.push(`{red-fg}${escapeTags(search.error)}{/red-fg}`); + } + + if (parts.length === 0) { + parts.push("{gray-fg}Run a search to see results and the answer panel.{/gray-fg}"); + } + + return parts.join("\n\n"); + } + + function renderHeader() { + const isSearchMode = getIsSearchMode(); + const providerModelOptions = getModelOptions(modelCatalog, provider); + const modeLabel = authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""; + const sendState = isSending ? "{yellow-fg}Sending...{/yellow-fg}" : "{green-fg}Ready{/green-fg}"; + const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`; + + let controls = + "{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit"; + if (!isSearchMode) { + controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`; + controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : ""; + } + + const status = errorMessage ? `{red-fg}${escapeTags(errorMessage)}{/red-fg}` : sendState; + header.setContent(`${top}\n${controls}\n${status}`); + } + + function applyPaneFocusStyles() { + const isSidebarFocused = screen.focused === sidebar; + const isTranscriptFocused = screen.focused === transcript; + const isComposerFocused = screen.focused === composer; + const isWorkspaceFocused = isTranscriptFocused || isComposerFocused; + + (sidebar.style as any).border = { fg: isSidebarFocused ? "cyan" : "magenta" }; + (sidebar.style as any).label = { fg: isSidebarFocused ? "cyan" : "magenta" }; + (header.style as any).border = { fg: isWorkspaceFocused ? "cyan" : "magenta" }; + (header.style as any).label = { fg: isWorkspaceFocused ? "cyan" : "magenta" }; + (transcript.style as any).border = { fg: isTranscriptFocused ? "cyan" : "magenta" }; + (transcript.style as any).label = { fg: isTranscriptFocused ? "cyan" : "magenta" }; + (composer.style as any).border = { fg: isSending ? "yellow" : isComposerFocused ? "cyan" : "magenta" }; + (composer.style as any).label = { fg: isComposerFocused ? "cyan" : "magenta" }; + } + + function updateUI() { + renderSidebar(); + renderHeader(); + + const content = getIsSearchMode() ? buildSearchContent() : buildChatTranscriptContent(); + transcript.setContent(content); + + if (forceScrollToBottom) { + transcript.setScrollPerc(100); + forceScrollToBottom = false; + } + + const composerLabel = getIsSearchMode() ? " Search the web " : " Message Sybil "; + composer.setLabel(composerLabel); + applyPaneFocusStyles(); + + screen.render(); + } + + function setError(message: string | null) { + errorMessage = message; + updateUI(); + } + + function resetWorkspaceState() { + chats = []; + searches = []; + selectedItem = null; + selectedChat = null; + selectedSearch = null; + draftKind = null; + pendingChatState = null; + isLoadingCollections = false; + isLoadingSelection = false; + isSending = false; + forceScrollToBottom = true; + renderedSidebarSelectedIndex = -1; + highlightedSidebarKey = null; + renderedSidebarItems = []; + renderedSidebarLines = []; + } + + function hasItem(items: SidebarItem[], selection: SidebarSelection | null) { + if (!selection) return false; + return items.some((item) => item.kind === selection.kind && item.id === selection.id); + } + + async function loadSelection(selection: SidebarSelection | null) { + if (!selection) { + selectedChat = null; + selectedSearch = null; + updateUI(); + return; + } + + const requestedSelectionKey = selectionKey(selection); + isLoadingSelection = true; + updateUI(); + + try { + if (selection.kind === "chat") { + const chat = await api.getChat(selection.id); + if (selectionKey(selectedItem) !== requestedSelectionKey) return; + selectedChat = chat; + selectedSearch = null; + } else { + const search = await api.getSearch(selection.id); + if (selectionKey(selectedItem) !== requestedSelectionKey) return; + selectedSearch = search; + selectedChat = null; + } + + updateProviderModelFromSelectedChat(); + } finally { + if (selectionKey(selectedItem) === requestedSelectionKey) { + isLoadingSelection = false; + } + forceScrollToBottom = true; + updateUI(); + } + } + + async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) { + isLoadingCollections = true; + updateUI(); + + try { + const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]); + chats = nextChats; + searches = nextSearches; + + const nextItems = buildSidebarItems(nextChats, nextSearches); + if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { + selectedItem = options.preferredSelection; + draftKind = null; + } else if (selectedItem && !hasItem(nextItems, selectedItem)) { + selectedItem = nextItems[0] ? { kind: nextItems[0].kind, id: nextItems[0].id } : null; + } else if (!selectedItem && !draftKind) { + selectedItem = nextItems[0] ? { kind: nextItems[0].kind, id: nextItems[0].id } : null; + } + + if (!selectedItem && !draftKind) { + selectedChat = null; + selectedSearch = null; + } + } finally { + isLoadingCollections = false; + updateUI(); + } + + if (options?.loadSelection) { + await loadSelection(selectedItem); + } + } + + async function refreshModels() { + const data = await api.listModels(); + modelCatalog = data.providers; + syncModelForProvider(); + updateUI(); + } + + function focusComposer() { + composer.focus(); + composer.readInput(); + } + + function cycleFocus(step: 1 | -1) { + const focused = screen.focused; + const currentIndex = focusables.findIndex((node) => node === focused); + const next = focusables[(currentIndex + step + focusables.length) % focusables.length] ?? focusables[0]; + next.focus(); + if (next === composer) { + composer.readInput(); + } + updateUI(); + } + + function enterCommandMode() { + if (!isTextInputFocused(screen, composer)) return; + sidebar.focus(); + updateUI(); + } + + async function selectSidebarIndex(index: number) { + const item = getSidebarItems()[index]; + if (!item || item.id === "") return; + highlightedSidebarKey = selectionKey(item) ?? null; + renderedSidebarSelectedIndex = index; + if (selectedItem?.kind === item.kind && selectedItem.id === item.id && !draftKind) { + updateUI(); + return; + } + + draftKind = null; + selectedItem = { kind: item.kind, id: item.id }; + pendingChatState = null; + errorMessage = null; + forceScrollToBottom = true; + updateUI(); + await loadSelection(selectedItem); + } + + function handleCreateChat() { + draftKind = "chat"; + selectedItem = null; + selectedChat = null; + selectedSearch = null; + pendingChatState = null; + errorMessage = null; + forceScrollToBottom = true; + updateUI(); + focusComposer(); + } + + function handleCreateSearch() { + draftKind = "search"; + selectedItem = null; + selectedChat = null; + selectedSearch = null; + pendingChatState = null; + errorMessage = null; + forceScrollToBottom = true; + updateUI(); + focusComposer(); + } + + async function maybeSuggestTitle(chatId: string, content: string) { + const chatSummary = chats.find((chat) => chat.id === chatId); + const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); + if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return; + + pendingTitleGeneration.add(chatId); + try { + const updated = await api.suggestChatTitle({ chatId, content }); + chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat)); + if (selectedChat?.id === updated.id) { + selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt }; + } + updateUI(); + } catch { + // ignored intentionally so chat flow is not interrupted + } finally { + pendingTitleGeneration.delete(chatId); + } + } + + async function handleSendChat(content: string) { + const optimisticUserMessage: Message = { + id: `temp-user-${Date.now()}`, + createdAt: new Date().toISOString(), + role: "user", + content, + name: null, + metadata: null, + }; + + const optimisticAssistantMessage: Message = { + id: `temp-assistant-${Date.now()}`, + createdAt: new Date().toISOString(), + role: "assistant", + content: "", + name: null, + metadata: null, + }; + + pendingChatState = { + chatId: selectedItem?.kind === "chat" ? selectedItem.id : null, + messages: (selectedChat?.messages ?? []).concat(optimisticUserMessage, optimisticAssistantMessage), + }; + forceScrollToBottom = true; + updateUI(); + + let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; + + if (!chatId) { + const chat = await api.createChat(); + chatId = chat.id; + draftKind = null; + chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; + selectedItem = { kind: "chat", id: chat.id }; + pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; + selectedChat = { + id: chat.id, + title: chat.title, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + initiatedProvider: chat.initiatedProvider, + initiatedModel: chat.initiatedModel, + lastUsedProvider: chat.lastUsedProvider, + lastUsedModel: chat.lastUsedModel, + messages: [], + }; + selectedSearch = null; + forceScrollToBottom = true; + updateUI(); + } + + if (!chatId) { + throw new Error("Unable to initialize chat"); + } + + void maybeSuggestTitle(chatId, content); + + let baseChat = selectedChat; + if (!baseChat || baseChat.id !== chatId) { + baseChat = await api.getChat(chatId); + } + + const requestMessages: CompletionRequestMessage[] = [ + ...baseChat.messages + .filter((message) => !isToolCallLogMessage(message)) + .map((message) => ({ + role: message.role, + content: message.content, + ...(message.name ? { name: message.name } : {}), + })), + { + role: "user", + content, + }, + ]; + + const selectedModel = model.trim(); + if (!selectedModel) { + throw new Error("No model available for selected provider"); + } + + let streamErrorMessage: string | null = null; + + await api.runCompletionStream( + { + chatId, + provider, + model: selectedModel, + messages: requestMessages, + }, + { + onMeta: (payload) => { + if (payload.chatId !== chatId) return; + pendingChatState = pendingChatState ? { ...pendingChatState, chatId: payload.chatId } : pendingChatState; + updateUI(); + }, + onToolCall: (payload) => { + if (!pendingChatState) return; + const alreadyPresent = pendingChatState.messages.some( + (message) => + asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` + ); + if (alreadyPresent) return; + + const toolMessage = buildOptimisticToolMessage(payload); + const assistantIndex = pendingChatState.messages.findIndex( + (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") + ); + + if (assistantIndex < 0) { + pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) }; + } else { + pendingChatState = { + ...pendingChatState, + messages: [ + ...pendingChatState.messages.slice(0, assistantIndex), + toolMessage, + ...pendingChatState.messages.slice(assistantIndex), + ], + }; + } + + forceScrollToBottom = true; + updateUI(); + }, + onDelta: (payload) => { + if (!payload.text || !pendingChatState) return; + + let updated = false; + const nextMessages = pendingChatState.messages.map((message, index, all) => { + const target = index === all.length - 1 && message.id.startsWith("temp-assistant-"); + if (!target) return message; + updated = true; + return { ...message, content: message.content + payload.text }; + }); + + if (!updated) return; + pendingChatState = { ...pendingChatState, messages: nextMessages }; + forceScrollToBottom = true; + updateUI(); + }, + onDone: (payload) => { + if (!pendingChatState) return; + + let updated = false; + const nextMessages = pendingChatState.messages.map((message, index, all) => { + const target = index === all.length - 1 && message.id.startsWith("temp-assistant-"); + if (!target) return message; + updated = true; + return { ...message, content: payload.text }; + }); + + if (!updated) return; + pendingChatState = { ...pendingChatState, messages: nextMessages }; + forceScrollToBottom = true; + updateUI(); + }, + onError: (payload) => { + streamErrorMessage = payload.message; + }, + } + ); + + if (streamErrorMessage) { + throw new Error(streamErrorMessage); + } + + await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false }); + + const currentSelection = selectedItem; + if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { + await loadSelection(currentSelection); + } + + pendingChatState = null; + forceScrollToBottom = true; + updateUI(); + } + + async function handleSendSearch(query: string) { + const runId = ++searchRunCounter; + searchRunController?.abort(); + const abortController = new AbortController(); + searchRunController = abortController; + + let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null; + + if (!searchId) { + const search = await api.createSearch({ + query, + title: query.slice(0, 80), + }); + searchId = search.id; + draftKind = null; + selectedItem = { kind: "search", id: searchId }; + searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; + selectedChat = null; + forceScrollToBottom = true; + updateUI(); + } + + if (!searchId) { + throw new Error("Unable to initialize search"); + } + + const nowIso = new Date().toISOString(); + if (!selectedSearch || selectedSearch.id !== searchId) { + selectedSearch = { + id: searchId, + title: query.slice(0, 80), + query, + createdAt: nowIso, + updatedAt: nowIso, + requestId: null, + latencyMs: null, + error: null, + answerText: null, + answerRequestId: null, + answerCitations: null, + answerError: null, + results: [], + }; + } else { + selectedSearch = { + ...selectedSearch, + title: query.slice(0, 80), + query, + error: null, + latencyMs: null, + answerText: null, + answerRequestId: null, + answerCitations: null, + answerError: null, + results: [], + }; + } + + forceScrollToBottom = true; + updateUI(); + + try { + await api.runSearchStream( + searchId, + { + query, + title: query.slice(0, 80), + type: "auto", + numResults: config.searchNumResults, + }, + { + onSearchResults: (payload) => { + if (runId !== searchRunCounter) return; + if (!selectedSearch || selectedSearch.id !== searchId) return; + selectedSearch = { + ...selectedSearch, + requestId: payload.requestId ?? selectedSearch.requestId, + error: null, + results: payload.results, + }; + forceScrollToBottom = true; + updateUI(); + }, + onSearchError: (payload) => { + if (runId !== searchRunCounter) return; + if (!selectedSearch || selectedSearch.id !== searchId) return; + selectedSearch = { ...selectedSearch, error: payload.error }; + updateUI(); + }, + onAnswer: (payload) => { + if (runId !== searchRunCounter) return; + if (!selectedSearch || selectedSearch.id !== searchId) return; + selectedSearch = { + ...selectedSearch, + answerText: payload.answerText, + answerRequestId: payload.answerRequestId, + answerCitations: payload.answerCitations, + answerError: null, + }; + forceScrollToBottom = true; + updateUI(); + }, + onAnswerError: (payload) => { + if (runId !== searchRunCounter) return; + if (!selectedSearch || selectedSearch.id !== searchId) return; + selectedSearch = { ...selectedSearch, answerError: payload.error }; + updateUI(); + }, + onDone: (payload) => { + if (runId !== searchRunCounter) return; + selectedSearch = payload.search; + selectedChat = null; + forceScrollToBottom = true; + updateUI(); + }, + onError: (payload) => { + if (runId !== searchRunCounter) return; + setError(payload.message); + }, + }, + { signal: abortController.signal } + ); + } catch (error) { + if (abortController.signal.aborted) return; + throw error; + } finally { + if (runId === searchRunCounter) { + searchRunController = null; + } + } + + await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false }); + + const currentSelection = selectedItem; + if (currentSelection?.kind === "search" && currentSelection.id === searchId) { + await loadSelection(currentSelection); + } + } + + async function handleSend(content: string) { + const trimmed = content.trim(); + if (!trimmed || isSending) return; + + const isSearchMode = getIsSearchMode(); + setError(null); + isSending = true; + updateUI(); + + try { + if (isSearchMode) { + await handleSendSearch(trimmed); + } else { + await handleSendChat(trimmed); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setError(message); + + if (!isSearchMode) { + pendingChatState = null; + } + + if (selectedItem) { + await loadSelection(selectedItem); + } + } finally { + isSending = false; + updateUI(); + focusComposer(); + } + } + + async function handleDeleteSelection() { + if (!selectedItem || isSending) return; + + const target = selectedItem; + setError(null); + + if (target.kind === "chat") { + await api.deleteChat(target.id); + } else { + await api.deleteSearch(target.id); + } + + if (target.kind === "chat" && selectedChat?.id === target.id) { + selectedChat = null; + } + if (target.kind === "search" && selectedSearch?.id === target.id) { + selectedSearch = null; + } + + await refreshCollections({ loadSelection: true }); + } + + function cycleProvider() { + const currentIndex = PROVIDERS.indexOf(provider); + const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.length] ?? "openai"; + provider = nextProvider; + syncModelForProvider(); + updateUI(); + } + + function cycleModel() { + const options = getModelOptions(modelCatalog, provider); + if (!options.length) { + setError("No models available for the selected provider"); + return; + } + + const currentIndex = Math.max(0, options.indexOf(model)); + const nextModel = options[(currentIndex + 1) % options.length] ?? options[0]; + if (!nextModel) { + setError("No models available for the selected provider"); + return; + } + model = nextModel; + providerModelPreferences[provider] = nextModel; + updateUI(); + } + + async function runAction(action: () => Promise | void) { + try { + await action(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setError(message); + } + } + + sidebar.on("focus", () => { + updateUI(); + }); + transcript.on("focus", () => { + updateUI(); + }); + composer.on("focus", () => { + updateUI(); + }); + + sidebar.on("select item", (_item, index) => { + if (suppressedSidebarSelectEvents > 0) return; + const highlightedItem = getSidebarItems()[index]; + highlightedSidebarKey = highlightedItem ? selectionKey(highlightedItem) : null; + renderedSidebarSelectedIndex = index; + updateUI(); + }); + + sidebar.on("action", (_item, index) => { + if (typeof index !== "number") return; + void runAction(async () => { + await selectSidebarIndex(index); + }); + }); + + composer.on("submit", (value) => { + const text = typeof value === "string" ? value : ""; + composer.clearValue(); + updateUI(); + void runAction(async () => { + await handleSend(text); + }); + }); + + screen.key(["C-c"], () => { + screen.destroy(); + process.exit(0); + }); + + screen.key(["q"], () => { + if (isTextInputFocused(screen, composer)) return; + screen.destroy(); + process.exit(0); + }); + + screen.key(["tab"], () => { + if (isTextInputFocused(screen, composer)) return; + cycleFocus(1); + }); + + screen.key(["S-tab", "backtab"], () => { + if (isTextInputFocused(screen, composer)) return; + cycleFocus(-1); + }); + + composer.key(["tab"], () => { + cycleFocus(1); + }); + + composer.key(["S-tab", "backtab"], () => { + cycleFocus(-1); + }); + + composer.key(["escape"], () => { + enterCommandMode(); + }); + + screen.key(["n"], () => { + if (isTextInputFocused(screen, composer)) return; + handleCreateChat(); + }); + + screen.key(["/"], () => { + if (isTextInputFocused(screen, composer)) return; + handleCreateSearch(); + }); + + screen.key(["d"], () => { + if (isTextInputFocused(screen, composer)) return; + void runAction(async () => { + await handleDeleteSelection(); + }); + }); + + screen.key(["p"], () => { + if (isTextInputFocused(screen, composer)) return; + if (getIsSearchMode() || isSending) return; + cycleProvider(); + }); + + screen.key(["m"], () => { + if (isTextInputFocused(screen, composer)) return; + if (getIsSearchMode() || isSending) return; + cycleModel(); + }); + + screen.key(["r"], () => { + if (isTextInputFocused(screen, composer)) return; + void runAction(async () => { + await refreshCollections({ loadSelection: true }); + await refreshModels(); + }); + }); + + process.on("SIGINT", () => { + screen.destroy(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + screen.destroy(); + process.exit(0); + }); + + updateUI(); + + try { + const session = await api.verifySession(); + authMode = session.mode; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + resetWorkspaceState(); + updateUI(); + + screen.destroy(); + + if (message.includes("bearer token")) { + const tokenHint = + "Set SYBIL_TUI_ADMIN_TOKEN (or SYBIL_ADMIN_TOKEN) and rerun. Example: SYBIL_TUI_ADMIN_TOKEN=... npm run dev"; + console.error(`Authentication failed: ${message}\n${tokenHint}`); + } else { + console.error(`Failed to connect to Sybil API at ${config.apiBaseUrl}: ${message}`); + } + process.exit(1); + } + + await runAction(async () => { + await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]); + }); + + focusComposer(); + updateUI(); +} + +void main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); +}); diff --git a/tui/src/types.ts b/tui/src/types.ts new file mode 100644 index 0000000..e04480c --- /dev/null +++ b/tui/src/types.ts @@ -0,0 +1,140 @@ +export type Provider = "openai" | "anthropic" | "xai"; + +export type ProviderModelInfo = { + models: string[]; + loadedAt: string | null; + error: string | null; +}; + +export type ModelCatalogResponse = { + providers: Record; +}; + +export type ChatSummary = { + id: string; + title: string | null; + createdAt: string; + updatedAt: string; + initiatedProvider: Provider | null; + initiatedModel: string | null; + lastUsedProvider: Provider | null; + lastUsedModel: string | null; +}; + +export type SearchSummary = { + id: string; + title: string | null; + query: string | null; + createdAt: string; + updatedAt: string; +}; + +export type Message = { + id: string; + createdAt: string; + role: "system" | "user" | "assistant" | "tool"; + content: string; + name: string | null; + metadata: unknown | null; +}; + +export type ToolCallEvent = { + toolCallId: string; + name: string; + status: "completed" | "failed"; + summary: string; + args: Record; + startedAt: string; + completedAt: string; + durationMs: number; + error?: string; + resultPreview?: string; +}; + +export type ChatDetail = { + id: string; + title: string | null; + createdAt: string; + updatedAt: string; + initiatedProvider: Provider | null; + initiatedModel: string | null; + lastUsedProvider: Provider | null; + lastUsedModel: string | null; + messages: Message[]; +}; + +export type SearchResultItem = { + id: string; + createdAt: string; + rank: number; + title: string | null; + url: string; + publishedDate: string | null; + author: string | null; + text: string | null; + highlights: string[] | null; + highlightScores: number[] | null; + score: number | null; + favicon: string | null; + image: string | null; +}; + +export type SearchDetail = { + id: string; + title: string | null; + query: string | null; + createdAt: string; + updatedAt: string; + requestId: string | null; + latencyMs: number | null; + error: string | null; + answerText: string | null; + answerRequestId: string | null; + answerCitations: Array<{ + id?: string; + url?: string; + title?: string | null; + publishedDate?: string | null; + author?: string | null; + text?: string | null; + }> | null; + answerError: string | null; + results: SearchResultItem[]; +}; + +export type SearchRunRequest = { + query?: string; + title?: string; + type?: "auto" | "fast" | "deep" | "instant"; + numResults?: number; + includeDomains?: string[]; + excludeDomains?: string[]; +}; + +export type CompletionRequestMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + name?: string; +}; + +export type CompletionStreamHandlers = { + onMeta?: (payload: { chatId: string; callId: string; provider: Provider; model: string }) => void; + onToolCall?: (payload: ToolCallEvent) => void; + onDelta?: (payload: { text: string }) => void; + onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void; + onError?: (payload: { message: string }) => void; +}; + +export type SearchStreamHandlers = { + onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void; + onSearchError?: (payload: { error: string }) => void; + onAnswer?: (payload: { answerText: string | null; answerRequestId: string | null; answerCitations: SearchDetail["answerCitations"] }) => void; + onAnswerError?: (payload: { error: string }) => void; + onDone?: (payload: { search: SearchDetail }) => void; + onError?: (payload: { message: string }) => void; +}; + +export type SessionStatus = { + authenticated: true; + mode: "open" | "token"; +}; diff --git a/tui/tsconfig.json b/tui/tsconfig.json new file mode 100644 index 0000000..85230e4 --- /dev/null +++ b/tui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}