diff --git a/.claude/tui-implementation-plan.md b/.claude/tui-implementation-plan.md new file mode 100644 index 00000000..0a46dda4 --- /dev/null +++ b/.claude/tui-implementation-plan.md @@ -0,0 +1,299 @@ +# Claudeman TUI Implementation Plan + +## Overview + +Build a full terminal user interface (TUI) for Claudeman that mirrors the web interface functionality. The TUI will allow users to: +- See all running screen sessions as tabs +- Switch between sessions with keyboard shortcuts +- View real-time terminal output from each Claude session +- Create, manage, and kill sessions +- Control respawn and Ralph loop features + +## Technology Choice: **Ink (React for CLI)** + +**Why Ink over alternatives:** +1. **Component-based**: Familiar React patterns, composable UI +2. **Already in ecosystem**: Claude CLI uses Ink (explains the screen input workaround) +3. **TypeScript native**: First-class TS support +4. **Active maintenance**: Regular updates, good community +5. **Handles PTY well**: Built for terminal apps with real-time output + +**Alternatives considered:** +- `blessed`/`blessed-contrib`: Legacy, unmaintained, complex API +- `terminal-kit`: Lower-level, more boilerplate +- Raw ANSI: Too low-level for complex UIs +- Existing bash script: Not extensible enough + +## Architecture + +``` +src/ +├── tui/ +│ ├── index.ts # Entry point, CLI command +│ ├── App.tsx # Main TUI app component +│ ├── components/ +│ │ ├── TabBar.tsx # Session tabs at top +│ │ ├── Terminal.tsx # PTY output display +│ │ ├── StatusBar.tsx # Bottom status bar +│ │ ├── SessionInfo.tsx # Session details panel +│ │ ├── HelpOverlay.tsx # Keyboard shortcuts help +│ │ ├── RalphPanel.tsx # Inner loop/todo tracking +│ │ └── StartScreen.tsx # Initial session discovery +│ ├── hooks/ +│ │ ├── useSession.ts # Session management hook +│ │ ├── useTerminal.ts # Terminal output handling +│ │ └── useKeyboard.ts # Global keyboard shortcuts +│ └── store/ +│ └── tui-state.ts # TUI-specific state +``` + +## Implementation Steps + +### Phase 1: Foundation (Core Infrastructure) + +#### Step 1.1: Add Dependencies +```bash +npm install ink ink-text-input ink-spinner ink-box react +npm install -D @types/react +``` + +#### Step 1.2: Create Entry Point (`src/tui/index.ts`) +- Add `tui` command to CLI in `src/cli.ts` +- Create Ink render entry point +- Handle graceful shutdown (restore terminal) + +#### Step 1.3: Create Main App Component (`src/tui/App.tsx`) +- Full-screen layout with Ink's `Box` components +- Three regions: TabBar (top), Terminal (center), StatusBar (bottom) +- Global keyboard event handling + +### Phase 2: Start Screen & Session Discovery + +#### Step 2.1: Create StartScreen Component +On launch, the TUI should: +1. Read `~/.claudeman/screens.json` for existing sessions +2. Check which screens are alive (`screen -ls`) +3. Display list of sessions with status +4. Allow user to: + - Select and attach to existing session + - Create new session + - Kill dead sessions + +#### Step 2.2: Session List Display +``` +╔═══════════════════════════════════════════════════════════╗ +║ Claudeman TUI ║ +╠═══════════════════════════════════════════════════════════╣ +║ Existing Sessions: ║ +║ ║ +║ [1] ● testcase 2h 15m idle claude ║ +║ [2] ● another-case 45m working claude ║ +║ [3] ○ old-session 3d dead claude ║ +║ ║ +║ ─────────────────────────────────────────────────────────║ +║ [n] New session [1-9] Select [d] Delete [q] Quit ║ +╚═══════════════════════════════════════════════════════════╝ +``` + +### Phase 3: Tab System + +#### Step 3.1: TabBar Component +- Horizontal tab bar at top of screen +- Shows session name + status indicator (● idle, ◐ working) +- Keyboard navigation: Ctrl+Tab (next), Ctrl+Shift+Tab (prev), Ctrl+1-9 (direct) +- Visual indication of active tab + +#### Step 3.2: Tab State Management +- Track active session ID +- Track tab order (array of session IDs) +- Handle session creation/deletion (add/remove tabs) +- Persist tab order to state + +### Phase 4: Terminal Display + +#### Step 4.1: Terminal Component +- Display PTY output for active session +- Scrollable viewport (show last N lines that fit) +- ANSI color support (Ink handles this) +- Handle terminal resize + +#### Step 4.2: Output Integration +Connect to existing Session class: +```typescript +// Subscribe to session events +session.on('terminal', (data) => { + // Append to display buffer +}); + +session.on('clearTerminal', () => { + // Clear display buffer +}); +``` + +#### Step 4.3: Input Handling +- Capture keyboard input when terminal focused +- Send to session via `session.write()` or `session.writeViaScreen()` +- Handle special keys (Ctrl+C, Ctrl+D, etc.) +- Distinguish between TUI commands and session input + +### Phase 5: Status Bar + +#### Step 5.1: StatusBar Component +Display at bottom: +- Session status (idle/working) +- Token count +- Cost +- Respawn status (if enabled) +- Ralph loop status (if active) + +#### Step 5.2: System Stats (Optional) +- CPU/Memory usage (like web UI) +- Poll periodically from `/proc` + +### Phase 6: Session Management + +#### Step 6.1: New Session Creation +- Prompt for case name (or use default) +- Create case folder if needed +- Start interactive Claude session +- Add to tab bar + +#### Step 6.2: Session Operations +- Close session (Ctrl+W): Kill screen, remove tab +- Kill all (Ctrl+Shift+K): Confirm dialog, kill all screens + +### Phase 7: Advanced Features + +#### Step 7.1: Ralph Panel +When Ralph loop active, show: +- Progress ring (iteration count) +- Todo list with status +- Completion phrase status + +#### Step 7.2: Help Overlay +- Press `?` or `F1` to show +- List all keyboard shortcuts +- Dismiss with Escape + +#### Step 7.3: Respawn Banner +When respawn enabled: +- Show current state +- Cycle count +- Controls (pause/resume) + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Ctrl+Tab` | Next tab | +| `Ctrl+Shift+Tab` | Previous tab | +| `Ctrl+1-9` | Go to tab N | +| `Ctrl+N` | New session | +| `Ctrl+W` | Close current session | +| `Ctrl+Shift+K` | Kill all sessions | +| `Ctrl+L` | Clear terminal | +| `?` or `F1` | Show help | +| `Escape` | Close overlay/panel | +| `Ctrl+C` | Exit TUI (with confirm) | + +## Data Flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ TUI App │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ TabBar │ │ +│ │ [testcase ●] [another ◐] [+] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Terminal Viewport │ │ +│ │ │ │ +│ │ (PTY output from active session) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ StatusBar: idle | 45.2k tokens | $0.82 | respawn: off │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ │ + │ events │ input + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Session │◄───│ PTY (node-pty) │ +│ (existing) │ └─────────────────┘ +└─────────────────┘ +``` + +## Files to Create + +| File | Purpose | LOC Est. | +|------|---------|----------| +| `src/tui/index.ts` | Entry point | 30 | +| `src/tui/App.tsx` | Main app | 150 | +| `src/tui/components/TabBar.tsx` | Tab navigation | 80 | +| `src/tui/components/Terminal.tsx` | Output display | 120 | +| `src/tui/components/StatusBar.tsx` | Status display | 60 | +| `src/tui/components/StartScreen.tsx` | Initial view | 100 | +| `src/tui/components/HelpOverlay.tsx` | Help modal | 50 | +| `src/tui/components/RalphPanel.tsx` | Loop tracking | 80 | +| `src/tui/hooks/useSession.ts` | Session management | 100 | +| `src/tui/hooks/useTerminal.ts` | Terminal handling | 80 | +| `src/tui/hooks/useKeyboard.ts` | Key shortcuts | 60 | +| `src/tui/store/tui-state.ts` | State management | 50 | + +**Total: ~960 lines of new code** + +## Files to Modify + +| File | Change | +|------|--------| +| `src/cli.ts` | Add `tui` command | +| `src/index.ts` | Export TUI if needed | +| `package.json` | Add Ink dependencies | +| `tsconfig.json` | Add JSX support for TSX files | + +## Testing Strategy + +1. **Unit tests**: Component rendering with Ink's test utilities +2. **Integration tests**: Session creation, tab switching +3. **Manual testing**: Real Claude sessions, screen attach/detach + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Terminal resize handling | Use Ink's built-in resize detection | +| PTY output performance | Reuse existing batching from Session class | +| Input conflicts (TUI vs session) | Clear mode distinction, Escape to exit input mode | +| Screen attachment complexity | Reuse existing screen-manager.ts logic | + +## Success Criteria + +1. Launch TUI and see existing sessions from `screens.json` +2. Switch between sessions with tabs +3. See real-time Claude output in terminal +4. Send input to active session +5. Create new sessions +6. Kill sessions +7. Responsive keyboard navigation +8. Clean exit (restore terminal state) + +## Implementation Order + +1. **Week 1**: Phases 1-2 (Foundation + Start Screen) +2. **Week 2**: Phases 3-4 (Tabs + Terminal) +3. **Week 3**: Phases 5-6 (Status + Session Management) +4. **Week 4**: Phase 7 (Advanced Features) + +## Commands + +After implementation: +```bash +# Start TUI +claudeman tui + +# Or via npm/tsx +npx tsx src/index.ts tui +npm run tui # After adding script +``` diff --git a/package-lock.json b/package-lock.json index 50e431e7..79235232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,11 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "fastify": "^5.1.0", + "ink": "^6.6.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "node-pty": "^1.1.0", + "react": "^19.2.3", "uuid": "^10.0.0" }, "bin": { @@ -21,6 +25,7 @@ }, "devDependencies": { "@types/node": "^20.14.0", + "@types/react": "^19.2.9", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.17", "agent-browser": "^0.6.0", @@ -32,6 +37,34 @@ "node": ">=18.0.0" } }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz", + "integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -1181,6 +1214,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/react": { + "version": "19.2.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", + "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -1385,6 +1428,21 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1440,6 +1498,18 @@ "node": ">=8.0.0" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/avvio": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", @@ -1472,6 +1542,89 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1511,6 +1664,15 @@ "node": ">= 0.6" } }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1538,6 +1700,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1568,6 +1737,18 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1575,6 +1756,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1623,6 +1814,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1825,6 +2025,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -1898,12 +2110,166 @@ "url": "https://opencollective.com/express" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ink": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", + "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.1", + "ansi-escapes": "^7.2.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": "^6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -1922,6 +2288,21 @@ "node": ">=8" } }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2110,6 +2491,15 @@ "node": ">=10.0.0" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -2189,12 +2579,36 @@ "node": ">=14.0.0" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2348,6 +2762,30 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2376,6 +2814,28 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/ret": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", @@ -2494,6 +2954,12 @@ "node": ">=10" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2574,6 +3040,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -2602,6 +3099,18 @@ "node": ">= 10.x" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2828,6 +3337,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3047,6 +3568,44 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -3142,7 +3701,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3160,6 +3718,12 @@ } } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 43d05911..2fb111e5 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,16 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "fastify": "^5.1.0", + "ink": "^6.6.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "node-pty": "^1.1.0", + "react": "^19.2.3", "uuid": "^10.0.0" }, "devDependencies": { "@types/node": "^20.14.0", + "@types/react": "^19.2.9", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.17", "agent-browser": "^0.6.0", diff --git a/src/cli.ts b/src/cli.ts index 1f65e06e..2bd21b20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -470,4 +470,18 @@ program } }); +// TUI command +program + .command('tui') + .description('Start the terminal user interface') + .action(async () => { + try { + const { startTUI } = await import('./tui/index.js'); + await startTUI(); + } catch (err) { + console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`)); + process.exit(1); + } + }); + export { program }; diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 00000000..5949ec3e --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,215 @@ +/** + * @fileoverview Main TUI App component + * + * The root component that manages the overall TUI layout: + * - StartScreen: Initial session discovery view + * - Main view: TabBar + Terminal + StatusBar + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Text, useApp, useInput, useStdout } from 'ink'; +import { StartScreen } from './components/StartScreen.js'; +import { TabBar } from './components/TabBar.js'; +import { TerminalView } from './components/TerminalView.js'; +import { StatusBar } from './components/StatusBar.js'; +import { HelpOverlay } from './components/HelpOverlay.js'; +import { useSessionManager } from './hooks/useSessionManager.js'; +import type { ScreenSession } from '../types.js'; + +type ViewMode = 'start' | 'main'; + +/** + * Main application component + */ +export function App(): React.ReactElement { + const { exit } = useApp(); + const { stdout } = useStdout(); + const [viewMode, setViewMode] = useState('start'); + const [showHelp, setShowHelp] = useState(false); + const [terminalHeight, setTerminalHeight] = useState(20); + + const { + sessions, + activeSessionId, + activeSession, + refreshSessions, + selectSession, + createSession, + killSession, + killAllSessions, + nextSession, + prevSession, + sendInput, + terminalOutput, + } = useSessionManager(); + + // Calculate terminal height based on stdout dimensions + useEffect(() => { + const updateHeight = () => { + // Reserve: 3 for TabBar, 3 for StatusBar, 2 for borders + const rows = stdout?.rows || 24; + setTerminalHeight(Math.max(10, rows - 8)); + }; + updateHeight(); + + stdout?.on('resize', updateHeight); + return () => { + stdout?.off('resize', updateHeight); + }; + }, [stdout]); + + // Handle keyboard input + useInput((input, key) => { + // Help overlay takes priority + if (showHelp) { + if (key.escape || input === 'q' || input === '?') { + setShowHelp(false); + } + return; + } + + // Global shortcuts + if (input === '?' || (key.ctrl && input === 'h')) { + setShowHelp(true); + return; + } + + // Exit on Ctrl+C (with confirmation in main view) + if (key.ctrl && input === 'c') { + exit(); + return; + } + + // Start screen specific inputs + if (viewMode === 'start') { + if (input === 'q') { + exit(); + return; + } + if (input === 'n') { + handleCreateSession(); + return; + } + if (input === 'r') { + refreshSessions(); + return; + } + // Number keys to select session + const num = parseInt(input, 10); + if (!isNaN(num) && num >= 1 && num <= sessions.length) { + handleSelectSession(sessions[num - 1]); + return; + } + return; + } + + // Main view shortcuts + if (key.ctrl && key.tab) { + if (key.shift) { + prevSession(); + } else { + nextSession(); + } + return; + } + + // Ctrl+1-9 for direct tab access + if (key.ctrl) { + const num = parseInt(input, 10); + if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) { + selectSession(sessions[num - 1].sessionId); + return; + } + } + + // Ctrl+W to close current session + if (key.ctrl && input === 'w') { + if (activeSessionId) { + killSession(activeSessionId); + if (sessions.length <= 1) { + setViewMode('start'); + } + } + return; + } + + // Ctrl+K to kill all sessions + if (key.ctrl && input === 'k') { + killAllSessions(); + setViewMode('start'); + return; + } + + // Ctrl+N for new session + if (key.ctrl && input === 'n') { + handleCreateSession(); + return; + } + + // Escape to go back to start screen + if (key.escape) { + setViewMode('start'); + return; + } + + // Forward other input to the active session + if (activeSessionId && !key.ctrl && !key.meta) { + if (key.return) { + sendInput(activeSessionId, '\r'); + } else if (key.backspace || key.delete) { + sendInput(activeSessionId, '\x7f'); + } else if (input) { + sendInput(activeSessionId, input); + } + } + }); + + const handleSelectSession = useCallback((session: ScreenSession) => { + selectSession(session.sessionId); + setViewMode('main'); + }, [selectSession]); + + const handleCreateSession = useCallback(async () => { + const sessionId = await createSession(); + if (sessionId) { + setViewMode('main'); + } + }, [createSession]); + + // Render help overlay if shown + if (showHelp) { + return setShowHelp(false)} />; + } + + // Render start screen + if (viewMode === 'start') { + return ( + + ); + } + + // Render main view with tabs, terminal, and status bar + return ( + + selectSession(id)} + /> + + + + + + ); +} diff --git a/src/tui/components/HelpOverlay.tsx b/src/tui/components/HelpOverlay.tsx new file mode 100644 index 00000000..fbed6adf --- /dev/null +++ b/src/tui/components/HelpOverlay.tsx @@ -0,0 +1,99 @@ +/** + * @fileoverview HelpOverlay component + * + * Full-screen overlay showing all keyboard shortcuts. + * Dismissible with Escape, q, or ?. + */ + +import React from 'react'; +import { Box, Text } from 'ink'; + +interface HelpOverlayProps { + onClose: () => void; +} + +interface ShortcutGroup { + title: string; + shortcuts: Array<{ key: string; description: string }>; +} + +const SHORTCUT_GROUPS: ShortcutGroup[] = [ + { + title: 'Navigation', + shortcuts: [ + { key: 'Ctrl+Tab', description: 'Next session' }, + { key: 'Ctrl+Shift+Tab', description: 'Previous session' }, + { key: 'Ctrl+1-9', description: 'Go to session N' }, + { key: 'Escape', description: 'Back to start screen' }, + ], + }, + { + title: 'Session Management', + shortcuts: [ + { key: 'Ctrl+N', description: 'New session' }, + { key: 'Ctrl+W', description: 'Close current session' }, + { key: 'Ctrl+K', description: 'Kill all sessions' }, + ], + }, + { + title: 'Start Screen', + shortcuts: [ + { key: '1-9', description: 'Select session by number' }, + { key: 'n', description: 'Create new session' }, + { key: 'r', description: 'Refresh session list' }, + { key: 'q', description: 'Quit' }, + ], + }, + { + title: 'General', + shortcuts: [ + { key: '?', description: 'Toggle this help' }, + { key: 'Ctrl+C', description: 'Exit TUI' }, + ], + }, +]; + +/** + * HelpOverlay component showing keyboard shortcuts + */ +export function HelpOverlay({ onClose }: HelpOverlayProps): React.ReactElement { + return ( + + {/* Header */} + + + Keyboard Shortcuts + + + + {/* Shortcut groups */} + {SHORTCUT_GROUPS.map((group, groupIndex) => ( + + + {group.title} + + + {group.shortcuts.map((shortcut, index) => ( + + {shortcut.key.padEnd(20)} + {shortcut.description} + + ))} + + + ))} + + {/* Footer */} + + Press Escape, q, or ? to close + + + ); +} diff --git a/src/tui/components/StartScreen.tsx b/src/tui/components/StartScreen.tsx new file mode 100644 index 00000000..9eb6ecf6 --- /dev/null +++ b/src/tui/components/StartScreen.tsx @@ -0,0 +1,138 @@ +/** + * @fileoverview StartScreen component + * + * Initial screen shown when TUI launches. Displays: + * - List of existing screen sessions from ~/.claudeman/screens.json + * - Session status (alive/dead), runtime, mode + * - Options to select, create, or refresh sessions + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import type { ScreenSession } from '../../types.js'; + +interface StartScreenProps { + sessions: ScreenSession[]; + onSelectSession: (session: ScreenSession) => void; + onCreateSession: () => void; + onRefresh: () => void; + onExit: () => void; +} + +/** + * Formats duration from milliseconds to human-readable string + */ +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}d ${hours % 24}h`; + } + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m`; + } + return `${seconds}s`; +} + +/** + * Start screen component showing session list + */ +export function StartScreen({ + sessions, + onSelectSession, + onCreateSession, + onRefresh, + onExit, +}: StartScreenProps): React.ReactElement { + const now = Date.now(); + + return ( + + {/* Header */} + + + Claudeman TUI + + + + + Session Manager - Press ? for help + + + {/* Session list */} + {sessions.length === 0 ? ( + + No sessions found + Press [n] to create a new session + + ) : ( + + {/* Table header */} + + + {' # '} + {'NAME'.padEnd(22)} + {'RUNTIME'.padEnd(12)} + {'STATUS'.padEnd(10)} + {'MODE'.padEnd(10)} + + + + {/* Session rows */} + {sessions.map((session, index) => { + const runtime = formatDuration(now - session.createdAt); + const statusColor = session.attached ? 'green' : 'red'; + const statusIcon = session.attached ? '\u25CF' : '\u25CB'; + const statusText = session.attached ? 'alive' : 'dead'; + const name = (session.name || 'unnamed').slice(0, 20); + + return ( + + + [{index + 1}] + + {name.padEnd(22)} + {runtime.padEnd(12)} + + {statusIcon} {statusText.padEnd(8)} + + {session.mode.padEnd(10)} + + + ); + })} + + )} + + {/* Footer with controls */} + + + + [n] + New session + [1-9] + Select + [r] + Refresh + [q] + Quit + [?] + Help + + + + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx new file mode 100644 index 00000000..27bed99c --- /dev/null +++ b/src/tui/components/StatusBar.tsx @@ -0,0 +1,91 @@ +/** + * @fileoverview StatusBar component + * + * Bottom status bar showing: + * - Session status (idle/working) + * - Token count + * - Cost + * - Runtime + * - Quick keyboard shortcuts + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import type { ScreenSession } from '../../types.js'; + +interface StatusBarProps { + session: ScreenSession | null; +} + +/** + * Formats duration from milliseconds to human-readable string + */ +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +/** + * StatusBar component showing session information + */ +export function StatusBar({ session }: StatusBarProps): React.ReactElement { + if (!session) { + return ( + + No session selected + Press ? for help | Esc to go back + + ); + } + + const runtime = formatDuration(Date.now() - session.createdAt); + const statusColor = session.attached ? 'green' : 'red'; + const statusText = session.attached ? 'alive' : 'dead'; + + return ( + + {/* Left side: Session info */} + + + {'\u25CF'} {statusText} + + | + + runtime: {runtime} + + | + + mode: {session.mode} + + | + + screen: {session.screenName} + + + + {/* Right side: Keyboard hints */} + + ? help | Esc back | Ctrl+C exit + + + ); +} diff --git a/src/tui/components/TabBar.tsx b/src/tui/components/TabBar.tsx new file mode 100644 index 00000000..10ab28b2 --- /dev/null +++ b/src/tui/components/TabBar.tsx @@ -0,0 +1,70 @@ +/** + * @fileoverview TabBar component + * + * Horizontal tab bar showing all active sessions. + * Features: + * - Visual indication of active tab + * - Status indicator (idle/working) + * - Tab truncation for long names + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import type { ScreenSession } from '../../types.js'; + +interface TabBarProps { + sessions: ScreenSession[]; + activeSessionId: string | null; + onSelectSession: (sessionId: string) => void; +} + +/** + * TabBar component for session navigation + */ +export function TabBar({ + sessions, + activeSessionId, +}: TabBarProps): React.ReactElement { + if (sessions.length === 0) { + return ( + + No sessions + + ); + } + + return ( + + {sessions.map((session, index) => { + const isActive = session.sessionId === activeSessionId; + const name = (session.name || 'unnamed').slice(0, 15); + // Status indicator: filled circle for alive, hollow for dead + const statusIcon = session.attached ? '\u25CF' : '\u25CB'; + const statusColor = session.attached ? 'green' : 'red'; + + return ( + + {isActive ? ( + + {' '} + {statusIcon} {name}{' '} + + ) : ( + + {' '} + {statusIcon}{' '} + {name}{' '} + + )} + {index < sessions.length - 1 && |} + + ); + })} + + {/* Shortcut hint */} + + Ctrl+Tab: switch | Ctrl+W: close + + + ); +} diff --git a/src/tui/components/TerminalView.tsx b/src/tui/components/TerminalView.tsx new file mode 100644 index 00000000..7b80745f --- /dev/null +++ b/src/tui/components/TerminalView.tsx @@ -0,0 +1,84 @@ +/** + * @fileoverview TerminalView component + * + * Displays PTY output from the active session. + * Features: + * - Scrollable viewport showing last N lines + * - ANSI color support (handled by Ink) + * - Visual border indicating focus + */ + +import React, { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import type { ScreenSession } from '../../types.js'; + +interface TerminalViewProps { + output: string; + height: number; + session: ScreenSession | null; +} + +/** + * TerminalView component for PTY output display + */ +export function TerminalView({ + output, + height, + session, +}: TerminalViewProps): React.ReactElement { + // Split output into lines and get the last N lines that fit + const displayLines = useMemo(() => { + if (!output) return []; + + const lines = output.split('\n'); + // Reserve 2 lines for border + const visibleLines = height - 2; + + if (lines.length <= visibleLines) { + return lines; + } + + // Return last N lines + return lines.slice(-visibleLines); + }, [output, height]); + + if (!session) { + return ( + + + No session selected + + + ); + } + + return ( + + {/* Terminal content */} + + {displayLines.length === 0 ? ( + Waiting for output... + ) : ( + displayLines.map((line, index) => ( + + {line} + + )) + )} + + + ); +} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts new file mode 100644 index 00000000..02f5c687 --- /dev/null +++ b/src/tui/components/index.ts @@ -0,0 +1,9 @@ +/** + * @fileoverview Component exports for TUI + */ + +export { StartScreen } from './StartScreen.js'; +export { TabBar } from './TabBar.js'; +export { TerminalView } from './TerminalView.js'; +export { StatusBar } from './StatusBar.js'; +export { HelpOverlay } from './HelpOverlay.js'; diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts new file mode 100644 index 00000000..ffc6c8a4 --- /dev/null +++ b/src/tui/hooks/index.ts @@ -0,0 +1,5 @@ +/** + * @fileoverview Hook exports for TUI + */ + +export { useSessionManager } from './useSessionManager.js'; diff --git a/src/tui/hooks/useSessionManager.ts b/src/tui/hooks/useSessionManager.ts new file mode 100644 index 00000000..f549cd50 --- /dev/null +++ b/src/tui/hooks/useSessionManager.ts @@ -0,0 +1,268 @@ +/** + * @fileoverview useSessionManager hook + * + * Manages session state for the TUI. Connects to: + * - ~/.claudeman/screens.json for session discovery + * - ScreenManager for session operations + * - Session class for terminal output + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { existsSync, readFileSync, watchFile, unwatchFile } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { execSync, spawn } from 'child_process'; +import type { ScreenSession } from '../../types.js'; + +const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); + +interface SessionManagerState { + sessions: ScreenSession[]; + activeSessionId: string | null; + activeSession: ScreenSession | null; + terminalOutput: string; + refreshSessions: () => void; + selectSession: (sessionId: string) => void; + createSession: () => Promise; + killSession: (sessionId: string) => void; + killAllSessions: () => void; + nextSession: () => void; + prevSession: () => void; + sendInput: (sessionId: string, input: string) => void; +} + +/** + * Check if a screen session is alive + */ +function isScreenAlive(screenName: string): boolean { + try { + const output = execSync('screen -ls', { encoding: 'utf-8' }); + return output.includes(screenName); + } catch { + return false; + } +} + +/** + * Load sessions from screens.json + */ +function loadSessions(): ScreenSession[] { + try { + if (!existsSync(SCREENS_FILE)) { + return []; + } + const data = readFileSync(SCREENS_FILE, 'utf-8'); + const sessions: ScreenSession[] = JSON.parse(data); + + // Check which sessions are alive + return sessions.map((session) => ({ + ...session, + attached: isScreenAlive(session.screenName), + })); + } catch { + return []; + } +} + +/** + * Hook for managing sessions in the TUI + */ +export function useSessionManager(): SessionManagerState { + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [terminalOutput, setTerminalOutput] = useState(''); + const outputBufferRef = useRef(''); + + // Load sessions on mount and watch for changes + useEffect(() => { + // Initial load + setSessions(loadSessions()); + + // Watch for changes + const handleChange = () => { + setSessions(loadSessions()); + }; + + try { + watchFile(SCREENS_FILE, { interval: 1000 }, handleChange); + } catch { + // File may not exist yet + } + + return () => { + try { + unwatchFile(SCREENS_FILE, handleChange); + } catch { + // Ignore + } + }; + }, []); + + // Get active session + const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null; + + // Refresh sessions manually + const refreshSessions = useCallback(() => { + setSessions(loadSessions()); + }, []); + + // Select a session + const selectSession = useCallback((sessionId: string) => { + setActiveSessionId(sessionId); + setTerminalOutput(''); + outputBufferRef.current = ''; + + // Start capturing output from the screen session + const session = sessions.find((s) => s.sessionId === sessionId); + if (session && isScreenAlive(session.screenName)) { + // Use screen -X hardcopy to capture current screen content + try { + const hardcopyFile = `/tmp/claudeman-${sessionId}-hardcopy`; + execSync(`screen -S ${session.screenName} -X hardcopy ${hardcopyFile}`, { + encoding: 'utf-8', + }); + if (existsSync(hardcopyFile)) { + const content = readFileSync(hardcopyFile, 'utf-8'); + setTerminalOutput(content); + outputBufferRef.current = content; + } + } catch { + // Hardcopy may fail, that's ok + } + } + }, [sessions]); + + // Create new session + const createSession = useCallback(async (): Promise => { + try { + // Use the web API to create a session if server is running + const response = await fetch('http://localhost:3000/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caseName: `tui-${Date.now()}` }), + }); + + if (response.ok) { + const data = await response.json() as { success?: boolean; sessionId?: string }; + if (data.success && data.sessionId) { + // Refresh and select new session + const sessionId = data.sessionId; + setTimeout(() => { + refreshSessions(); + setActiveSessionId(sessionId); + }, 500); + return sessionId; + } + } + + // Fallback: Just report that web server isn't running + setTerminalOutput( + 'Web server not running. Start it with: claudeman web\n' + + 'Then use this TUI to manage sessions.' + ); + return null; + } catch { + setTerminalOutput( + 'Cannot connect to Claudeman server.\n' + + 'Start the server with: claudeman web\n' + + 'Then use this TUI to manage sessions.' + ); + return null; + } + }, [refreshSessions]); + + // Kill a session + const killSession = useCallback((sessionId: string) => { + const session = sessions.find((s) => s.sessionId === sessionId); + if (!session) return; + + try { + // Kill via screen + execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' }); + } catch { + // May already be dead + } + + // If this was the active session, select another + if (activeSessionId === sessionId) { + const remaining = sessions.filter((s) => s.sessionId !== sessionId); + if (remaining.length > 0) { + setActiveSessionId(remaining[0].sessionId); + } else { + setActiveSessionId(null); + } + } + + // Refresh to update the list + setTimeout(refreshSessions, 500); + }, [sessions, activeSessionId, refreshSessions]); + + // Kill all sessions + const killAllSessions = useCallback(() => { + for (const session of sessions) { + try { + execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' }); + } catch { + // Ignore + } + } + setActiveSessionId(null); + setTimeout(refreshSessions, 500); + }, [sessions, refreshSessions]); + + // Navigate to next session + const nextSession = useCallback(() => { + if (sessions.length === 0) return; + const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId); + const nextIndex = (currentIndex + 1) % sessions.length; + selectSession(sessions[nextIndex].sessionId); + }, [sessions, activeSessionId, selectSession]); + + // Navigate to previous session + const prevSession = useCallback(() => { + if (sessions.length === 0) return; + const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId); + const prevIndex = currentIndex <= 0 ? sessions.length - 1 : currentIndex - 1; + selectSession(sessions[prevIndex].sessionId); + }, [sessions, activeSessionId, selectSession]); + + // Send input to a session + const sendInput = useCallback((sessionId: string, input: string) => { + const session = sessions.find((s) => s.sessionId === sessionId); + if (!session || !isScreenAlive(session.screenName)) return; + + try { + // Send input via screen stuff command + // Escape special characters for shell + const escaped = input.replace(/'/g, "'\\''"); + + if (input === '\r') { + // Send Enter key + execSync(`screen -S ${session.screenName} -p 0 -X stuff $'\\015'`, { + encoding: 'utf-8', + }); + } else { + execSync(`screen -S ${session.screenName} -p 0 -X stuff '${escaped}'`, { + encoding: 'utf-8', + }); + } + } catch { + // Input may fail if screen is not ready + } + }, [sessions]); + + return { + sessions, + activeSessionId, + activeSession, + terminalOutput, + refreshSessions, + selectSession, + createSession, + killSession, + killAllSessions, + nextSession, + prevSession, + sendInput, + }; +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx new file mode 100644 index 00000000..30cb2ee3 --- /dev/null +++ b/src/tui/index.tsx @@ -0,0 +1,41 @@ +/** + * @fileoverview TUI entry point for Claudeman + * + * Renders the terminal user interface using Ink (React for CLI). + * This provides a full-screen TUI similar to the web interface, + * with tabs for sessions and real-time terminal output. + */ + +import React from 'react'; +import { render } from 'ink'; +import { App } from './App.js'; + +/** + * Checks if the terminal supports raw mode (required for TUI) + */ +function isRawModeSupported(): boolean { + return Boolean( + process.stdin.isTTY && + typeof process.stdin.setRawMode === 'function' + ); +} + +/** + * Starts the TUI application + * @returns Promise that resolves when the app exits + */ +export async function startTUI(): Promise { + // Check if we're in an interactive terminal + if (!isRawModeSupported()) { + console.error('Error: TUI requires an interactive terminal with TTY support.'); + console.error('Make sure you are running this command in a real terminal, not piped.'); + process.exit(1); + } + + // Clear the terminal for full-screen experience + process.stdout.write('\x1b[2J\x1b[H'); + + const { waitUntilExit } = render(); + + await waitUntilExit(); +} diff --git a/tsconfig.json b/tsconfig.json index c83c5333..dd444455 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,9 @@ "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "jsx": "react-jsx", + "jsxImportSource": "react" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]