diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index abb3011..ae19b83 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -9,12 +9,12 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json - name: Setup Biome - uses: biomejs/setup-biome@29711cbb52afee00eb13aeb30636592f9edc0088 # v2.7.0 + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2.7.1 with: version: latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b687f95..1b746ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Bun - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..d80a17f --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "@franlol/opencode-md-table-formatter@latest", + "opencode-helicone-session", + "opencode-pty", + "opencode-vibeguard", + "opencode-wakatime" + ], + "permission": { + "skill": { + "*": "allow", + "pr-review": "allow", + "internal-*": "deny", + "experimental-*": "ask" + } + }, + "mcp": { + "browser-mcp": { + "type": "local", + "command": ["bunx", "-y", "@browsermcp/mcp@latest"] + }, + "sqlite-mcp": { + "type": "local", + "command": ["bunx", "-y", "sqlite-mcp-server"] + } + }, + "lsp": {} +} diff --git a/.opencode/package.json b/.opencode/package.json index 47703c0..4b8b4cb 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@opencode-ai/plugin": "1.2.27" + "@opencode-ai/plugin": "1.3.6" } } diff --git a/.opencode/skills/git-release/SKILL.md b/.opencode/skills/git-release/SKILL.md deleted file mode 100644 index 959f8de..0000000 --- a/.opencode/skills/git-release/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: git-release -description: Create consistent releases and changelogs -license: MIT -compatibility: opencode -metadata: - audience: maintainers - workflow: github ---- - -## What I do - -- Draft release notes from merged PRs -- Propose a version bump -- Provide a copy-pasteable `gh release create` command - -## When to use me - -Use this when you are preparing a tagged release. -Ask clarifying questions if the target versioning scheme is unclear. diff --git a/README.md b/README.md index 45e1124..158ce28 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,34 @@ -# try-opentui +# Agent TUI -## Project Overview +A Bun-based TypeScript TUI (Terminal User Interface) application using `@opentui/core` for rendering. -This is a Bun-based TypeScript TUI (Terminal User Interface) application using `@opentui/core` for rendering. +## Features -## Development & CLI Commands Guide +- **Chat Interface**: Interactive chat with AI assistant +- **Command Palette**: Press `Ctrl+K` to access commands +- **Theme Switching**: Multiple themes (default, nord, dracula, nightowl) +- **Model Switching**: Support for multiple LLM models +- **Keyboard Shortcuts**: Efficient keyboard-driven navigation -### Installation & Running +## Commands + +| Command | Description | +| ------- | ----------- | +| `/theme ` | Switch theme (default, nord, dracula, nightowl) | +| `/models ` | Switch LLM model | +| `/exit` | Exit application | +| `/help` | Show available commands | + +## Keyboard Shortcuts + +| Shortcut | Action | +| -------- | ------ | +| `Tab` | Toggle Plan/Build mode | +| `Ctrl+K` | Open command palette | +| `Ctrl+P` | Quit application | +| `Enter` | Send message | + +## Installation & Running ```bash bun install # Install dependencies @@ -14,7 +36,7 @@ bun dev # Run in development mode with file watching bun start # Run the application ``` -### Linting & Formatting +## Linting & Formatting ```bash bun lint # Run oxlint to check code quality @@ -22,31 +44,53 @@ bun lint:fix # Run oxlint with auto-fix bun format # Run oxfmt to format code ``` -### Type Checking & Pre-commit +## Type Checking & Pre-commit ```bash bun typecheck # Run TypeScript type checking (tsgo) bun run actions:up # Run actions-up (updates?) ``` -**Note:** There is no test framework configured in this project yet. +## Testing + +```bash +bun test # Run tests +``` + +## Environment Variables ---- +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `LLM_BASE_URL` | http://localhost:11434 | LLM API endpoint | +| `LLM_API_KEY` | - | API key for authentication | +| `LLM_MODEL` | llama3 | Default model to use | ## File Organization ```bash -├── main.ts # Entry point +├── main.ts # Entry point ├── src/ -│ ├── config.ts # Configuration management -│ ├── container.ts # Main UI container component -│ ├── renderer.ts # Renderer lifecycle management -│ ├── theme.ts # Theme loading and management -│ ├── utils.ts # Utility functions -│ └── figures.ts # Figure/shape utilities -├── docs/ # Documentation -├── themes/ # Theme JSON files -├── .oxlintrc.json # Linting rules -├── .oxfmtrc.json # Formatting rules -└── tsconfig.json # TypeScript configuration +│ ├── cli.ts # CLI argument parsing +│ ├── config.ts # Configuration management +│ ├── container.ts # Main UI container component +│ ├── renderer.ts # Renderer lifecycle management +│ ├── theme.ts # Theme loading and management +│ ├── utils.ts # Utility functions +│ ├── figures.ts # Figure/shape utilities +│ ├── errors.ts # Error types +│ ├── types.ts # TypeScript type definitions +│ ├── store.ts # State management +│ ├── message-list.ts # Chat message display +│ ├── typing-indicator.ts # Thinking indicator +│ ├── command-palette.ts # Command palette UI +│ ├── status-bar.ts # Bottom status bar +│ ├── llm-types.ts # LLM client types +│ ├── llm-client.ts # LLM HTTP client +│ └── connection-manager.ts # Connection management +├── test/ # Test files +├── docs/ # Documentation +├── themes/ # Theme JSON files +├── .oxlintrc.json # Linting rules +├── .oxfmtrc.json # Formatting rules +└── tsconfig.json # TypeScript configuration ``` diff --git a/bun.lock b/bun.lock index 06a07cb..d02078d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,15 @@ "": { "name": "try-opentui", "dependencies": { - "@opentui/core": "^0.1.90", + "@opentui/core": "^0.1.92", }, "devDependencies": { "@types/bun": "latest", "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260320.1", + "@typescript/native-preview": "^7.0.0-dev.20260329.1", "actions-up": "^1.12.0", "oxfmt": "^0.35.0", - "oxlint": "^1.56.0", + "oxlint": "^1.57.0", "simple-git-hooks": "^2.13.1", }, "peerDependencies": { @@ -80,19 +80,19 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="], + "@opentui/core": ["@opentui/core@0.1.92", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.92", "@opentui/core-darwin-x64": "0.1.92", "@opentui/core-linux-arm64": "0.1.92", "@opentui/core-linux-x64": "0.1.92", "@opentui/core-win32-arm64": "0.1.92", "@opentui/core-win32-x64": "0.1.92", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-c+KdYAIH3M8n24RYaor+t7AQtKZ3l84L7xdP7DEaN4xtuYH8W08E6Gi+wUal4g+HSai3HS9irox68yFf0VPAxw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NX/qFRuc7My0pazyOrw9fdTXmU7omXcZzQuHcsaVnwssljaT52UYMrJ7mCKhSo69RhHw0lnGCymTorvz3XBdsA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zb4jn33hOf167llINKLniOabQIycs14LPOBZnQ6l4khbeeTPVJdG8gy9PhlAyIQygDKmRTFncVlP0RP+L6C7og=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-4VA1A91OTMPJ3LkAyaxKEZVJsk5jIc3Kz0gV2vip8p2aGLPpYHHpkFZpXP/FyzsnJzoSGftBeA6ya1GKa5bkXg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tr7va8hfKS1uY+TBmulQBoBlwijzJk56K/U/L9/tbHfW7oJctqxPVwEFHIh1HDcOQ3/UhMMWGvMfeG6cFiK8/A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-34YM3uPtDjzUVeSnJWIK2J8mxyduzV7f3mYc4Hub0glNpUdM1jjzF2HvvvnrKK5ElzTsIcno3c3lOYT8yvG1Zg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-uk442kA2Vn0mmJHHqk5sPM+Zai/AN9sgl7egekhoEOUx2VK3gxftKsVlx2YVpCHTvTE/S+vnD2WpQaJk2SNjww=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="], @@ -132,43 +132,43 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -176,21 +176,21 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260320.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260320.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260320.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260320.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260320.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260320.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260320.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260320.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uLkkdYwvJQ/atPqMW9RUt1j71lcbL53e7a+G/I3AXb5QioMdWkG6nbNfKPM+06RgXwPDyDV9+bI0FT/13BEVgg=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260329.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260329.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260329.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260329.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260329.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260329.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260329.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260329.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-v5lJ0TgSt2m9yVk2xoj9+NH/gTDeWTLaWGPx6MJsUKOYd6bmCJhHbMcWmb8d/zlfhE9ffpixUKYj62CdYfriqA=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260320.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9zadqk/wJ+4bCheapoP/wZZxIGbHotKj+QDvwlCeB93LFrcuIYDt8q91xW1tzrc12YrUTgWXqJuYHTNZ9fzyHw=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260329.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zS1thDk7luD82nXVwvMd97F7FgxAE6jGtSmnHeXdaQ+6hJQcQLOVkfUdaehhdodqKDapWA2jEURxQAYjDGvv3g=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260320.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-uw3sflQVaIIJhfu6xtFFogE8aC0MQ4xt9yRx2CDngp8/PQ0Qp8xp0LrUigQHbwtt7Y8AKR3YCRaX53rbovj4dg=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260329.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-3IJ2qmpjQ1OXpZNUhJRjF1+SbDuqGC14Ug8DjWJlPBp06isi1fcJph90f5qW//FxEsNnJPYRcNwpP0A2RbTASg=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260320.1", "", { "os": "linux", "cpu": "arm" }, "sha512-tCKwcnikt4uvSfHc14A6FJwEQox1yTsKcssTsAhxycH2bYuUr05AQNUdud3W41tr0MySHrcQG4CstzFxE9C86w=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260329.1", "", { "os": "linux", "cpu": "arm" }, "sha512-WKSSJrH611DFFAg6YCkgbnkdy0a4RRpzvDpNXtPzLTbMYC5oJdq3Dpvncx5nrJvGh4J4yvzXoMxraGPyygqGLw=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260320.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-fmi8apETnFd6KhLzcGFLUcPRTZO+6vHnFw0Yz08IT68aX2Vft91LnxlJuuaiKVfzE8w9fxQlU1lAZyTVWARGIg=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260329.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gQb6SjB5JlUKDaDuz6mv/m+/OBWVDlcjHINFOykBZZYZtgxBx6nEDjLrT8TiJRjmHEG6hSbv+yisUL9IThWycA=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260320.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouNWS+eUHna/mxP0tLQRzBfR5CwldCrG9mi+pTZY1pKnjvvu7b0i6OOFh9em36wIsDCjPGu6dQiv2sfBCCyRwA=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260329.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kg4r+ssxoEWruBynUg9bFMdcMpo5NupzAPqNBlV8uWbmYGZjaPLonFWAi9ZZMiVJY/x5ZQ9GBl6xskwLdd3PJQ=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260320.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-fG32mrKJOW8U9pq3VdHNXCfxCN3T7Uh5zkf3beH3mFIRtIv65BUONwW6rh63DCtyGZTeBkF6GTJO2nIIfx3dbA=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260329.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Qi4lddVxl5MG7Tk67gYhCFnoqqLGd4TvaI8RN4qHFjt3GV8s6c+0cQGsJXJnVgMx27qbyDTdsyAa2pvb42rYcQ=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260320.1", "", { "os": "win32", "cpu": "x64" }, "sha512-iklQlFdDWkV62GbxqVltm7y148vL6TC18BYZRzRaJjf2r+Gd2UGW3FPydNwKViTRfvWWwNx0aU8fnqTaoMBGqw=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260329.1", "", { "os": "win32", "cpu": "x64" }, "sha512-+k5+usuB8HZ6Xc+enLdb95ZJd25bQqsnI1zXxfRCHP+RS9mxs70Mi9ezQz3lKOLZFFXShSH7iW9iulm8KwVzCQ=="], "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], @@ -260,7 +260,7 @@ "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], - "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="], + "oxlint": ["oxlint@1.57.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.57.0", "@oxlint/binding-android-arm64": "1.57.0", "@oxlint/binding-darwin-arm64": "1.57.0", "@oxlint/binding-darwin-x64": "1.57.0", "@oxlint/binding-freebsd-x64": "1.57.0", "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", "@oxlint/binding-linux-arm-musleabihf": "1.57.0", "@oxlint/binding-linux-arm64-gnu": "1.57.0", "@oxlint/binding-linux-arm64-musl": "1.57.0", "@oxlint/binding-linux-ppc64-gnu": "1.57.0", "@oxlint/binding-linux-riscv64-gnu": "1.57.0", "@oxlint/binding-linux-riscv64-musl": "1.57.0", "@oxlint/binding-linux-s390x-gnu": "1.57.0", "@oxlint/binding-linux-x64-gnu": "1.57.0", "@oxlint/binding-linux-x64-musl": "1.57.0", "@oxlint/binding-openharmony-arm64": "1.57.0", "@oxlint/binding-win32-arm64-msvc": "1.57.0", "@oxlint/binding-win32-ia32-msvc": "1.57.0", "@oxlint/binding-win32-x64-msvc": "1.57.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5bd7ad5..04f5b2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,33 +4,78 @@ The application follows a layered architecture with clear separation of concerns: -```bash +``` main.ts (Entry Point) ├── AgentConfig (Configuration) ├── AgentTheme (Theme Management) ├── Renderer (Lifecycle/Singleton Manager) │ └── createCliRenderer (@opentui/core) +├── LlmClient (LLM API Client) +│ ├── chat() - Non-streaming requests +│ └── chatStream() - Streaming responses +├── ConnectionManager (Connection Management) └── Container (UI Composition) -├── BoxRenderable (@opentui/core) -├── InputRenderable (@opentui/core) -└── TextRenderable (@opentui/core) + ├── MessageList (Chat history) + ├── TypingIndicator (Thinking display) + ├── CommandPalette (Command menu) + └── StatusBar (Bottom status) ``` ## Component Breakdown -| Component | File | Responsibility | Pattern | -| ----------- | ------------ | --------------------------------- | ----------------- | -| main.ts | Entry point | Orchestrates initialization | Procedural | -| AgentConfig | config.ts | Banner/configuration management | Simple Config | -| AgentTheme | theme.ts | Theme loading & variant selection | Strategy | -| Renderer | renderer.ts | CLI renderer lifecycle management | Singleton Manager | -| Container | container.ts | UI component composition | Composite | -| figures.ts | figures.ts | Unicode figure symbols | Utility | -| utils.ts | utils.ts | Terminal Unicode detection | Utility | +| Component | File | Responsibility | Pattern | +| --------- | ---- | -------------- | ------- | +| main.ts | Entry point | Orchestrates initialization | Procedural | +| AgentConfig | config.ts | Banner/configuration management | Simple Config | +| AgentTheme | theme.ts | Theme loading & variant selection | Strategy | +| Renderer | renderer.ts | CLI renderer lifecycle management | Singleton Manager | +| Container | container.ts | UI component composition | Composite | +| LlmClient | llm-client.ts | LLM API communication | Adapter | +| ConnectionManager | connection-manager.ts | Connection health & retry | State Machine | +| StateStore | store.ts | Application state management | Observer | +| MessageList | message-list.ts | Chat message display | Composite | +| CommandPalette | command-palette.ts | Command selection UI | Composite | +| StatusBar | status-bar.ts | Status display | Composite | +| figures.ts | figures.ts | Unicode figure symbols | Utility | +| utils.ts | utils.ts | Terminal Unicode detection | Utility | +| errors.ts | errors.ts | Error type definitions | Error Handling | + +## State Management + +The application uses a simple pub/sub state store: + +```typescript +interface AppState { + messages: Message[]; + isTyping: boolean; + connectionStatus: ConnectionStatus; + themeMode: "light" | "dark"; + appMode: "plan" | "build"; + currentModel: string; + currentThemeName: string; +} +``` + +## LLM Client + +Supports streaming and non-streaming chat completions: + +```typescript +// Non-streaming +const response = await client.chat(request); + +// Streaming +const cleanup = client.chatStream( + request, + (chunk) => { /* handle delta */ }, + (final) => { /* handle done */ }, + (error) => { /* handle error */ } +); +``` ## Dependency Flow -```bash +``` @opentui/core ├── createCliRenderer() → CliRenderer ├── BoxRenderable (layout container) diff --git a/main.ts b/main.ts index 9e41900..2d63737 100644 --- a/main.ts +++ b/main.ts @@ -2,22 +2,37 @@ import { AgentConfig } from "./src/config"; import { Container } from "./src/container"; import { Renderer } from "./src/renderer"; import { AgentTheme } from "./src/theme"; +import { getCliArgs, showHelp } from "./src/cli"; +import { loadConfig, saveConfig } from "./src/persistent-config"; +import { version } from "./package.json" with { type: "json" }; -/** - * The main entry point for the TUI application. - * - * This function orchestrates the setup of the application's core components: - * 1. It initializes the `AgentConfig` to load configuration. - * 2. It sets up the `Renderer` which manages the lifecycle of the terminal UI. - * 3. It creates the main `Container` component, injecting the renderer and config. - * 4. It calls the `render` method to display the UI. - */ async function main() { - const config = new AgentConfig(); - const theme = new AgentTheme({ - themeName: "default", - mode: "light", - }); + const cliArgs = getCliArgs(); + + if (cliArgs.help) { + showHelp(); + process.exit(0); + } + + if (cliArgs.version) { + console.log(`agent-tui v${version}`); + process.exit(0); + } + + const savedConfig = loadConfig(); + + const mergedTheme = { + ...savedConfig.theme, + ...cliArgs.theme, + }; + + const mergedConfig = { + ...savedConfig.config, + ...cliArgs.config, + }; + + const config = new AgentConfig(mergedConfig); + const theme = new AgentTheme(mergedTheme); const rendererManager = new Renderer(); const renderer = await rendererManager.get(); @@ -28,6 +43,13 @@ async function main() { }); container.render(); + + process.on("exit", () => { + saveConfig({ + config: mergedConfig, + theme: mergedTheme, + }); + }); } main().catch(console.error); diff --git a/package.json b/package.json index d3a8d04..6193548 100644 --- a/package.json +++ b/package.json @@ -12,22 +12,22 @@ "prepare": "simple-git-hooks", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "format": "oxfmt", + "format": "oxfmt .", "actions:up": "actions-up", "typecheck": "tsgo --noEmit", "dev": "bun --watch main.ts", "start": "bun main.ts" }, "dependencies": { - "@opentui/core": "^0.1.90" + "@opentui/core": "^0.1.92" }, "devDependencies": { "@types/bun": "latest", "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260320.1", + "@typescript/native-preview": "^7.0.0-dev.20260329.1", "actions-up": "^1.12.0", "oxfmt": "^0.35.0", - "oxlint": "^1.56.0", + "oxlint": "^1.57.0", "simple-git-hooks": "^2.13.1" }, "peerDependencies": { diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..9e4c6b6 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,116 @@ +import type { ConfigOpts } from "./config"; +import type { AgentThemeOpts } from "./theme"; + +export interface CliArgs { + config: ConfigOpts; + theme: AgentThemeOpts; + help: boolean; + version: boolean; +} + +const DEFAULT_CLI_ARGS: CliArgs = { + config: {}, + theme: {}, + help: false, + version: false, +}; + +const ALLOWED_THEMES = ["default", "nord", "dracula", "nightowl"] as const; +const THEMES_HELP = ALLOWED_THEMES.join(", "); + +const HELP_TEXT = ` +Usage: agent-tui [options] + +Options: + --theme Theme to use (${THEMES_HELP}) [default: default] + --mode Color mode: light or dark [default: light] + --banner Custom banner text + --no-banner Disable banner display + --help Show this help message + --version Show version number + +Examples: + agent-tui --theme nord --mode dark + agent-tui --banner "Hello World" + agent-tui --no-banner +`; + +function parseArgs(args: string[]): CliArgs { + const result: CliArgs = { ...DEFAULT_CLI_ARGS }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case "--help": + case "-h": + result.help = true; + break; + + case "--version": + case "-v": + result.version = true; + break; + + case "--theme": { + const themeName = args[++i]; + if (!themeName || themeName?.startsWith("-")) { + console.error("--theme requires a value"); + process.exit(1); + } + if (!ALLOWED_THEMES.includes(themeName as (typeof ALLOWED_THEMES)[number])) { + console.error(`Invalid theme '${themeName}'. Allowed: ${THEMES_HELP}`); + process.exit(1); + } + result.theme.themeName = themeName; + break; + } + + case "--mode": { + const mode = args[++i]; + if (!mode || mode.startsWith("-")) { + console.error("--mode requires a value (light or dark)"); + process.exit(1); + } + if (mode !== "light" && mode !== "dark") { + console.error("--mode must be 'light' or 'dark'"); + process.exit(1); + } + result.theme.mode = mode; + break; + } + + case "--banner": { + const banner = args[++i]; + if (!banner || banner.startsWith("-")) { + console.error("--banner requires a value"); + process.exit(1); + } + result.config.banner = banner; + break; + } + + case "--no-banner": + result.config.banner = ""; + break; + + default: + if (arg?.startsWith("-")) { + console.error(`Unknown option: ${arg}`); + console.error("Use --help for usage information"); + process.exit(1); + } + break; + } + } + + return result; +} + +export function getCliArgs(args: string[] = process.argv.slice(2)): CliArgs { + return parseArgs(args); +} + +export function showHelp(): void { + console.log(HELP_TEXT); +} diff --git a/src/command-palette.ts b/src/command-palette.ts new file mode 100644 index 0000000..cd22e9f --- /dev/null +++ b/src/command-palette.ts @@ -0,0 +1,202 @@ +import { BoxRenderable, InputRenderable, TextRenderable } from "@opentui/core"; +import type { CliRenderer } from "./renderer"; +import type { IAgentTheme, ThemeVariant } from "./theme"; +import figures from "./figures"; + +export interface Command { + id: string; + label: string; + description: string; + action: () => void | Promise; +} + +export interface CommandPaletteOpts { + renderer: CliRenderer; + theme: IAgentTheme; + commands: Command[]; +} + +export class CommandPalette { + private readonly renderer: CliRenderer; + private readonly theme: IAgentTheme; + private readonly commands: Command[]; + private overlay: BoxRenderable | null = null; + private input: InputRenderable | null = null; + private resultsBox: BoxRenderable | null = null; + private selectedIndex = 0; + private filteredCommands: Command[] = []; + private isOpen = false; + + constructor(options: CommandPaletteOpts) { + this.renderer = options.renderer; + this.theme = options.theme; + this.commands = options.commands; + this.filteredCommands = [...this.commands]; + } + + open(): void { + if (this.isOpen) return; + this.isOpen = true; + this.selectedIndex = 0; + this.filteredCommands = [...this.commands]; + this.render(); + } + + close(): void { + if (!this.isOpen) return; + this.isOpen = false; + + if (this.overlay) { + this.overlay.remove("command-palette"); + this.overlay = null; + this.input = null; + this.resultsBox = null; + } + } + + toggle(): void { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + isVisible(): boolean { + return this.isOpen; + } + + private render(): void { + const _theme: ThemeVariant = this.theme.getTheme(); + + this.overlay = new BoxRenderable(this.renderer, { + id: "command-palette", + flexDirection: "column", + width: "80%", + maxWidth: 60, + backgroundColor: _theme.seeds.neutral, + }); + + const inputContainer = new BoxRenderable(this.renderer, { + id: "command-palette-input", + flexDirection: "row", + height: 3, + backgroundColor: _theme.seeds.primary, + }); + + this.input = new InputRenderable(this.renderer, { + id: "command-input", + placeholder: "Type a command...", + textColor: _theme.seeds.neutral, + marginLeft: 1, + onKeyDown: (key) => { + if (key.name === "escape") { + this.close(); + } else if (key.name === "arrowup") { + this.selectPrevious(); + } else if (key.name === "arrowdown") { + this.selectNext(); + } else if (key.name === "enter") { + this.executeSelected(); + } + }, + }); + + this.input.focus(); + + inputContainer.add(this.input); + + this.resultsBox = new BoxRenderable(this.renderer, { + id: "command-results", + flexDirection: "column", + gap: 0, + height: 10, + }); + + this.renderResults(_theme); + + this.overlay.add(inputContainer); + this.overlay.add(this.resultsBox); + + this.renderer.root.add(this.overlay); + } + + private renderResults(_theme: ThemeVariant): void { + if (!this.resultsBox) return; + + this.resultsBox.remove("command-results"); + + this.resultsBox = new BoxRenderable(this.renderer, { + id: "command-results", + flexDirection: "column", + gap: 0, + height: 10, + }); + + for (let i = 0; i < this.filteredCommands.length; i++) { + const cmd = this.filteredCommands[i]; + if (!cmd) continue; + + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? figures.pointer : " "; + const fg = isSelected ? _theme.seeds.primary : _theme.seeds.neutral; + + const resultItem = new TextRenderable(this.renderer, { + id: `command-result-${cmd.id}`, + content: `${prefix} ${cmd.label}`, + fg, + }); + + const description = new TextRenderable(this.renderer, { + id: `command-desc-${cmd.id}`, + content: ` ${cmd.description}`, + fg: _theme.seeds.info, + }); + + this.resultsBox.add(resultItem); + this.resultsBox.add(description); + } + + this.overlay?.add(this.resultsBox); + } + + private filterCommands(query: string): void { + if (!query.trim()) { + this.filteredCommands = [...this.commands]; + } else { + const lowerQuery = query.toLowerCase(); + this.filteredCommands = this.commands.filter( + (cmd) => + cmd.label.toLowerCase().includes(lowerQuery) || + cmd.description.toLowerCase().includes(lowerQuery), + ); + } + + this.selectedIndex = 0; + const _theme: ThemeVariant = this.theme.getTheme(); + this.renderResults(_theme); + } + + private selectPrevious(): void { + if (this.filteredCommands.length === 0) return; + this.selectedIndex = + (this.selectedIndex - 1 + this.filteredCommands.length) % this.filteredCommands.length; + const _theme: ThemeVariant = this.theme.getTheme(); + this.renderResults(_theme); + } + + private selectNext(): void { + if (this.filteredCommands.length === 0) return; + this.selectedIndex = (this.selectedIndex + 1) % this.filteredCommands.length; + const _theme: ThemeVariant = this.theme.getTheme(); + this.renderResults(_theme); + } + + private executeSelected(): void { + const cmd = this.filteredCommands[this.selectedIndex]; + if (cmd) { + this.close(); + cmd.action(); + } + } +} diff --git a/src/connection-manager.ts b/src/connection-manager.ts new file mode 100644 index 0000000..8beb6f0 --- /dev/null +++ b/src/connection-manager.ts @@ -0,0 +1,148 @@ +import type { ConnectionStatus } from "./types"; +import { store } from "./store"; + +type StatusListener = (status: ConnectionStatus) => void; + +export interface ConnectionManagerOptions { + maxRetries?: number; + retryDelayMs?: number; + healthCheckUrl?: string; +} + +export class ConnectionManager { + private status: ConnectionStatus = "disconnected"; + private listeners: Set = new Set(); + private retryCount = 0; + private maxRetries: number; + private retryDelayMs: number; + private healthCheckUrl?: string; + private healthCheckInterval: ReturnType | null = null; + private isChecking = false; + + constructor(options: ConnectionManagerOptions = {}) { + this.maxRetries = options.maxRetries ?? 5; + this.retryDelayMs = options.retryDelayMs ?? 2000; + this.healthCheckUrl = options.healthCheckUrl; + } + + getStatus(): ConnectionStatus { + return this.status; + } + + subscribe(listener: StatusListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private setStatus(status: ConnectionStatus): void { + this.status = status; + store.setConnectionStatus(status); + + for (const listener of this.listeners) { + listener(status); + } + } + + async connect(): Promise { + if (this.status === "connected" || this.status === "connecting") { + return; + } + + this.setStatus("connecting"); + + try { + if (this.healthCheckUrl) { + await this.performHealthCheck(); + } + + this.retryCount = 0; + this.setStatus("connected"); + this.startHealthCheck(); + } catch (error) { + console.error("Connection failed:", error); + this.setStatus("error"); + await this.scheduleRetry(); + } + } + + async disconnect(): Promise { + this.stopHealthCheck(); + this.setStatus("disconnected"); + } + + private async performHealthCheck(): Promise { + if (!this.healthCheckUrl) return; + + const response = await fetch(this.healthCheckUrl, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`Health check failed: ${response.status}`); + } + } + + private startHealthCheck(): void { + if (!this.healthCheckUrl || this.healthCheckInterval) return; + + this.healthCheckInterval = setInterval(async () => { + if (this.isChecking) return; + + this.isChecking = true; + try { + await this.performHealthCheck(); + if (this.status !== "connected") { + this.setStatus("connected"); + } + } catch { + this.setStatus("error"); + await this.scheduleRetry(); + } finally { + this.isChecking = false; + } + }, 30000); + } + + private stopHealthCheck(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + } + + private async scheduleRetry(): Promise { + if (this.retryCount >= this.maxRetries) { + console.error(`Max retries (${this.maxRetries}) reached`); + this.setStatus("error"); + return; + } + + this.retryCount++; + const delay = this.retryDelayMs * this.retryCount; + + console.log( + `Retrying connection in ${delay}ms (attempt ${this.retryCount}/${this.maxRetries})`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + try { + if (this.healthCheckUrl) { + await this.performHealthCheck(); + } + this.retryCount = 0; + this.setStatus("connected"); + } catch { + await this.scheduleRetry(); + } + } + + reset(): void { + this.retryCount = 0; + this.stopHealthCheck(); + } +} + +export const connectionManager = new ConnectionManager(); diff --git a/src/container.ts b/src/container.ts index 8ca6639..2f8d7f6 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,7 +1,15 @@ -import { BoxRenderable, InputRenderable, TextRenderable } from "@opentui/core"; +import { BoxRenderable, InputRenderable } from "@opentui/core"; import type { IAgentConfig } from "./config"; import type { CliRenderer } from "./renderer"; import type { IAgentTheme, ThemeVariant } from "./theme"; +import { MessageList } from "./message-list"; +import { TypingIndicator } from "./typing-indicator"; +import { CommandPalette, type Command } from "./command-palette"; +import { StatusBar } from "./status-bar"; +import { store } from "./store"; +import { createMessage } from "./types"; +import { LlmClient } from "./llm-client"; +import type { ChatMessage } from "./llm-types"; export interface ContainerOpts { renderer: CliRenderer; @@ -19,12 +27,24 @@ export class Container implements IContainer { private readonly config: IAgentConfig; private readonly theme: IAgentTheme; private readonly container: BoxRenderable; + private messageList: MessageList; + private typingIndicator: TypingIndicator; + private commandPalette: CommandPalette; + private statusBar: StatusBar; + private queryInput: InputRenderable; + private llmClient: LlmClient; constructor(options: ContainerOpts) { this.renderer = options.renderer; this.config = options.config; this.theme = options.theme; + this.llmClient = new LlmClient({ + baseUrl: process.env.LLM_BASE_URL ?? "http://localhost:11434", + apiKey: process.env.LLM_API_KEY ?? "", + model: process.env.LLM_MODEL ?? "llama3", + }); + this.container = new BoxRenderable(this.renderer, { id: "main-container", flexDirection: "column", @@ -32,70 +52,225 @@ export class Container implements IContainer { width: "100%", height: "100%", }); + + this.messageList = new MessageList({ + renderer: this.renderer, + theme: this.theme, + }); + + this.typingIndicator = new TypingIndicator({ + renderer: this.renderer, + theme: this.theme, + }); + + const commands: Command[] = [ + { + id: "clear", + label: "Clear Chat", + description: "Clear all messages", + action: () => { + store.clearMessages(); + this.messageList.clear(); + }, + }, + { + id: "toggle-theme", + label: "Toggle Theme", + description: "Switch between light and dark mode", + action: () => { + store.toggleThemeMode(); + const state = store.getState(); + this.statusBar.updateThemeMode(state.currentThemeName, state.themeMode); + }, + }, + { + id: "quit", + label: "Quit", + description: "Exit the application", + action: () => { + process.exit(0); + }, + }, + ]; + + this.commandPalette = new CommandPalette({ + renderer: this.renderer, + theme: this.theme, + commands, + }); + + this.statusBar = new StatusBar({ + renderer: this.renderer, + theme: this.theme, + }); + + this.queryInput = null as unknown as InputRenderable; + } + + private async sendMessage(content: string): Promise { + if (content.startsWith("/")) { + this.handleCommand(content); + return; + } + + const userMessage = createMessage("user", content); + store.addMessage(userMessage); + this.messageList.addMessage(userMessage); + + this.typingIndicator.show(); + + const messages: ChatMessage[] = store.getState().messages.map((msg) => ({ + role: msg.role === "assistant" ? "assistant" : "user", + content: msg.content, + })); + + const assistantMessage = createMessage("assistant", ""); + store.addMessage(assistantMessage); + this.messageList.addMessage(assistantMessage); + + const currentModel = store.getState().currentModel; + + this.llmClient.chatStream( + { + model: currentModel, + messages, + }, + (chunk) => { + store.updateLastMessage(chunk.delta); + }, + () => { + this.typingIndicator.hide(); + this.statusBar.updateConnectionStatus("connected"); + }, + (error) => { + console.error("LLM Error:", error); + this.typingIndicator.hide(); + this.statusBar.updateConnectionStatus("error"); + }, + ); + + store.subscribe((state) => { + if (state.messages.length > 0) { + const lastMsg = state.messages[state.messages.length - 1]; + if (lastMsg && lastMsg.role === "assistant") { + this.messageList.addMessage(lastMsg); + } + } + }); + } + + private handleCommand(content: string): void { + const parts = content.split(" "); + const command = parts[0]?.toLowerCase() ?? ""; + const args = parts.slice(1).join(" "); + + const state = store.getState(); + + switch (command) { + case "/exit": + case "/quit": + process.exit(0); + break; + + case "/theme": { + const themeName = args.trim() || "default"; + const mode = state.themeMode; + store.setCurrentThemeName(themeName); + this.statusBar.updateThemeMode(themeName, mode); + const response = createMessage("assistant", `Theme set to: ${themeName} (${mode} mode)`); + store.addMessage(response); + this.messageList.addMessage(response); + break; + } + + case "/models": { + const model = args.trim() || "llama3"; + store.setCurrentModel(model); + this.statusBar.updateModel(model); + const response = createMessage("assistant", `Model switched to: ${model}`); + store.addMessage(response); + this.messageList.addMessage(response); + break; + } + + case "/help": { + const helpText = `Available commands: +/theme - Switch theme (default, nord, dracula, nightowl) +/models - Switch model (default: llama3) +/exit - Quit application +Tab - Toggle Plan/Build mode`; + const response = createMessage("assistant", helpText); + store.addMessage(response); + this.messageList.addMessage(response); + break; + } + + default: { + const response = createMessage( + "assistant", + `Unknown command: ${command}. Type /help for available commands.`, + ); + store.addMessage(response); + this.messageList.addMessage(response); + } + } } render() { const _theme: ThemeVariant = this.theme.getTheme(); - const queryContainer = new BoxRenderable(this.renderer, { - id: "query-container", + this.messageList.render(); + + const inputContainer = new BoxRenderable(this.renderer, { + id: "input-container", height: 3, flexDirection: "row", backgroundColor: _theme.overrides?.["text-weak"], }); - const queryPrefix = new BoxRenderable(this.renderer, { - id: "query-prefix", + const inputPrefix = new BoxRenderable(this.renderer, { + id: "input-prefix", backgroundColor: _theme.seeds.primary, }); - const queryInput = new InputRenderable(this.renderer, { + this.queryInput = new InputRenderable(this.renderer, { id: "query-input", - placeholder: "Ask anything...", + placeholder: "Ask anything... (/help for commands)", textColor: _theme.seeds.neutral, marginTop: 1, marginBottom: 1, marginLeft: 2, onKeyDown: (key) => { if (key.name === "escape") { - queryInput.blur(); + this.queryInput.blur(); + } else if (key.ctrl && key.name === "k") { + this.commandPalette.toggle(); + } else if (key.ctrl && key.name === "p") { + process.exit(0); + } else if (key.name === "tab") { + store.toggleAppMode(); + this.statusBar.updateAppMode(store.getState().appMode); + } else if (key.name === "enter") { + const value = (this.queryInput as unknown as { _value: string })._value; + if (value?.trim()) { + this.sendMessage(value.trim()); + } } }, }); - queryInput.focus(); - queryContainer.add(queryPrefix); - queryContainer.add(queryInput); - - const title = new TextRenderable(this.renderer, { - id: "title", - content: this.config.getBanner(), - fg: _theme.seeds.primary, - alignItems: "center", - alignSelf: "center", - }); - - const footerContainer = new BoxRenderable(this.renderer, { - id: "footer-container", - width: "100%", - }); - - const footer = new TextRenderable(this.renderer, { - id: "footer", - content: "Press Ctrl+C to exit", - fg: _theme.seeds.warning, - }); - - footerContainer.add(footer); + this.statusBar.render(); - this.container.add(title); - this.container.add(queryContainer); - this.container.add(footerContainer); + this.queryInput.focus(); + inputContainer.add(inputPrefix); + inputContainer.add(this.queryInput); + this.container.add(this.messageList); + this.container.add(inputContainer); this.renderer.root.add(this.container); } remove() { - this.container.remove("body"); + this.container.remove("main-container"); } } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..1fcbed1 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,55 @@ +export class AgentError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentError"; + } +} + +export class RendererError extends AgentError { + constructor( + message: string, + public override readonly cause?: Error, + ) { + super(message); + this.name = "RendererError"; + } +} + +export class ThemeError extends AgentError { + constructor( + message: string, + public override readonly cause?: Error, + ) { + super(message); + this.name = "ThemeError"; + } +} + +export class ConfigError extends AgentError { + constructor( + message: string, + public override readonly cause?: Error, + ) { + super(message); + this.name = "ConfigError"; + } +} + +export class ContainerError extends AgentError { + constructor( + message: string, + public override readonly cause?: Error, + ) { + super(message); + this.name = "ContainerError"; + } +} + +export function wrapError(error: unknown, fallback: T): T { + if (error instanceof Error) { + console.error(error.message); + return fallback; + } + console.error(error); + return fallback; +} diff --git a/src/llm-client.ts b/src/llm-client.ts new file mode 100644 index 0000000..54402da --- /dev/null +++ b/src/llm-client.ts @@ -0,0 +1,186 @@ +import type { + ILlmClient, + LLMClientOptions, + ChatCompletionRequest, + ChatCompletionResponse, + StreamChunk, + Tool, +} from "./llm-types"; + +export class LlmClient implements ILlmClient { + private baseUrl: string; + private apiKey: string; + private model: string; + private tools: Tool[]; + + constructor(options: LLMClientOptions) { + this.baseUrl = options.baseUrl; + this.apiKey = options.apiKey; + this.model = options.model; + this.tools = options.defaultTools ?? []; + } + + setApiKey(apiKey: string): void { + this.apiKey = apiKey; + } + + setBaseUrl(url: string): void { + this.baseUrl = url; + } + + async chat(request: ChatCompletionRequest): Promise { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + ...request, + model: request.model ?? this.model, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error: ${response.status} - ${error}`); + } + + return response.json() as Promise; + } + + chatStream( + request: ChatCompletionRequest, + onChunk: (chunk: StreamChunk) => void, + onDone: (finalContent: string) => void, + onError: (error: Error) => void, + ): () => void { + let content = ""; + let finished = false; + let controller: AbortController | null = null; + + const cleanup = () => { + finished = true; + if (controller) { + controller.abort(); + } + }; + + const run = async () => { + controller = new AbortController(); + + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + ...request, + model: request.model ?? this.model, + stream: true, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error: ${response.status} - ${error}`); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!finished) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data: ")) continue; + + const data = trimmed.slice(6); + if (data === "[DONE]") { + finished = true; + break; + } + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content ?? ""; + const finishReason = parsed.choices?.[0]?.finishReason ?? null; + + if (delta) { + content += delta; + onChunk({ + id: parsed.id ?? "", + delta, + finishReason, + }); + } + + if (finishReason) { + finished = true; + } + } catch { + // Skip malformed JSON + } + } + } + + onDone(content); + } catch (error) { + if ((error as Error).name !== "AbortError") { + onError(error instanceof Error ? error : new Error(String(error))); + } + } + }; + + run(); + + return cleanup; + } + + addTool(tool: Tool): void { + this.tools.push(tool); + } + + removeTool(name: string): void { + this.tools = this.tools.filter((t) => t.name !== name); + } + + getTools(): Tool[] { + return [...this.tools]; + } + + async executeTool(toolCall: { name: string; arguments: string }): Promise { + const tool = this.tools.find((t) => t.name === toolCall.name); + + if (!tool) { + return JSON.stringify({ error: `Tool '${toolCall.name}' not found` }); + } + + try { + const args = JSON.parse(toolCall.arguments); + const result = await tool.execute(args); + return result; + } catch (error) { + return JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/src/llm-types.ts b/src/llm-types.ts new file mode 100644 index 0000000..df38215 --- /dev/null +++ b/src/llm-types.ts @@ -0,0 +1,84 @@ +export interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + toolCalls?: ChatToolCall[]; + toolCallId?: string; +} + +export interface ChatToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + stream?: boolean; + tools?: ToolDefinition[]; + temperature?: number; + maxTokens?: number; +} + +export interface ChatCompletionResponse { + id: string; + model: string; + choices: { + index: number; + message: ChatMessage; + finishReason: string | null; + }[]; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +export interface ToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface Tool { + name: string; + description: string; + parameters: Record; + execute: (args: Record) => Promise; +} + +export interface StreamChunk { + id: string; + delta: string; + finishReason: string | null; +} + +export type StreamHandler = (chunk: StreamChunk) => void; +export type DoneHandler = (finalContent: string) => void; +export type ErrorHandler = (error: Error) => void; + +export interface LLMClientOptions { + baseUrl: string; + apiKey: string; + model: string; + defaultTools?: Tool[]; +} + +export interface ILlmClient { + chat(request: ChatCompletionRequest): Promise; + chatStream( + request: ChatCompletionRequest, + onChunk: StreamHandler, + onDone: DoneHandler, + onError: ErrorHandler, + ): () => void; + setApiKey(apiKey: string): void; + setBaseUrl(url: string): void; +} diff --git a/src/message-list.ts b/src/message-list.ts new file mode 100644 index 0000000..2f35fda --- /dev/null +++ b/src/message-list.ts @@ -0,0 +1,109 @@ +import { BoxRenderable, TextRenderable } from "@opentui/core"; +import type { CliRenderer } from "./renderer"; +import type { IAgentTheme, ThemeVariant } from "./theme"; +import type { Message } from "./types"; +import figures from "./figures"; + +export interface MessageListOpts { + renderer: CliRenderer; + theme: IAgentTheme; +} + +export interface IMessageList { + render(): void; + update(): void; + addMessage(message: Message): void; +} + +export class MessageList implements IMessageList { + private readonly renderer: CliRenderer; + private readonly theme: IAgentTheme; + private container: BoxRenderable; + private messages: Message[] = []; + + constructor(options: MessageListOpts) { + this.renderer = options.renderer; + this.theme = options.theme; + + this.container = new BoxRenderable(this.renderer, { + id: "message-list", + flexDirection: "column", + gap: 1, + width: "100%", + }); + } + + render(): void { + const _theme: ThemeVariant = this.theme.getTheme(); + + this.container = new BoxRenderable(this.renderer, { + id: "message-list", + flexDirection: "column", + gap: 1, + width: "100%", + backgroundColor: _theme.overrides?.["bg"], + }); + + this.renderer.root.add(this.container); + } + + update(): void { + this.container.remove("message-list"); + + const _theme: ThemeVariant = this.theme.getTheme(); + + this.container = new BoxRenderable(this.renderer, { + id: "message-list", + flexDirection: "column", + gap: 1, + width: "100%", + backgroundColor: _theme.overrides?.["bg"], + }); + + for (const message of this.messages) { + this.addMessageToContainer(message, _theme); + } + + this.renderer.root.add(this.container); + } + + addMessage(message: Message): void { + this.messages.push(message); + const _theme: ThemeVariant = this.theme.getTheme(); + this.addMessageToContainer(message, _theme); + } + + private addMessageToContainer(message: Message, _theme: ThemeVariant): void { + const prefix = message.role === "user" ? figures.pointer : figures.triangleRight; + const prefixColor = message.role === "user" ? _theme.seeds.primary : _theme.seeds.success; + const roleLabel = message.role === "user" ? "You" : "Assistant"; + + const messageBox = new BoxRenderable(this.renderer, { + id: `message-${message.id}`, + flexDirection: "column", + width: "100%", + }); + + const header = new TextRenderable(this.renderer, { + id: `message-header-${message.id}`, + content: `${prefix} ${roleLabel}`, + fg: prefixColor, + }); + + const content = new TextRenderable(this.renderer, { + id: `message-content-${message.id}`, + content: message.content, + fg: _theme.seeds.neutral, + marginLeft: 2, + }); + + messageBox.add(header); + messageBox.add(content); + this.container.add(messageBox); + } + + clear(): void { + this.messages = []; + this.update(); + } +} diff --git a/src/persistent-config.ts b/src/persistent-config.ts new file mode 100644 index 0000000..d2cc5f1 --- /dev/null +++ b/src/persistent-config.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { ConfigOpts } from "./config"; +import type { AgentThemeOpts } from "./theme"; + +export interface PersistentConfig { + config: ConfigOpts; + theme: AgentThemeOpts; +} + +const DEFAULT_CONFIG: PersistentConfig = { + config: {}, + theme: {}, +}; + +const CONFIG_DIR = join(homedir(), ".agent-tui"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +function ensureConfigDir(): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +export function loadConfig(): PersistentConfig { + try { + if (!existsSync(CONFIG_FILE)) { + return DEFAULT_CONFIG; + } + + const data = readFileSync(CONFIG_FILE, "utf-8"); + const parsed = JSON.parse(data); + + if (typeof parsed !== "object" || parsed === null) { + console.error("Invalid config file, using defaults"); + return DEFAULT_CONFIG; + } + + return { + config: parsed.config ?? DEFAULT_CONFIG.config, + theme: parsed.theme ?? DEFAULT_CONFIG.theme, + }; + } catch (error) { + console.error("Failed to load config:", error); + return DEFAULT_CONFIG; + } +} + +export function saveConfig(config: PersistentConfig): void { + try { + ensureConfigDir(); + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } catch (error) { + console.error("Failed to save config:", error); + } +} + +export function resetConfig(): void { + try { + saveConfig(DEFAULT_CONFIG); + } catch (error) { + console.error("Failed to reset config:", error); + } +} + +export function getConfigPath(): string { + return CONFIG_FILE; +} diff --git a/src/renderer.ts b/src/renderer.ts index e265229..f72595d 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,4 +1,8 @@ import { createCliRenderer } from "@opentui/core"; +import { RendererError } from "./errors"; + +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 500; export type CliRenderer = Awaited>; export type CliRendererOptions = Parameters[0]; @@ -43,13 +47,37 @@ export class Renderer implements IRenderer { /** * Lazily creates and returns a single instance of the CLI renderer. + * Includes retry logic for failed initializations. * @returns A promise that resolves to the CliRenderer instance. */ async get(): Promise { - if (!this.instance) { - this.instance = await createCliRenderer(this.options); + if (this.instance) { + return this.instance; } - return this.instance; + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + this.instance = await createCliRenderer(this.options); + return this.instance; + } catch (error) { + lastError = error instanceof Error ? error : new RendererError(String(error)); + console.error( + `Renderer initialization attempt ${attempt}/${MAX_RETRIES} failed:`, + lastError.message, + ); + + if (attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * attempt)); + } + } + } + + throw new RendererError( + `Failed to initialize renderer after ${MAX_RETRIES} attempts`, + lastError, + ); } /** diff --git a/src/status-bar.ts b/src/status-bar.ts new file mode 100644 index 0000000..b44fc0a --- /dev/null +++ b/src/status-bar.ts @@ -0,0 +1,196 @@ +import { BoxRenderable, TextRenderable } from "@opentui/core"; +import { cwd } from "node:process"; +import type { CliRenderer } from "./renderer"; +import type { IAgentTheme, ThemeVariant } from "./theme"; +import type { ConnectionStatus, AppMode } from "./types"; +import figures from "./figures"; +import { version } from "../package.json" with { type: "json" }; + +export interface StatusBarOpts { + renderer: CliRenderer; + theme: IAgentTheme; +} + +export class StatusBar { + private readonly renderer: CliRenderer; + private readonly theme: IAgentTheme; + private container: BoxRenderable | null = null; + private leftBox: BoxRenderable | null = null; + private centerBox: BoxRenderable | null = null; + private rightBox: BoxRenderable | null = null; + + constructor(options: StatusBarOpts) { + this.renderer = options.renderer; + this.theme = options.theme; + } + + render(): void { + const _theme: ThemeVariant = this.theme.getTheme(); + const currentDir = cwd().split(/[/\\]/).pop() ?? "agent-tui"; + + this.container = new BoxRenderable(this.renderer, { + id: "status-bar", + flexDirection: "row", + width: "100%", + height: 2, + }); + + this.leftBox = new BoxRenderable(this.renderer, { + id: "status-left", + flexDirection: "row", + gap: 2, + }); + + this.centerBox = new BoxRenderable(this.renderer, { + id: "status-center", + flexDirection: "row", + gap: 2, + }); + + this.rightBox = new BoxRenderable(this.renderer, { + id: "status-right", + flexDirection: "row", + gap: 2, + }); + + const versionText = new TextRenderable(this.renderer, { + id: "status-version", + content: `v${version}`, + fg: _theme.seeds.info, + }); + + const dirText = new TextRenderable(this.renderer, { + id: "status-dir", + content: currentDir, + fg: _theme.seeds.neutral, + }); + + const connectionText = new TextRenderable(this.renderer, { + id: "status-connection", + content: `${figures.circle} Offline`, + fg: _theme.seeds.warning, + }); + + const modeText = new TextRenderable(this.renderer, { + id: "status-mode", + content: "[Plan]", + fg: _theme.seeds.primary, + }); + + const themeText = new TextRenderable(this.renderer, { + id: "status-theme", + content: "theme:default", + fg: _theme.seeds.info, + }); + + const modelText = new TextRenderable(this.renderer, { + id: "status-model", + content: "model:llama3", + fg: _theme.seeds.success, + }); + + const helpText = new TextRenderable(this.renderer, { + id: "status-help", + content: "ctrl+p commands", + fg: _theme.seeds.neutral, + }); + + this.leftBox.add(versionText); + this.leftBox.add(dirText); + this.leftBox.add(connectionText); + + this.centerBox.add(modeText); + + this.rightBox.add(themeText); + this.rightBox.add(modelText); + this.rightBox.add(helpText); + + this.container.add(this.leftBox); + this.container.add(this.centerBox); + this.container.add(this.rightBox); + this.renderer.root.add(this.container); + } + + updateConnectionStatus(status: ConnectionStatus): void { + if (!this.leftBox) return; + + const _theme: ThemeVariant = this.theme.getTheme(); + let icon: string; + let text: string; + let fg: string; + + switch (status) { + case "connected": + icon = figures.tick; + text = "Online"; + fg = _theme.seeds.success; + break; + case "connecting": + icon = figures.ellipsis; + text = "Connecting"; + fg = _theme.seeds.warning; + break; + case "error": + icon = figures.cross; + text = "Error"; + fg = _theme.seeds.error; + break; + case "disconnected": + default: + icon = figures.circle; + text = "Offline"; + fg = _theme.seeds.warning; + break; + } + + this.leftBox.remove("status-connection"); + const connectionText = new TextRenderable(this.renderer, { + id: "status-connection", + content: `${icon}${text}`, + fg, + }); + this.leftBox.add(connectionText); + } + + updateThemeMode(themeName: string, _mode: "light" | "dark"): void { + if (!this.rightBox) return; + + const _theme: ThemeVariant = this.theme.getTheme(); + + this.rightBox.remove("status-theme"); + const themeText = new TextRenderable(this.renderer, { + id: "status-theme", + content: `theme:${themeName}`, + fg: _theme.seeds.info, + }); + this.rightBox.add(themeText); + } + + updateModel(model: string): void { + if (!this.rightBox) return; + + const _theme: ThemeVariant = this.theme.getTheme(); + + this.rightBox.remove("status-model"); + const modelText = new TextRenderable(this.renderer, { + id: "status-model", + content: `model:${model}`, + fg: _theme.seeds.success, + }); + this.rightBox.add(modelText); + } + + updateAppMode(mode: AppMode): void { + if (!this.rightBox) return; + + const _theme: ThemeVariant = this.theme.getTheme(); + + this.rightBox.remove("status-mode"); + const modeText = new TextRenderable(this.renderer, { + id: "status-mode", + content: `[${mode.toUpperCase()}]`, + fg: _theme.seeds.primary, + }); + this.rightBox.add(modeText); + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..11e4ecd --- /dev/null +++ b/src/store.ts @@ -0,0 +1,101 @@ +import type { AppState, Message, ConnectionStatus, AppMode } from "./types"; + +type Listener = (state: AppState) => void; + +class StateStore { + private state: AppState = { + messages: [], + isTyping: false, + connectionStatus: "disconnected", + themeMode: "light", + appMode: "plan", + currentModel: "llama3", + currentThemeName: "default", + }; + + private listeners: Set = new Set(); + + getState(): AppState { + return this.state; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify(): void { + for (const listener of this.listeners) { + listener(this.state); + } + } + + addMessage(message: Message): void { + this.state = { + ...this.state, + messages: [...this.state.messages, message], + }; + this.notify(); + } + + updateLastMessage(content: string): void { + if (this.state.messages.length === 0) return; + + const messages = [...this.state.messages]; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage) return; + + messages[messages.length - 1] = { + ...lastMessage, + content: lastMessage.content + content, + }; + + this.state = { ...this.state, messages }; + this.notify(); + } + + clearMessages(): void { + this.state = { ...this.state, messages: [] }; + this.notify(); + } + + setTyping(isTyping: boolean): void { + this.state = { ...this.state, isTyping }; + this.notify(); + } + + setConnectionStatus(status: ConnectionStatus): void { + this.state = { ...this.state, connectionStatus: status }; + this.notify(); + } + + setThemeMode(mode: "light" | "dark"): void { + this.state = { ...this.state, themeMode: mode }; + this.notify(); + } + + toggleThemeMode(): void { + const newMode = this.state.themeMode === "light" ? "dark" : "light"; + this.setThemeMode(newMode); + } + + toggleAppMode(): void { + const newMode: AppMode = this.state.appMode === "plan" ? "build" : "plan"; + this.state = { ...this.state, appMode: newMode }; + this.notify(); + } + + setCurrentModel(model: string): void { + this.state = { ...this.state, currentModel: model }; + this.notify(); + } + + setCurrentThemeName(themeName: string): void { + this.state = { ...this.state, currentThemeName: themeName }; + this.notify(); + } +} + +export const store = new StateStore(); diff --git a/src/theme.ts b/src/theme.ts index e5e8139..7ce3ada 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { ThemeError } from "./errors"; const ALLOWED_THEMES = ["default", "nord", "dracula", "nightowl"] as const; @@ -111,7 +112,11 @@ export class AgentTheme implements IAgentTheme { const parsed = JSON.parse(themeFile); return this.validateTheme(parsed); } catch (error) { - console.error(`Failed to parse theme '${themeName}':`, error); + const themeError: ThemeError = new ThemeError( + `Failed to load theme '${themeName}'`, + error instanceof Error ? error : undefined, + ); + console.error(themeError.message); return DEFAULT_THEME; } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1d813f4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,54 @@ +export type MessageRole = "user" | "assistant" | "system"; + +export interface Message { + id: string; + role: MessageRole; + content: string; + timestamp: number; +} + +export interface ToolCall { + id: string; + name: string; + arguments: string; + result?: string; +} + +export interface ResponseStream { + messageId: string; + delta: string; + done: boolean; +} + +export type ConnectionStatus = "connected" | "disconnected" | "connecting" | "error"; + +export type AppMode = "plan" | "build"; + +export interface AppState { + messages: Message[]; + isTyping: boolean; + connectionStatus: ConnectionStatus; + themeMode: "light" | "dark"; + appMode: AppMode; + currentModel: string; + currentThemeName: string; +} + +let messageIdCounter = 0; + +export function createMessage(role: MessageRole, content: string): Message { + return { + id: `msg_${++messageIdCounter}`, + role, + content, + timestamp: Date.now(), + }; +} + +export function createToolCall(name: string, args: string): ToolCall { + return { + id: `tool_${++messageIdCounter}`, + name, + arguments: args, + }; +} diff --git a/src/typing-indicator.ts b/src/typing-indicator.ts new file mode 100644 index 0000000..27b6f21 --- /dev/null +++ b/src/typing-indicator.ts @@ -0,0 +1,87 @@ +import { BoxRenderable, TextRenderable } from "@opentui/core"; +import type { CliRenderer } from "./renderer"; +import type { IAgentTheme, ThemeVariant } from "./theme"; + +export interface TypingIndicatorOpts { + renderer: CliRenderer; + theme: IAgentTheme; +} + +export class TypingIndicator { + private readonly renderer: CliRenderer; + private readonly theme: IAgentTheme; + private container: BoxRenderable | null = null; + private dotsComponent: TextRenderable | null = null; + private dots = 0; + private intervalId: ReturnType | null = null; + + constructor(options: TypingIndicatorOpts) { + this.renderer = options.renderer; + this.theme = options.theme; + } + + show(): void { + if (this.container) return; + + const _theme: ThemeVariant = this.theme.getTheme(); + + this.container = new BoxRenderable(this.renderer, { + id: "typing-indicator", + flexDirection: "row", + gap: 1, + }); + + const label = new TextRenderable(this.renderer, { + id: "typing-label", + content: "Assistant is thinking", + fg: _theme.seeds.info, + }); + + this.dotsComponent = new TextRenderable(this.renderer, { + id: "typing-dots", + content: "...", + fg: _theme.seeds.info, + }); + + this.container.add(label); + this.container.add(this.dotsComponent); + + this.intervalId = setInterval(() => { + this.dots = (this.dots + 1) % 4; + const dotsText = ".".repeat(this.dots).padEnd(3); + this.updateDots(dotsText); + }, 300); + + this.renderer.root.add(this.container); + } + + private updateDots(text: string): void { + if (this.dotsComponent) { + this.dotsComponent.remove("typing-dots"); + const _theme: ThemeVariant = this.theme.getTheme(); + this.dotsComponent = new TextRenderable(this.renderer, { + id: "typing-dots", + content: text, + fg: _theme.seeds.info, + }); + this.container?.add(this.dotsComponent); + } + } + + hide(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + if (this.container) { + this.container.remove("typing-indicator"); + this.container = null; + this.dotsComponent = null; + } + } + + isVisible(): boolean { + return this.container !== null; + } +}