diff --git a/.gitignore b/.gitignore index 42d6529..42cbd40 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ node_modules/ # Context tasks.md +todo.md diff --git a/Dashboard-ss.png b/Dashboard-ss.png new file mode 100644 index 0000000..047b9c3 Binary files /dev/null and b/Dashboard-ss.png differ diff --git a/README.md b/README.md index d9882e2..27a89ae 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,61 @@ # SessionBase CLI -CLI tool for SessionBase - manage and share AI coding sessions from Claude Code, Gemini CLI, Amazon Q Chat, and OpenAI Codex CLI. +**๐Ÿš€ Interactive Terminal Dashboard** - The fastest way to manage your AI coding sessions + +A powerful CLI tool for SessionBase featuring an interactive TUI dashboard to browse, search, and manage AI coding sessions from Claude Code, Gemini CLI, Amazon Q Chat, and OpenAI Codex CLI. + +## ๐Ÿ–ฅ๏ธ Interactive Terminal Dashboard + +Experience SessionBase through our powerful Terminal User Interface (TUI) - the fastest way to browse, search, and manage your AI coding sessions directly from your terminal. + +![Dashboard Screenshot](./Dashboard-ss.png) +![Sessions Browser Screenshot](./Sessions_ss.png) + +### Launch the Dashboard + +```bash +sessionbase dashboard +# or use the shorthand +sb dashboard +``` + +### Dashboard Features + +**๐Ÿ“Š Dashboard View** +- Session statistics and metrics +- Platform breakdown visualization +- Activity heatmap showing your coding patterns +- Quick access to recent sessions + +**๐Ÿ” Sessions Browser** +- **Smart Date Grouping**: Sessions organized by "Today", "Yesterday", and specific dates +- **Advanced Search**: Filter by platform, search within session content, or browse by tags +- **Rich Metadata Display**: View message counts, timestamps, file paths, and project locations +- **Tag System**: Organize sessions with custom tags and filter by them +- **Split-Screen Details**: View session details alongside the list without losing context + +**โŒจ๏ธ Keyboard-First Navigation** +- **Instant Search**: Press `/` to focus search, `Enter` to apply +- **Platform Filters**: `a` (All), `c` (Claude), `g` (Gemini), `q` (Q Chat), `x` (Codex) +- **Sort Options**: Press `s` to cycle through Recent/Oldest/Title sorting +- **Session Details**: `Enter` to expand details, `Esc` to close +- **Efficient Movement**: Arrow keys for navigation, `Tab` to toggle focus + +#### Complete Keyboard Reference + +| Key | Action | +|-----|--------| +| `1` | Switch to Dashboard view | +| `2` | Switch to Sessions view | +| `โ†‘` `โ†“` | Navigate sessions | +| `/` | Focus search bar | +| `Enter` | Apply search / Open session details | +| `Esc` | Clear search / Close details / Unfocus | +| `Tab` | Toggle focus between search and list | +| `a`, `c`, `g`, `q`, `x` | Filter by platform | +| `s` | Cycle sort order (Recent โ†’ Oldest โ†’ Title) | +| `q` or `Ctrl+C` | Quit dashboard | ## Quick Start @@ -19,8 +73,8 @@ npm install -g @sessionbase/cli ``` This provides two commands: -- `sessionbase` - Main CLI interface - - `sb` - Shorthand alias for faster typing +- `sessionbase` - Main CLI interface with TUI dashboard +- `sb` - Shorthand alias for faster typing - `sessionbase-mcp` - MCP server for AI platforms ### Authentication @@ -48,6 +102,8 @@ Push your most recent AI chat session from your current directory: ```bash # From Claude Code sessionbase push --claude +# or +sb push --claude # From Gemini CLI (after saving with /chat save) sessionbase push --gemini @@ -59,9 +115,11 @@ sessionbase push --qchat sessionbase push --codex ``` +**๐Ÿ’ก Pro Tip**: Use the TUI dashboard (`sessionbase dashboard`) to browse your local sessions before pushing them to SessionBase! + ## MCP Server Setup (Recommended) -The MCP server enables you to push sessions directly from your AI chat without breaking your workflow. +The MCP server enables you to push sessions directly from your AI chat without breaking your workflow. Combine it with the interactive TUI dashboard for the complete SessionBase experience! ### Claude Code @@ -108,17 +166,34 @@ command = "sessionbase-mcp" ## Usage Examples +### Terminal Dashboard (Recommended) + +```bash +# Launch the interactive TUI dashboard +sessionbase dashboard +sb dashboard # shorthand alias + +# Browse and manage all your sessions +# - Search by content or tags +# - Filter by AI platform +# - View session details and metadata +# - Push sessions directly from the interface +``` + ### CLI Commands ```bash -# List all sessions +# List all sessions (local and remote) sessionbase ls --global +sb ls --global # Push private session with metadata sessionbase push --claude --title "Debug Session" --tags "debugging,api" --private +sb push --claude --title "Debug Session" --tags "debugging,api" --private # Push specific file sessionbase push /path/to/session.json +sb push /path/to/session.json ``` ### MCP Server (Natural Language) @@ -207,6 +282,9 @@ npm link # Verify installation sessionbase --help sessionbase --version + +# Test the TUI dashboard +sessionbase dashboard ``` ### Unlink (when done testing) diff --git a/Sessions-ss.png b/Sessions-ss.png new file mode 100644 index 0000000..180fa08 Binary files /dev/null and b/Sessions-ss.png differ diff --git a/package-lock.json b/package-lock.json index 95660f4..4ef4afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,15 +7,22 @@ "": { "name": "@sessionbase/cli", "version": "1.0.1", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.1", + "@types/react": "^19.2.7", "chalk": "^5.4.1", "commander": "^11", + "date-fns": "^4.1.0", "execa": "^9.6.0", + "ink": "^6.5.1", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "keytar": "^7", "oh-my-logo": "^0.1.0", "open": "^9", "ora": "^8", + "react": "^19.2.1", "sqlite3": "^5.1.7", "zod": "^3" }, @@ -36,23 +43,21 @@ } }, "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.2.tgz", + "integrity": "sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA==", "dependencies": { "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=14.13.1" + "node": ">=18" } }, "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "engines": { "node": ">=12" }, @@ -61,12 +66,14 @@ } }, "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1018,14 +1025,11 @@ } }, "node_modules/@types/react": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "license": "MIT", - "optional": true, - "peer": true, + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/tinycolor2": { @@ -1244,10 +1248,9 @@ } }, "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "license": "MIT", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", "dependencies": { "environment": "^1.0.0" }, @@ -1683,10 +1686,9 @@ } }, "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1779,59 +1781,33 @@ } }, "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", + "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==", "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/code-excerpt": { @@ -1990,12 +1966,18 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "optional": true, - "peer": true + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } }, "node_modules/debug": { "version": "4.4.1", @@ -2251,7 +2233,6 @@ "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" }, @@ -2297,14 +2278,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.7", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.7.tgz", - "integrity": "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==" }, "node_modules/esbuild": { "version": "0.25.6", @@ -2840,10 +2816,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "license": "MIT", + "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==", "engines": { "node": ">=18" }, @@ -3231,30 +3206,28 @@ "license": "ISC" }, "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", - "license": "MIT", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.5.1.tgz", + "integrity": "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ==", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", + "@alcalzone/ansi-tokenize": "^0.2.1", + "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", - "chalk": "^5.3.0", + "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", + "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", + "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", + "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", + "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", - "string-width": "^7.2.0", + "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", @@ -3262,12 +3235,12 @@ "yoga-layout": "~3.2.1" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": "^6.1.2" }, "peerDependenciesMeta": { "@types/react": { @@ -3319,6 +3292,37 @@ "ink": ">=4" } }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "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==", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, "node_modules/ink/node_modules/ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", @@ -3386,6 +3390,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "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.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", @@ -3403,6 +3422,22 @@ "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==", + "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/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -3495,15 +3530,14 @@ } }, "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", "bin": { "is-in-ci": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4446,6 +4480,232 @@ "node": ">=18" } }, + "node_modules/oh-my-logo/node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/oh-my-logo/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/oh-my-logo/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==", + "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/oh-my-logo/node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oh-my-logo/node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/oh-my-logo/node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.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": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/oh-my-logo/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oh-my-logo/node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oh-my-logo/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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/oh-my-logo/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oh-my-logo/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oh-my-logo/node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/oh-my-logo/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==", + "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/oh-my-logo/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/oh-my-logo/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==", + "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/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4958,13 +5218,9 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "engines": { "node": ">=0.10.0" } @@ -4977,19 +5233,17 @@ "license": "MIT" }, "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "license": "MIT", + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "engines": { "node": ">=0.10.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.0" } }, "node_modules/readable-stream": { @@ -5343,13 +5597,9 @@ "license": "MIT" }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "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==" }, "node_modules/semver": { "version": "7.7.2", diff --git a/package.json b/package.json index d8f9ba7..5ea467c 100644 --- a/package.json +++ b/package.json @@ -47,13 +47,19 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.1", + "@types/react": "^19.2.7", "chalk": "^5.4.1", "commander": "^11", + "date-fns": "^4.1.0", "execa": "^9.6.0", + "ink": "^6.5.1", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "keytar": "^7", "oh-my-logo": "^0.1.0", "open": "^9", "ora": "^8", + "react": "^19.2.1", "sqlite3": "^5.1.7", "zod": "^3" }, diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts new file mode 100644 index 0000000..c138d5e --- /dev/null +++ b/src/commands/dashboard.ts @@ -0,0 +1,19 @@ +import { Command } from 'commander'; +import { render } from 'ink'; +import React from 'react'; +import { App } from '../tui/App.js'; + +export const dashboardCommand = new Command('dashboard') + .description('Launch interactive TUI dashboard') + .action(async () => { + try { + const { unmount, waitUntilExit } = render(React.createElement(App)); + + await waitUntilExit(); + unmount(); + } catch (error) { + console.error('Dashboard error:', error); + process.exit(1); + } + }); + diff --git a/src/index.ts b/src/index.ts index 3d4ef61..f6a08fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { pushCommand } from './commands/push.js'; import { whoamiCommand } from './commands/whoami.js'; import { logoutCommand } from './commands/logout.js'; import { deleteCommand } from './commands/delete.js'; +import { dashboardCommand } from './commands/dashboard.js'; program.addCommand(lsCommand); program.addCommand(loginCommand); @@ -32,6 +33,7 @@ program.addCommand(pushCommand); program.addCommand(whoamiCommand); program.addCommand(logoutCommand); program.addCommand(deleteCommand); +program.addCommand(dashboardCommand); // Process CLI commands program.parseAsync(process.argv).catch((error) => { diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 0000000..3cb3e84 --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,291 @@ +import React, { useState } from 'react'; +import { Box, useInput, useApp, Text } from 'ink'; +import { View } from './types.js'; +import { Sidebar } from './components/Sidebar.js'; +import { MainContent } from './components/MainContent.js'; +import { useSessionData } from './hooks/useSessionData.js'; +import { useSessionDetail } from './hooks/useSessionDetail.js'; +import { useRef } from 'react'; + +export const App: React.FC = () => { + const { exit } = useApp(); + const [currentView, setCurrentView] = useState('dashboard'); + const [selectedPlatform, setSelectedPlatform] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [searchDraft, setSearchDraft] = useState(''); + const [selectedSessionIndex, setSelectedSessionIndex] = useState(0); + const [isSearchFocused, setIsSearchFocused] = useState(false); + const [sortOrder, setSortOrder] = useState<'recent' | 'oldest' | 'title'>('recent'); + const [showHelp, setShowHelp] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [detailExpanded, setDetailExpanded] = useState(false); + const [detailPage, setDetailPage] = useState(0); + const lastMToggleRef = useRef(0); + + // Fetch data + const { sessions, stats, isLoading, error, isAuth, userName } = useSessionData({ + platform: selectedPlatform, + searchQuery: searchQuery, + }); + + // Reset session index when sessions change + React.useEffect(() => { + if (selectedSessionIndex >= sessions.length && sessions.length > 0) { + setSelectedSessionIndex(Math.max(0, sessions.length - 1)); + } + }, [sessions.length, selectedSessionIndex]); + + // Apply sorting to sessions before rendering + const sortedSessions = React.useMemo(() => { + const copy = [...sessions]; + switch (sortOrder) { + case 'oldest': + return copy.sort((a, b) => a.lastModified.getTime() - b.lastModified.getTime()); + case 'title': + return copy.sort((a, b) => { + const aTitle = (a.firstMessagePreview || a.title || '').toLowerCase(); + const bTitle = (b.firstMessagePreview || b.title || '').toLowerCase(); + return aTitle.localeCompare(bTitle); + }); + case 'recent': + default: + return copy.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + } + }, [sessions, sortOrder]); + + const selectedSession = sortedSessions[selectedSessionIndex] || null; + + // Detail loading + const { detail, isLoading: detailLoading, error: detailError } = useSessionDetail( + detailOpen ? selectedSession : null + ); + + // Keyboard shortcuts + useInput((input, key) => { + // Help overlay toggle + if (input === '?') { + setShowHelp((prev) => !prev); + return; + } + + // If search is focused, Enter applies draft; Esc handled below + if (isSearchFocused && key.return) { + setSearchQuery(searchDraft); + setIsSearchFocused(false); + return; + } + + // When help overlay is open, only allow closing or quitting + if (showHelp) { + if (key.escape) { + setShowHelp(false); + } + if (input === 'q' && !isSearchFocused && currentView !== 'sessions') { + exit(); + } + return; + } + + // Global quit (disabled in sessions to avoid conflict with Q Chat filter) + if (input === 'q' && !isSearchFocused && currentView !== 'sessions') { + exit(); + return; + } + + // Toggle detail + if (key.return && currentView === 'sessions') { + if (sortedSessions.length > 0 && selectedSession) { + setDetailOpen(true); + setDetailExpanded(false); + setDetailPage(0); + } + return; + } + + if (key.escape) { + if (detailOpen) { + setDetailOpen(false); + setDetailExpanded(false); + setDetailPage(0); + return; + } + } + + // View switching + if (input === '1' && !isSearchFocused) { + setCurrentView('dashboard'); + return; + } + + if (input === '2' && !isSearchFocused) { + setCurrentView('sessions'); + return; + } + + // Search focus - works globally, switches to sessions view if needed + if (input === '/' && !isSearchFocused) { + if (currentView !== 'sessions') { + setCurrentView('sessions'); + } + setIsSearchFocused(true); + return; + } + + if (key.escape) { + if (isSearchFocused) { + setIsSearchFocused(false); + setSearchQuery(''); + setSearchDraft(''); + } + return; + } + + if (key.tab && !isSearchFocused) { + // Tab switches between dashboard and sessions + setCurrentView(prev => prev === 'dashboard' ? 'sessions' : 'dashboard'); + return; + } + + // Sessions view specific shortcuts + if (currentView === 'sessions' && !isSearchFocused) { + // Platform filters + if (input === 'a') { + setSelectedPlatform('all'); + return; + } + if (input === 'c') { + setSelectedPlatform('claude-code'); + return; + } + if (input === 'g') { + setSelectedPlatform('gemini-cli'); + return; + } + if (input === 'x') { + setSelectedPlatform('codex'); + return; + } + if (input === 'q') { + setSelectedPlatform('qchat'); + return; + } + + if (input === 's') { + setSortOrder(prev => (prev === 'recent' ? 'oldest' : prev === 'oldest' ? 'title' : 'recent')); + return; + } + + // Detail expansion toggle + if (input?.toLowerCase() === 'm' && detailOpen) { + const now = Date.now(); + if (now - lastMToggleRef.current > 800) { + lastMToggleRef.current = now; + setDetailExpanded((prev) => !prev); + setDetailPage(0); + } + return; + } + + // Detail paging when expanded + if (detailOpen && detailExpanded) { + if (key.pageDown || input === ']') { + setDetailPage((prev) => prev + 1); + return; + } + if (key.pageUp || input === '[') { + setDetailPage((prev) => Math.max(0, prev - 1)); + return; + } + } + + // Session navigation + if (key.upArrow) { + setSelectedSessionIndex((prev) => Math.max(0, prev - 1)); + return; + } + + if (key.downArrow) { + setSelectedSessionIndex((prev) => Math.min(sessions.length - 1, prev + 1)); + return; + } + + // Page navigation + if (key.pageUp) { + setSelectedSessionIndex((prev) => Math.max(0, prev - 10)); + return; + } + + if (key.pageDown) { + setSelectedSessionIndex((prev) => Math.min(sessions.length - 1, prev + 10)); + return; + } + } + }); + + // Show error if present + if (error) { + return ( + + + + + โŒ + Error loading sessions: + + + {error} + + + + + ); + } + + return ( + + {showHelp && ( + + Help & Shortcuts + q: quit | 1/2: switch views | ?: toggle help + / : focus search | Esc: clear search/unfocus + a/c/g/q/x: filter by platform + s: cycle sort (Recent โ–ธ Oldest โ–ธ Title) + โ†‘ โ†“ : navigate sessions | PageUp/PageDown: fast scroll + Tab: toggle focus + + )} + + + + + + ); +}; + diff --git a/src/tui/components/ActivityHeatmap.tsx b/src/tui/components/ActivityHeatmap.tsx new file mode 100644 index 0000000..d058fc8 --- /dev/null +++ b/src/tui/components/ActivityHeatmap.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { format, getDay } from 'date-fns'; + +interface ActivityHeatmapProps { + activity: Array<{ date: Date; count: number }>; +} + +export const ActivityHeatmap: React.FC = ({ activity }) => { + // Get the last 28 days (4 weeks) + const last28Days = activity.slice(-28); + + // Group by weeks + const weeks: Array> = []; + let currentWeek: Array<{ date: Date; count: number }> = []; + + // Pad the beginning if needed to start on Monday + const firstDayOfWeek = getDay(last28Days[0]?.date || new Date()); + const paddingDays = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1; // Monday = 0 padding + + for (let i = 0; i < paddingDays; i++) { + currentWeek.push({ date: new Date(0), count: -1 }); // Placeholder + } + + // Fill in the actual days + for (const day of last28Days) { + currentWeek.push(day); + + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + } + + // Add remaining days to last week + if (currentWeek.length > 0) { + while (currentWeek.length < 7) { + currentWeek.push({ date: new Date(0), count: -1 }); // Placeholder + } + weeks.push(currentWeek); + } + + // Calculate max count for color intensity + const maxCount = Math.max(...last28Days.map(d => d.count), 1); + + const getBlock = (count: number): string => { + if (count === -1) return ' '; // Placeholder (double-width) + if (count === 0) return 'โ–‘โ–‘'; + + // Color intensity based on count (double-width blocks) + const intensity = count / maxCount; + if (intensity >= 0.75) return 'โ–ˆโ–ˆ'; + if (intensity >= 0.5) return 'โ–“โ–“'; + if (intensity >= 0.25) return 'โ–’โ–’'; + return 'โ–‘โ–‘'; + }; + + const getColor = (count: number): string => { + if (count === -1 || count === 0) return 'gray'; + + const intensity = count / maxCount; + if (intensity >= 0.75) return 'cyan'; + if (intensity >= 0.5) return 'cyan'; + if (intensity >= 0.25) return 'cyan'; + return 'cyan'; + }; + + // Count active days (days with at least one session) + const activeDays = last28Days.filter(d => d.count > 0).length; + + return ( + + {/* Day labels - wider spacing for double-width blocks */} + + M T W T F S S + + + {/* Heatmap grid - double-width blocks */} + + {weeks.map((week, weekIdx) => ( + + {week.map((day, dayIdx) => ( + + {getBlock(day.count)}{dayIdx < 6 ? ' ' : ''} + + ))} + + ))} + + + {/* Compact inline legend */} + + + Legend: โ–ˆโ–ˆ high โ–“โ–“ med โ–’โ–’ low โ–‘โ–‘ none + + + + ); +}; + diff --git a/src/tui/components/Box.tsx b/src/tui/components/Box.tsx new file mode 100644 index 0000000..a0cdc61 --- /dev/null +++ b/src/tui/components/Box.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Box as InkBox, Text } from 'ink'; +import chalk from 'chalk'; + +interface BorderedBoxProps { + title?: string; + children: React.ReactNode; + borderColor?: string; + width?: number | string; + height?: number; + padding?: number; +} + +export const BorderedBox: React.FC = ({ + title, + children, + borderColor = 'cyan', + width, + height, + padding = 1, +}) => { + const colorFn = (chalk as any)[borderColor] || chalk.cyan; + + return ( + + {title && ( + + {title} + + )} + + {children} + + + ); +}; + diff --git a/src/tui/components/MainContent.tsx b/src/tui/components/MainContent.tsx new file mode 100644 index 0000000..746212e --- /dev/null +++ b/src/tui/components/MainContent.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Box } from 'ink'; +import { View } from '../types.js'; +import { DashboardView } from '../views/DashboardView.js'; +import { SessionsView } from '../views/SessionsView.js'; +import { SessionInfo } from '../../platforms/types.js'; +import { SessionStats } from '../types.js'; + +interface MainContentProps { + currentView: View; + sessions: SessionInfo[]; + sortedSessions: SessionInfo[]; + stats: SessionStats | null; + isLoading: boolean; + searchQuery: string; + searchDraft: string; + onSearchChange: (query: string) => void; + onSearchDraftChange: (query: string) => void; + selectedPlatform: string; + onPlatformChange: (platform: string) => void; + selectedSessionIndex: number; + isSearchFocused: boolean; + sortOrder: 'recent' | 'oldest' | 'title'; + detailOpen: boolean; + detailExpanded: boolean; + detailPage: number; + detail?: any; + detailLoading: boolean; + detailError: string | null; +} + +export const MainContent: React.FC = ({ + currentView, + sessions, + sortedSessions, + stats, + isLoading, + searchQuery, + searchDraft, + onSearchChange, + onSearchDraftChange, + selectedPlatform, + onPlatformChange, + selectedSessionIndex, + isSearchFocused, + sortOrder, + detailOpen, + detailExpanded, + detailPage, + detail, + detailLoading, + detailError, +}) => { + return ( + + {currentView === 'dashboard' ? ( + + ) : ( + + )} + + ); +}; + diff --git a/src/tui/components/SearchBar.tsx b/src/tui/components/SearchBar.tsx new file mode 100644 index 0000000..77686f7 --- /dev/null +++ b/src/tui/components/SearchBar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + isFocused: boolean; + placeholder?: string; +} + +export const SearchBar: React.FC = ({ + value, + onChange, + isFocused, + placeholder = 'Search sessions...', +}) => { + return ( + + Search: + + + ); +}; + diff --git a/src/tui/components/SessionList.tsx b/src/tui/components/SessionList.tsx new file mode 100644 index 0000000..b402293 --- /dev/null +++ b/src/tui/components/SessionList.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { SessionInfo } from '../../platforms/types.js'; +import { formatDistanceToNow, format, isToday, isYesterday } from 'date-fns'; +import { getPlatformEmoji, getPlatformName } from '../constants.js'; + +interface SessionListProps { + sessions: SessionInfo[]; + selectedIndex: number; + maxHeight?: number; +} + +export const SessionList: React.FC = ({ + sessions, + selectedIndex, + maxHeight = 10, +}) => { + if (sessions.length === 0) { + return ( + + No sessions found + + ); + } + + // Calculate visible window + const startIdx = Math.max(0, Math.min(selectedIndex - Math.floor(maxHeight / 2), sessions.length - maxHeight)); + const endIdx = Math.min(sessions.length, startIdx + maxHeight); + const visibleSessions = sessions.slice(startIdx, endIdx); + const selected = sessions[selectedIndex]; + + const formatGroupLabel = (date: Date) => { + if (isToday(date)) return 'Today'; + if (isYesterday(date)) return 'Yesterday'; + return format(date, 'MMM d, yyyy'); + }; + + return ( + + {visibleSessions.map((session, idx) => { + const actualIdx = startIdx + idx; + const isSelected = actualIdx === selectedIndex; + const emoji = getPlatformEmoji(session.platform || ''); + const platformName = getPlatformName(session.platform || ''); + const preview = session.firstMessagePreview || session.title || session.id || 'Untitled'; + const timeAgo = formatDistanceToNow(session.lastModified, { addSuffix: true }); + const groupLabel = formatGroupLabel(session.lastModified); + const showGroupHeader = idx === 0 || formatGroupLabel(visibleSessions[idx - 1].lastModified) !== groupLabel; + const tags = session.tags && session.tags.length ? session.tags.slice(0, 3) : []; + + return ( + + {showGroupHeader && ( + + โ”€ {groupLabel} โ”€ + + )} + + + {isSelected ? 'โžค ' : ' '} + {emoji} {preview.slice(0, 60)}{preview.length > 60 ? '...' : ''} + + + + + ๐Ÿ’ฌ {session.messageCount ?? '?'} โ€ข {timeAgo} โ€ข {platformName} + + + {tags.length > 0 && ( + + {tags.map((tag, tagIdx) => ( + [{tag}] + ))} + {session.tags && session.tags.length > tags.length && โ€ฆ} + + )} + {isSelected && ( + + ๐Ÿ“ {session.filePath} + ๐Ÿ“‚ {session.projectPath} + + )} + + ); + })} + + {sessions.length > maxHeight && ( + + Showing {startIdx + 1}-{endIdx} of {sessions.length} sessions ยท PageUp/PageDown to scroll + + )} + {selected && ( + + Selected {selectedIndex + 1}/{sessions.length} ยท {getPlatformName(selected.platform || '')}{' '} + ยท ๐Ÿ’ฌ {selected.messageCount} ยท {formatDistanceToNow(selected.lastModified, { addSuffix: true })} + + )} + + + ); +}; + diff --git a/src/tui/components/Sidebar.tsx b/src/tui/components/Sidebar.tsx new file mode 100644 index 0000000..1553448 --- /dev/null +++ b/src/tui/components/Sidebar.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { View } from '../types.js'; + +interface SidebarProps { + currentView: View; + isAuthenticated: boolean; + userName?: string | null; +} + +const SIDEBAR_WIDTH = 20; +const SIDEBAR_PADDING = 2; // paddingX (left + right) +const SEPARATOR_WIDTH = SIDEBAR_WIDTH - SIDEBAR_PADDING; + +export const Sidebar: React.FC = ({ currentView, isAuthenticated, userName }) => { + const menuItems: Array<{ key: View; label: string; shortcut: string }> = [ + { key: 'dashboard', label: 'Dashboard', shortcut: '1' }, + { key: 'sessions', label: 'Sessions', shortcut: '2' }, + ]; + + return ( + + {/* App Title */} + + + SessionBase + + + + {/* Separator */} + + {'โ”€'.repeat(SEPARATOR_WIDTH)} + + + {/* Navigation Menu with shortcuts */} + + {menuItems.map((item) => { + const isActive = currentView === item.key; + return ( + + + {isActive ? 'โ–ธ ' : ' '} + {item.label} + + [{item.shortcut}] + + ); + })} + + + {/* Separator */} + + {'โ”€'.repeat(SEPARATOR_WIDTH)} + + + {/* Compact Auth Status */} + + + {isAuthenticated ? 'โœ“ ' : 'โœ— '} + @ + {isAuthenticated ? (userName || 'User') : 'Not logged in'} + + + + {/* Separator */} + + {'โ”€'.repeat(SEPARATOR_WIDTH)} + + + {/* Additional Shortcuts */} + + + Shortcuts + + + [/] + Search + + + [q] + Quit + + + + ); +}; + diff --git a/src/tui/components/StatsCard.tsx b/src/tui/components/StatsCard.tsx new file mode 100644 index 0000000..a0f4161 --- /dev/null +++ b/src/tui/components/StatsCard.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +interface StatsCardProps { + label: string; + value: string | number; + color?: string; +} + +export const StatsCard: React.FC = ({ label, value, color = 'cyan' }) => { + // Format large numbers (e.g., 302758 โ†’ "302.8K") + const formatValue = (val: string | number): string => { + if (typeof val === 'string') return val; + + if (val >= 1000000) { + return `${(val / 1000000).toFixed(1)}M`; + } else if (val >= 1000) { + return `${(val / 1000).toFixed(1)}K`; + } + return val.toString(); + }; + + return ( + + {label} + {formatValue(value)} + + ); +}; + diff --git a/src/tui/constants.ts b/src/tui/constants.ts new file mode 100644 index 0000000..ad2d0a9 --- /dev/null +++ b/src/tui/constants.ts @@ -0,0 +1,54 @@ +/** + * Platform configuration for SessionBase TUI + * Defines emojis, labels, and helper functions for supported AI platforms + */ + +export interface PlatformConfig { + key: string; + label: string; + emoji: string; +} + +export const PLATFORMS: PlatformConfig[] = [ + { key: 'all', label: 'All', emoji: '๐Ÿ“‹' }, + { key: 'claude-code', label: 'Claude', emoji: '๐ŸŸ ' }, + { key: 'gemini-cli', label: 'Gemini', emoji: '๐Ÿ”ท' }, + { key: 'qchat', label: 'Q Chat', emoji: '๐Ÿค–' }, + { key: 'codex', label: 'Codex', emoji: '๐Ÿ’œ' }, +]; + +/** + * Get platform emoji by platform key + */ +export function getPlatformEmoji(platform: string): string { + switch (platform) { + case 'claude-code': + return '๐ŸŸ '; + case 'gemini-cli': + return '๐Ÿ”ท'; + case 'qchat': + return '๐Ÿค–'; + case 'codex': + return '๐Ÿ’œ'; + default: + return '๐Ÿ’ฌ'; + } +} + +/** + * Get full platform name by platform key + */ +export function getPlatformName(platform: string): string { + switch (platform) { + case 'claude-code': + return 'Claude Code'; + case 'gemini-cli': + return 'Gemini CLI'; + case 'qchat': + return 'Q Chat'; + case 'codex': + return 'Codex'; + default: + return platform || 'Unknown'; + } +} diff --git a/src/tui/hooks/useSessionData.ts b/src/tui/hooks/useSessionData.ts new file mode 100644 index 0000000..e21e691 --- /dev/null +++ b/src/tui/hooks/useSessionData.ts @@ -0,0 +1,178 @@ +import { useState, useEffect } from 'react'; +import { platformRegistry } from '../../platforms/index.js'; +import { SessionInfo } from '../../platforms/types.js'; +import { SessionStats } from '../types.js'; +import { isAuthenticated } from '../../utils/auth.js'; +import { sessionBaseClient } from '../../api/client.js'; +import { startOfDay, subDays, format } from 'date-fns'; + +interface UseSessionDataOptions { + platform?: string; + searchQuery?: string; +} + +interface UseSessionDataResult { + sessions: SessionInfo[]; + stats: SessionStats | null; + isLoading: boolean; + error: string | null; + isAuth: boolean; + userName: string | null; + refetch: () => void; +} + +export function useSessionData(options: UseSessionDataOptions = {}): UseSessionDataResult { + const [sessions, setSessions] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isAuth, setIsAuth] = useState(false); + const [userName, setUserName] = useState(null); + const [refetchTrigger, setRefetchTrigger] = useState(0); + + useEffect(() => { + let mounted = true; + + async function loadData() { + setIsLoading(true); + setError(null); + + try { + // Check auth status + const authStatus = await isAuthenticated(); + if (mounted) { + setIsAuth(authStatus); + } + + // Fetch user info for display + if (authStatus) { + try { + const info = await sessionBaseClient.getUserInfo(); + const user = + info.user?.username || + info.user?.name || + info.user?.email || + null; + if (mounted) { + setUserName(user); + } + } catch { + // If user info fails, keep going; auth indicator will still show + } + } else { + if (mounted) { + setUserName(null); + } + } + + // Get available providers + const providers = await platformRegistry.getAvailableProviders(); + + // Filter by platform if specified + const targetProviders = options.platform && options.platform !== 'all' + ? providers.filter(p => p.platform === options.platform) + : providers; + + // Fetch sessions from all providers + const allSessions: SessionInfo[] = []; + for (const provider of targetProviders) { + try { + const providerSessions = await provider.listSessions(undefined, true); + allSessions.push(...providerSessions); + } catch (err) { + // Continue with other providers if one fails + console.error(`Failed to load sessions from ${provider.displayName}:`, err); + } + } + + // Filter by search query + let filteredSessions = allSessions; + if (options.searchQuery && options.searchQuery.trim()) { + const query = options.searchQuery.toLowerCase(); + filteredSessions = allSessions.filter(session => { + const preview = session.firstMessagePreview?.toLowerCase() || ''; + const title = session.title?.toLowerCase() || ''; + const projectPath = session.projectPath.toLowerCase(); + return preview.includes(query) || title.includes(query) || projectPath.includes(query); + }); + } + + // Calculate stats + const calculatedStats = calculateStats(allSessions); + + if (mounted) { + setSessions(filteredSessions); + setStats(calculatedStats); + setIsLoading(false); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setIsLoading(false); + } + } + } + + loadData(); + + return () => { + mounted = false; + }; + }, [options.platform, options.searchQuery, refetchTrigger]); + + const refetch = () => { + setRefetchTrigger(prev => prev + 1); + }; + + return { + sessions, + stats, + isLoading, + error, + isAuth, + userName, + refetch, + }; +} + +function calculateStats(sessions: SessionInfo[]): SessionStats { + const sessionsByPlatform: Record = {}; + const activityMap = new Map(); + + // Count sessions by platform + for (const session of sessions) { + const platform = session.platform || 'unknown'; + sessionsByPlatform[platform] = (sessionsByPlatform[platform] || 0) + 1; + + // Track activity by date + const dateKey = format(startOfDay(session.lastModified), 'yyyy-MM-dd'); + activityMap.set(dateKey, (activityMap.get(dateKey) || 0) + 1); + } + + // Convert activity map to array + const recentActivity: Array<{ date: Date; count: number }> = []; + const today = new Date(); + + // Generate last 30 days + for (let i = 29; i >= 0; i--) { + const date = subDays(today, i); + const dateKey = format(startOfDay(date), 'yyyy-MM-dd'); + const count = activityMap.get(dateKey) || 0; + recentActivity.push({ date, count }); + } + + // Find last push date (most recent session) + const lastPushDate = sessions.length > 0 + ? sessions.reduce((latest, session) => + session.lastModified > latest ? session.lastModified : latest + , sessions[0].lastModified) + : undefined; + + return { + totalSessions: sessions.length, + sessionsByPlatform, + recentActivity, + lastPushDate, + }; +} + diff --git a/src/tui/hooks/useSessionDetail.ts b/src/tui/hooks/useSessionDetail.ts new file mode 100644 index 0000000..513f0b3 --- /dev/null +++ b/src/tui/hooks/useSessionDetail.ts @@ -0,0 +1,240 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { platformRegistry } from '../../platforms/index.js'; +import { SessionInfo, SessionData } from '../../platforms/types.js'; + +export interface SessionDetailData { + session: SessionInfo; + title?: string; + tags?: string[]; + messageCount?: number; + previewLines: string[]; + fullLines: string[]; +} + +const PREVIEW_LIMIT = 6; // max messages to show in preview +const PREVIEW_CHARS = 200; // max chars to keep overall +const FULL_PAGE_LINES = 200; // total lines cap (not per page) +const FULL_LINE_TRUNC = 200; // max chars per line +type NormalizedLine = { role: string; text: string }; + +export function useSessionDetail(session: SessionInfo | null) { + const cacheRef = useRef>(new Map()); + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const cacheKey = useMemo(() => session?.filePath || null, [session?.filePath]); + + useEffect(() => { + let cancelled = false; + + async function load() { + if (!session || !cacheKey) { + setDetail(null); + setError(null); + return; + } + + // cache + const cached = cacheRef.current.get(cacheKey); + if (cached) { + setDetail(cached); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const provider = session.platform + ? platformRegistry.getProvider(session.platform as any) + : null; + + if (!provider) { + throw new Error('Unknown platform for this session'); + } + + const parsed: SessionData = await provider.parseSession(session.filePath); + const normalized = normalizeLines(parsed, session.platform); + const previewLines = buildPreviewFromLines(normalized); + const fullLines = buildFullLinesFromLines(normalized); + + const data: SessionDetailData = { + session, + title: parsed.title || session.title, + tags: parsed.tags, + messageCount: parsed.messageCount || parsed.messages?.length || parsed.history?.length, + previewLines, + fullLines, + }; + + cacheRef.current.set(cacheKey, data); + if (!cancelled) { + setDetail(data); + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || 'Failed to load session detail'); + setDetail(null); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + load(); + return () => { + cancelled = true; + }; + }, [cacheKey, session]); + + return { detail, isLoading, error }; +} + +function buildPreviewFromLines(lines: NormalizedLine[]): string[] { + const limited = lines.slice(0, PREVIEW_LIMIT); + let total = 0; + const trimmed: string[] = []; + for (const ln of limited) { + const line = `${ln.role}: ${ln.text}`; + if (total >= PREVIEW_CHARS) break; + const room = PREVIEW_CHARS - total; + const trimmedLine = line.length > room ? line.slice(0, room - 1) + 'โ€ฆ' : line; + trimmed.push(trimmedLine); + total += trimmedLine.length; + } + return trimmed; +} + +function buildFullLinesFromLines(lines: NormalizedLine[]): string[] { + const out: string[] = []; + for (const ln of lines) { + if (out.length >= FULL_PAGE_LINES) break; + const content = ln.text.length > FULL_LINE_TRUNC ? ln.text.slice(0, FULL_LINE_TRUNC - 1) + 'โ€ฆ' : ln.text; + out.push(`${ln.role}: ${content}`); + } + return out; +} + +function normalizeLines(parsed: SessionData, platform?: string): NormalizedLine[] { + if (platform === 'codex') return normalizeCodex(parsed); + if (platform === 'claude-code') return normalizeClaude(parsed); + if (platform === 'gemini-cli') return normalizeGemini(parsed); + if (platform === 'qchat') return normalizeQChat(parsed); + // fallback generic + const lines: NormalizedLine[] = []; + if (parsed.history && Array.isArray(parsed.history)) { + for (const turn of parsed.history) { + const user = Array.isArray(turn) ? turn[0] : (turn as any).user; + const assistant = Array.isArray(turn) ? turn[1] : (turn as any).assistant; + if (user?.content) lines.push({ role: 'user', text: stringifyContent(user.content) }); + if (assistant?.content) lines.push({ role: 'assistant', text: stringifyContent(assistant.content) }); + if (lines.length >= FULL_PAGE_LINES) break; + } + } else if (parsed.messages && Array.isArray(parsed.messages)) { + for (const msg of parsed.messages) { + const role = (msg as any).role || 'message'; + const content = stringifyContent((msg as any).content || (msg as any).text || ''); + lines.push({ role, text: content }); + if (lines.length >= FULL_PAGE_LINES) break; + } + } + return lines; +} + +function normalizeCodex(parsed: SessionData): NormalizedLine[] { + const lines: NormalizedLine[] = []; + const records = parsed.messages || []; + for (const rec of records) { + if (rec && typeof rec === 'object') { + if ((rec as any).type === 'response_item' && (rec as any).payload?.type === 'message') { + const contentArr = (rec as any).payload?.content || []; + const text = extractInputText(contentArr); + if (text) lines.push({ role: (rec as any).payload?.role || 'message', text }); + } else if ((rec as any).type === 'message' && Array.isArray((rec as any).content)) { + const text = extractInputText((rec as any).content); + if (text) lines.push({ role: (rec as any).role || 'message', text }); + } + } + if (lines.length >= FULL_PAGE_LINES) break; + } + return lines; +} + +function normalizeClaude(parsed: SessionData): NormalizedLine[] { + const lines: NormalizedLine[] = []; + const records = parsed.messages || []; + for (const rec of records) { + const msg = (rec as any).message || rec; + const role = msg?.role || (msg?.author && msg.author.role) || 'message'; + const content = msg?.content; + if (content) { + const text = stringifyContent(content); + if (text) lines.push({ role, text }); + } + if (lines.length >= FULL_PAGE_LINES) break; + } + return lines; +} + +function normalizeGemini(parsed: SessionData): NormalizedLine[] { + const lines: NormalizedLine[] = []; + const msgs = parsed.messages || []; + for (const msg of msgs) { + const role = (msg as any).role || 'message'; + const parts = (msg as any).parts; + if (Array.isArray(parts)) { + const textPart = parts.find((p: any) => p?.text)?.text; + if (textPart) lines.push({ role, text: textPart }); + } + if (lines.length >= FULL_PAGE_LINES) break; + } + return lines; +} + +function normalizeQChat(parsed: SessionData): NormalizedLine[] { + const lines: NormalizedLine[] = []; + const history = parsed.history || []; + for (const turn of history) { + const user = Array.isArray(turn) ? turn[0] : (turn as any).user; + const assistant = Array.isArray(turn) ? turn[1] : (turn as any).assistant; + if (user?.content?.Prompt?.prompt) lines.push({ role: 'user', text: user.content.Prompt.prompt }); + if (assistant?.content?.completion) lines.push({ role: 'assistant', text: assistant.content.completion }); + if (lines.length >= FULL_PAGE_LINES) break; + } + return lines; +} + +function stringifyContent(content: any): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + const textArr = content.map((c: any) => c?.text || '').filter(Boolean); + if (textArr.length) return textArr.join(' '); + const inputText = content.find((c: any) => c?.type === 'input_text' && c?.text)?.text; + if (inputText) return inputText; + const plain = content.find((c: any) => typeof c === 'string'); + if (plain) return plain as string; + } + if (content?.text) return content.text; + if (typeof content === 'object') return JSON.stringify(content); + return ''; +} + +function extractInputText(contentArr: any[]): string { + if (!Array.isArray(contentArr)) return ''; + const texts: string[] = []; + for (const c of contentArr) { + if (c?.type === 'input_text' && c?.text) { + texts.push(c.text); + } else if (typeof c === 'string') { + texts.push(c); + } else if (c?.text) { + texts.push(c.text); + } + } + return texts.join(' ').trim(); +} + diff --git a/src/tui/types.ts b/src/tui/types.ts new file mode 100644 index 0000000..e9532f4 --- /dev/null +++ b/src/tui/types.ts @@ -0,0 +1,16 @@ +export type View = 'dashboard' | 'sessions'; + +export interface AppState { + currentView: View; + selectedPlatform: string | 'all'; + searchQuery: string; + isLoading: boolean; +} + +export interface SessionStats { + totalSessions: number; + sessionsByPlatform: Record; + recentActivity: Array<{ date: Date; count: number }>; + lastPushDate?: Date; +} + diff --git a/src/tui/views/DashboardView.tsx b/src/tui/views/DashboardView.tsx new file mode 100644 index 0000000..9a3b766 --- /dev/null +++ b/src/tui/views/DashboardView.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { formatDistanceToNow } from 'date-fns'; +import { SessionStats } from '../types.js'; +import { ActivityHeatmap } from '../components/ActivityHeatmap.js'; +import { SessionInfo } from '../../platforms/types.js'; +import { getPlatformEmoji, getPlatformName } from '../constants.js'; + +interface DashboardViewProps { + stats: SessionStats | null; + isLoading: boolean; + recentSessions?: SessionInfo[]; +} + +export const DashboardView: React.FC = ({ stats, isLoading, recentSessions }) => { + if (isLoading || !stats) { + return ( + + + Loading dashboard... + + + ); + } + + const platformCount = Object.keys(stats.sessionsByPlatform).length; + const recentCount = stats.recentActivity.filter(a => a.count > 0).length; + const latestSession = stats.lastPushDate + ? formatDistanceToNow(stats.lastPushDate, { addSuffix: true }) + : 'โ€”'; + const mostActivePlatformEntry = Object.entries(stats.sessionsByPlatform) + .sort(([, a], [, b]) => b - a)[0]; + const mostActivePlatform = + mostActivePlatformEntry && mostActivePlatformEntry[1] > 0 + ? `${getPlatformName(mostActivePlatformEntry[0])} (${mostActivePlatformEntry[1]})` + : 'โ€”'; + const mostActiveDayEntry = stats.recentActivity + .filter(a => a.count > 0) + .sort((a, b) => b.count - a.count)[0]; + const mostActiveDay = mostActiveDayEntry + ? `${mostActiveDayEntry.count} on ${mostActiveDayEntry.date.toLocaleDateString()}` + : 'โ€”'; + + // Calculate streak (consecutive days with activity) + const calculateStreak = (): number => { + let streak = 0; + const sortedActivity = [...stats.recentActivity].reverse(); // Most recent first + for (const day of sortedActivity) { + if (day.count > 0) { + streak++; + } else if (streak > 0) { + break; // Stop counting once we hit a gap + } + } + return streak; + }; + + // Calculate average sessions per active day + const avgSessionsPerDay = recentCount > 0 + ? (stats.recentActivity.reduce((sum, day) => sum + day.count, 0) / recentCount).toFixed(1) + : '0'; + + const currentStreak = calculateStreak(); + + return ( + + {/* Header with inline stats */} + + + ๐Ÿ“Š Dashboard + + + + Sessions: + {stats.totalSessions} + + | + + Latest: + {latestSession} + + | + + Platforms: + {platformCount} + + | + + Active days: + {recentCount} + + + + + {/* Two-column layout: Left content stack | Right activity */} + + {/* Left Column: Platform Breakdown & Recent Sessions */} + + {/* Platform Breakdown */} + + Platform Breakdown + {Object.keys(stats.sessionsByPlatform).length > 0 ? ( + + {Object.entries(stats.sessionsByPlatform) + .sort(([, a], [, b]) => b - a) + .map(([platform, count]) => { + const name = `${getPlatformEmoji(platform)} ${getPlatformName(platform)}`; + const dots = '.'.repeat(Math.max(2, 35 - name.length - count.toString().length)); + return ( + + {name} {dots} {count} + + ); + })} + + ) : ( + No sessions found + )} + + + {/* Recent Sessions */} + {recentSessions && recentSessions.length > 0 && ( + + Recent Sessions + + {recentSessions.slice(0, 4).map((session) => ( + + + {getPlatformEmoji(session.platform || '')}{' '} + {(session.firstMessagePreview || session.title || 'Untitled').slice(0, 35)} + {(session.firstMessagePreview || session.title || '').length > 35 ? 'โ€ฆ' : ''} + + + {session.messageCount} msgs โ€ข {formatDistanceToNow(session.lastModified, { addSuffix: true })} + + + ))} + + + )} + + + {/* Right Column: Activity Heatmap */} + + Activity (Last 28 days) + + Most active: {mostActivePlatform} + + + Busiest day: {mostActiveDay} + + + + + {/* Additional stats - compact 2-line layout */} + + + ๐Ÿ”ฅ + {recentCount} + active days + {currentStreak > 0 && ( + <> + + โšก + Streak: + {currentStreak} + days + + )} + + + ๐Ÿ“ˆ + Best: + {mostActiveDayEntry?.count || 0} + on {mostActiveDayEntry?.date.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }) || 'โ€”'} + + ๐Ÿ“Š + Avg: + {avgSessionsPerDay} + /day + + + + + + {/* Footer */} + + + Tab Switch View โ€ข q Quit + + + + ); +}; + diff --git a/src/tui/views/SessionsView.tsx b/src/tui/views/SessionsView.tsx new file mode 100644 index 0000000..33ac61d --- /dev/null +++ b/src/tui/views/SessionsView.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { SessionInfo } from '../../platforms/types.js'; +import { SearchBar } from '../components/SearchBar.js'; +import { SessionList } from '../components/SessionList.js'; +import { formatDistanceToNow } from 'date-fns'; +import { PLATFORMS } from '../constants.js'; + +const PAGE_LINES = 20; +const TRUNC_LINE = 120; + +interface SessionsViewProps { + sessions: SessionInfo[]; + isLoading: boolean; + searchQuery: string; + searchDraft: string; + onSearchChange: (query: string) => void; + onSearchDraftChange: (query: string) => void; + selectedPlatform: string; + onPlatformChange: (platform: string) => void; + selectedSessionIndex: number; + isSearchFocused: boolean; + sortOrder: 'recent' | 'oldest' | 'title'; + detailOpen: boolean; + detailExpanded: boolean; + detailPage: number; + detail?: any; + detailLoading: boolean; + detailError: string | null; +} + +export const SessionsView: React.FC = ({ + sessions, + isLoading, + searchQuery, + searchDraft, + onSearchChange, + onSearchDraftChange, + selectedPlatform, + onPlatformChange, + selectedSessionIndex, + isSearchFocused, + sortOrder, + detailOpen, + detailExpanded, + detailPage, + detail, + detailLoading, + detailError, +}) => { + + if (isLoading) { + return ( + + + Loading sessions... + + + ); + } + + return ( + + + Sessions Browser + + Filters: Platform={PLATFORMS.find(p => p.key === selectedPlatform)?.label || 'All'} + {searchQuery ? ยท Query="{searchQuery}" : null} ยท Sort:{' '} + {sortOrder === 'recent' + ? 'Recent โ–ธ' + : sortOrder === 'oldest' + ? 'Oldest โ–ธ' + : 'Title โ–ธ'} + + + + + + / to focus โ€ข Tab to toggle focus + + + + {PLATFORMS.map((platform) => { + const isSelected = selectedPlatform === platform.key; + return ( + + + [{platform.emoji} {platform.label}] + + + ); + })} + + a/c/g/q/x to switch โ€ข s to cycle sort + + + + + + {sessions.length} session{sessions.length !== 1 ? 's' : ''} found + {detailOpen && selectedSessionIndex >= 0 + ? ` โ€ข Viewing ${selectedSessionIndex + 1}/${sessions.length}` + : ''} + + + + {sessions.length > 0 ? ( + + + + + {detailOpen && ( + + Session Details + + m: more/less โ€ข [ / ] or PageUp/PageDown: page transcript โ€ข Esc: close detail + + {detailLoading && ( + Loading detail... + )} + {detailError && Error: {detailError}} + {!detailLoading && !detailError && detail && detail.session && ( + + + {detail.session.platform ? detail.session.platform : 'Platform'}{' '} + | ๐Ÿ’ฌ {detail.messageCount ?? detail.session.messageCount ?? '?'}{' '} + | {formatDistanceToNow(detail.session.lastModified, { addSuffix: true })} + + Path: {detail.session.filePath} + Project: {detail.session.projectPath} + {detail.title && Title: {detail.title}} + {detail.tags && detail.tags.length > 0 && ( + Tags: {detail.tags.join(', ')} + )} + {!detailExpanded && ( + <> + Preview: + {detail.previewLines && detail.previewLines.length > 0 ? ( + detail.previewLines.map((line: string, idx: number) => ( + {line} + )) + ) : ( + No preview available + )} + + )} + {detailExpanded && ( + <> + Transcript: + {renderTranscript(detail, detailPage)} + + )} + + )} + + )} + + ) : ( + + + {searchQuery + ? `No sessions match "${searchQuery}"` + : 'No sessions found. Try running a command to create some sessions!'} + + + )} + + + + โ†‘/โ†“ navigate โ€ข Enter open detail โ€ข Esc close โ€ข / search โ€ข Tab toggle focus โ€ข q to quit (on Dashboard) + + + + ); +}; + +function renderTranscript(detail: any, page: number) { + const lines: string[] = detail?.fullLines || []; + if (!lines.length) { + return No transcript available; + } + + const start = page * PAGE_LINES; + const end = start + PAGE_LINES; + const slice = lines.slice(start, end); + const totalPages = Math.max(1, Math.ceil(lines.length / PAGE_LINES)); + + return ( + + {slice.map((line, idx) => ( + {maybeTrunc(line)} + ))} + + Page {page + 1} / {totalPages} ({lines.length} lines) โ€ข use [ / ] or PageUp/PageDown + + + ); +} + +function maybeTrunc(text: string) { + if (text.length <= TRUNC_LINE) return text; + return text.slice(0, TRUNC_LINE - 1) + 'โ€ฆ'; +} +