feat(tui): add terminal user interface with Ink

Add a full TUI for Claudeman using Ink (React for CLI):
- StartScreen: Shows existing sessions from screens.json
- TabBar: Session tabs with status indicators
- TerminalView: PTY output display with scrolling
- StatusBar: Session info and keyboard shortcuts
- HelpOverlay: Full keyboard shortcut reference

Features:
- Session discovery from ~/.claudeman/screens.json
- Tab navigation (Ctrl+Tab, Ctrl+1-9)
- Create new sessions (connects to web API)
- Kill sessions via screen commands
- Keyboard-driven navigation

Usage: claudeman tui

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 06:19:02 +01:00
co-authored by Claude Opus 4.5
parent b3dd0bcacb
commit 84dab25f38
15 changed files with 1906 additions and 2 deletions
+299
View File
@@ -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
```
+565 -1
View File
@@ -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",
+5
View File
@@ -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",
+14
View File
@@ -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 };
+215
View File
@@ -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<ViewMode>('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 <HelpOverlay onClose={() => setShowHelp(false)} />;
}
// Render start screen
if (viewMode === 'start') {
return (
<StartScreen
sessions={sessions}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
onRefresh={refreshSessions}
onExit={exit}
/>
);
}
// Render main view with tabs, terminal, and status bar
return (
<Box flexDirection="column" height="100%">
<TabBar
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={(id) => selectSession(id)}
/>
<TerminalView
output={terminalOutput}
height={terminalHeight}
session={activeSession}
/>
<StatusBar session={activeSession} />
</Box>
);
}
+99
View File
@@ -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 (
<Box flexDirection="column" padding={2}>
{/* Header */}
<Box
borderStyle="double"
borderColor="cyan"
paddingX={2}
paddingY={1}
marginBottom={1}
justifyContent="center"
>
<Text bold color="cyan">
Keyboard Shortcuts
</Text>
</Box>
{/* Shortcut groups */}
{SHORTCUT_GROUPS.map((group, groupIndex) => (
<Box key={groupIndex} flexDirection="column" marginBottom={1}>
<Text bold color="yellow">
{group.title}
</Text>
<Box flexDirection="column" marginLeft={2}>
{group.shortcuts.map((shortcut, index) => (
<Box key={index}>
<Text color="green">{shortcut.key.padEnd(20)}</Text>
<Text>{shortcut.description}</Text>
</Box>
))}
</Box>
</Box>
))}
{/* Footer */}
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>Press Escape, q, or ? to close</Text>
</Box>
</Box>
);
}
+138
View File
@@ -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 (
<Box flexDirection="column" padding={1}>
{/* Header */}
<Box
borderStyle="double"
borderColor="cyan"
paddingX={2}
paddingY={1}
justifyContent="center"
>
<Text bold color="cyan">
Claudeman TUI
</Text>
</Box>
<Box marginY={1}>
<Text dimColor>Session Manager - Press ? for help</Text>
</Box>
{/* Session list */}
{sessions.length === 0 ? (
<Box flexDirection="column" marginY={1}>
<Text color="yellow">No sessions found</Text>
<Text dimColor>Press [n] to create a new session</Text>
</Box>
) : (
<Box flexDirection="column">
{/* Table header */}
<Box marginBottom={1}>
<Text bold>
<Text color="gray">{' # '}</Text>
<Text>{'NAME'.padEnd(22)}</Text>
<Text>{'RUNTIME'.padEnd(12)}</Text>
<Text>{'STATUS'.padEnd(10)}</Text>
<Text>{'MODE'.padEnd(10)}</Text>
</Text>
</Box>
{/* 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 (
<Box key={session.sessionId}>
<Text>
<Text color="cyan">[{index + 1}]</Text>
<Text> </Text>
<Text>{name.padEnd(22)}</Text>
<Text dimColor>{runtime.padEnd(12)}</Text>
<Text color={statusColor}>
{statusIcon} {statusText.padEnd(8)}
</Text>
<Text>{session.mode.padEnd(10)}</Text>
</Text>
</Box>
);
})}
</Box>
)}
{/* Footer with controls */}
<Box marginTop={2} flexDirection="column">
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text>
<Text color="green">[n]</Text>
<Text> New session </Text>
<Text color="green">[1-9]</Text>
<Text> Select </Text>
<Text color="green">[r]</Text>
<Text> Refresh </Text>
<Text color="yellow">[q]</Text>
<Text> Quit </Text>
<Text color="blue">[?]</Text>
<Text> Help</Text>
</Text>
</Box>
</Box>
</Box>
);
}
+91
View File
@@ -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 (
<Box
borderStyle="single"
borderColor="gray"
paddingX={1}
justifyContent="space-between"
>
<Text dimColor>No session selected</Text>
<Text dimColor>Press ? for help | Esc to go back</Text>
</Box>
);
}
const runtime = formatDuration(Date.now() - session.createdAt);
const statusColor = session.attached ? 'green' : 'red';
const statusText = session.attached ? 'alive' : 'dead';
return (
<Box
borderStyle="single"
borderColor="cyan"
paddingX={1}
justifyContent="space-between"
>
{/* Left side: Session info */}
<Box>
<Text color={statusColor} bold>
{'\u25CF'} {statusText}
</Text>
<Text> | </Text>
<Text>
<Text dimColor>runtime:</Text> {runtime}
</Text>
<Text> | </Text>
<Text>
<Text dimColor>mode:</Text> {session.mode}
</Text>
<Text> | </Text>
<Text>
<Text dimColor>screen:</Text> {session.screenName}
</Text>
</Box>
{/* Right side: Keyboard hints */}
<Box>
<Text dimColor>? help | Esc back | Ctrl+C exit</Text>
</Box>
</Box>
);
}
+70
View File
@@ -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 (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>No sessions</Text>
</Box>
);
}
return (
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
{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 (
<Box key={session.sessionId} marginRight={1}>
{isActive ? (
<Text backgroundColor="blue" color="white" bold>
{' '}
<Text color={statusColor}>{statusIcon}</Text> {name}{' '}
</Text>
) : (
<Text>
{' '}
<Text color={statusColor}>{statusIcon}</Text>{' '}
<Text dimColor>{name}</Text>{' '}
</Text>
)}
{index < sessions.length - 1 && <Text dimColor>|</Text>}
</Box>
);
})}
{/* Shortcut hint */}
<Box flexGrow={1} justifyContent="flex-end">
<Text dimColor>Ctrl+Tab: switch | Ctrl+W: close</Text>
</Box>
</Box>
);
}
+84
View File
@@ -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 (
<Box
flexDirection="column"
borderStyle="single"
borderColor="gray"
height={height}
paddingX={1}
>
<Box flexGrow={1} alignItems="center" justifyContent="center">
<Text dimColor>No session selected</Text>
</Box>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="single"
borderColor="green"
height={height}
paddingX={1}
overflow="hidden"
>
{/* Terminal content */}
<Box flexDirection="column" flexGrow={1}>
{displayLines.length === 0 ? (
<Text dimColor>Waiting for output...</Text>
) : (
displayLines.map((line, index) => (
<Text key={index} wrap="truncate">
{line}
</Text>
))
)}
</Box>
</Box>
);
}
+9
View File
@@ -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';
+5
View File
@@ -0,0 +1,5 @@
/**
* @fileoverview Hook exports for TUI
*/
export { useSessionManager } from './useSessionManager.js';
+268
View File
@@ -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<string | null>;
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<ScreenSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [terminalOutput, setTerminalOutput] = useState<string>('');
const outputBufferRef = useRef<string>('');
// 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<string | null> => {
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,
};
}
+41
View File
@@ -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<void> {
// 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(<App />);
await waitUntilExit();
}
+3 -1
View File
@@ -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"]