mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7c502c2a | ||
|
|
546bbcbe7c | ||
|
|
774d5ff321 | ||
|
|
98ceb5da1d | ||
|
|
34fb5e49f8 | ||
|
|
002cf81b1e | ||
|
|
9b4aab2502 | ||
|
|
829c797726 | ||
|
|
85da3bb898 | ||
|
|
29b2653801 | ||
|
|
7b8b175133 | ||
|
|
c3027b21e1 | ||
|
|
0b231edd43 | ||
|
|
ea1c2ee4ec | ||
|
|
b4a808adcf | ||
|
|
f3cbe9bca6 | ||
|
|
a11bcb0029 | ||
|
|
47fd9a922f | ||
|
|
14f7d8298d | ||
|
|
d32f4debb2 | ||
|
|
3cb7b510f8 | ||
|
|
6a12a72c9c | ||
|
|
f1a126efeb | ||
|
|
12fd780af8 | ||
|
|
fd74a42933 | ||
|
|
7101e64800 | ||
|
|
1b10d9b733 |
No files matched your search
+6
-1
@@ -48,7 +48,12 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
tools/remotion/out/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
tools/
|
||||
scripts/remotion/
|
||||
@@ -1,5 +1,46 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community contributions from @aakhter:
|
||||
- **feat (#66): Tab reorder shortcuts** — `Ctrl+Shift+{` and `Ctrl+Shift+}` move the active session tab left/right, matching WezTerm convention. Order persists across reloads via `saveSessionOrder()`.
|
||||
- **feat (#67): Active tab visibility + Alt+N badges** — active tab now has a bright green border with color-matched glow, and the first 9 tabs display number badges hinting at the `Alt+N` switch shortcut. Badges update on reorder/rerender.
|
||||
- **feat (#68): Clipboard API** — new `POST /api/clipboard` accepting `{text}` broadcasts a `clipboard:write` SSE event; connected browsers attempt `navigator.clipboard.writeText()` with a manual-copy modal fallback when the page isn't focused. Auth-protected via the standard middleware. Useful for pushing snippets from remote sessions to the user's local clipboard.
|
||||
- **fix (#65): Android Shift+key double character** — pressing `Shift+A` on attached Android keyboards no longer produces "AA". Tracks xterm-handled keydown timestamps and skips the orphaned-input listener for 50ms after a real keydown, while still catching Gboard symbol-keyboard inputs (keyCode 229).
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
|
||||
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
|
||||
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix quick-start to resolve linked cases before codeman-cases fallback. `/api/quick-start` was always resolving `caseName` against `CASES_DIR`, ignoring entries in `~/.codeman/linked-cases.json`. Sessions started via quick-start now correctly honour linked external project directories, consistent with regular case routes.
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Community contributions and security hardening:
|
||||
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
|
||||
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
|
||||
- Named Cloudflare tunnel support (PR #58)
|
||||
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
|
||||
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
|
||||
- Content-Disposition filename sanitization to prevent header injection in file downloads
|
||||
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
|
||||
- Static import for execFile in session-routes
|
||||
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
|
||||
- Repo restructure for cleaner GitHub landing page
|
||||
- Mobile logo, expandable history, session resume fixes
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.5.10 (must match `package.json`)
|
||||
**Version**: 0.6.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
@@ -136,7 +136,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`.
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
@@ -150,7 +150,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
|
||||
### Security
|
||||
|
||||
@@ -204,7 +204,7 @@ npx vitest run -t "pattern" # By name (SAFE)
|
||||
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles).
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
## References
|
||||
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
'scripts/remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,11 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.10",
|
||||
"version": "0.6.0",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -15,12 +15,12 @@
|
||||
"dev": "tsx src/index.ts web",
|
||||
"web": "node dist/index.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --config config/vitest.config.ts",
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
|
||||
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
|
||||
'agent-002': [
|
||||
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Usage: node scripts/capture-video-screenshots.mjs
|
||||
* Port: 3198 (static file server)
|
||||
* Output: remotion/public/ (6 PNGs)
|
||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
||||
const PORT = 3198;
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-welcome.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -706,12 +706,12 @@ async function main() {
|
||||
console.log('All 6 screenshots captured!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nOutput files:');
|
||||
console.log(' remotion/public/desktop-welcome.png');
|
||||
console.log(' remotion/public/desktop-claude.png');
|
||||
console.log(' remotion/public/desktop-both-claude.png');
|
||||
console.log(' remotion/public/desktop-both-opencode.png');
|
||||
console.log(' remotion/public/mobile-claude.png');
|
||||
console.log(' remotion/public/mobile-opencode.png');
|
||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Codeman Cloudflare Named Tunnel
|
||||
After=network-online.target codeman-web.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/codeman.yml run codeman
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=process
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman-tunnel-named
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+199
-35
@@ -1,45 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick Cloudflare Tunnel for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
|
||||
# Cloudflare Tunnel manager for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [quick|named] [start|stop|status|url]
|
||||
#
|
||||
# Modes:
|
||||
# quick — Quick tunnel with random trycloudflare.com URL (default)
|
||||
# named — Named tunnel on a fixed hostname (requires setup, see below)
|
||||
#
|
||||
# Environment variables:
|
||||
# CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
|
||||
# CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
|
||||
# CODEMAN_TUNNEL_HOSTNAME — public hostname (e.g. codeman.example.com)
|
||||
#
|
||||
# First-time named tunnel setup:
|
||||
# cloudflared tunnel login
|
||||
# cloudflared tunnel create <tunnel-name>
|
||||
# cloudflared tunnel route dns <tunnel-name> <hostname>
|
||||
# ./scripts/tunnel.sh named setup # writes ~/.cloudflared/<tunnel-name>.yml
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="codeman-tunnel"
|
||||
QUICK_SERVICE="codeman-tunnel"
|
||||
NAMED_SERVICE="codeman-tunnel-named"
|
||||
TUNNEL_NAME="${CLOUDFLARED_TUNNEL_NAME:-codeman}"
|
||||
TUNNEL_HOSTNAME="${CODEMAN_TUNNEL_HOSTNAME:-codeman.example.com}"
|
||||
CODEMAN_PORT="3000"
|
||||
LOG_FILE="$HOME/.codeman/tunnel.log"
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
|
||||
# Install service if not already
|
||||
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
systemctl --user start "$SERVICE"
|
||||
echo "Tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
# Extract the tunnel URL from journal
|
||||
URL=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1)
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 url"
|
||||
fi
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_require_cloudflared() {
|
||||
if ! command -v cloudflared &>/dev/null; then
|
||||
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_cloudflared_bin() {
|
||||
command -v cloudflared
|
||||
}
|
||||
|
||||
_install_service() {
|
||||
local svc_file="$1"
|
||||
local svc_name="$2"
|
||||
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $svc_name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
_install_named_service() {
|
||||
if ! systemctl --user cat "$NAMED_SERVICE" &>/dev/null 2>&1; then
|
||||
# Generate service file with the configured tunnel name
|
||||
sed "s/codeman\.yml/$TUNNEL_NAME.yml/g; s/run codeman/run $TUNNEL_NAME/g" \
|
||||
"$(dirname "$0")/codeman-tunnel-named.service" \
|
||||
> "$HOME/.config/systemd/user/codeman-tunnel-named.service"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $NAMED_SERVICE installed (tunnel: $TUNNEL_NAME)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── named tunnel setup ───────────────────────────────────────────────────────
|
||||
|
||||
_named_setup() {
|
||||
_require_cloudflared
|
||||
|
||||
local creds_dir="$HOME/.cloudflared"
|
||||
local config_file="$creds_dir/$TUNNEL_NAME.yml"
|
||||
# Replace with your tunnel ID (from: cloudflared tunnel list)
|
||||
local tunnel_id="${CLOUDFLARED_TUNNEL_ID:-YOUR_TUNNEL_ID_HERE}"
|
||||
local creds_file="$creds_dir/$tunnel_id.json"
|
||||
|
||||
if [ ! -f "$creds_file" ]; then
|
||||
echo "Credentials not found: $creds_file"
|
||||
echo "Run: cloudflared tunnel create $TUNNEL_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$config_file" <<EOF
|
||||
tunnel: $tunnel_id
|
||||
credentials-file: $creds_file
|
||||
|
||||
ingress:
|
||||
- hostname: $TUNNEL_HOSTNAME
|
||||
service: http://localhost:$CODEMAN_PORT
|
||||
- service: http_status:404
|
||||
EOF
|
||||
|
||||
echo "Config written to $config_file"
|
||||
echo "Tunnel ID: $tunnel_id"
|
||||
echo "Hostname: $TUNNEL_HOSTNAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Add Cloudflare Access policy for $TUNNEL_HOSTNAME (Zero Trust dashboard)"
|
||||
echo " 2. ./scripts/tunnel.sh named start"
|
||||
}
|
||||
|
||||
# ── quick mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_quick_start() {
|
||||
if ! systemctl --user is-active "$QUICK_SERVICE" &>/dev/null; then
|
||||
_install_service "codeman-tunnel.service" "$QUICK_SERVICE"
|
||||
systemctl --user start "$QUICK_SERVICE"
|
||||
echo "Quick tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
local url
|
||||
url=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1)
|
||||
if [ -n "$url" ]; then
|
||||
echo "$url"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 quick url"
|
||||
fi
|
||||
}
|
||||
|
||||
_quick_stop() {
|
||||
systemctl --user stop "$QUICK_SERVICE"
|
||||
echo "Quick tunnel stopped"
|
||||
}
|
||||
|
||||
_quick_status() {
|
||||
systemctl --user status "$QUICK_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
_quick_url() {
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# ── named mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_named_start() {
|
||||
_require_cloudflared
|
||||
if [ ! -f "$HOME/.cloudflared/$TUNNEL_NAME.yml" ]; then
|
||||
echo "Config not found. Run: $0 named setup"
|
||||
exit 1
|
||||
fi
|
||||
if ! systemctl --user is-active "$NAMED_SERVICE" &>/dev/null; then
|
||||
_install_named_service
|
||||
systemctl --user start "$NAMED_SERVICE"
|
||||
echo "Named tunnel starting..."
|
||||
sleep 3
|
||||
fi
|
||||
echo "https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_stop() {
|
||||
systemctl --user stop "$NAMED_SERVICE"
|
||||
echo "Named tunnel stopped"
|
||||
}
|
||||
|
||||
_named_status() {
|
||||
systemctl --user status "$NAMED_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL: https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_enable() {
|
||||
_install_named_service
|
||||
systemctl --user enable "$NAMED_SERVICE"
|
||||
echo "Named tunnel enabled at boot."
|
||||
}
|
||||
|
||||
_named_disable() {
|
||||
systemctl --user disable "$NAMED_SERVICE"
|
||||
echo "Named tunnel disabled."
|
||||
}
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MODE="${1:-quick}"
|
||||
CMD="${2:-start}"
|
||||
|
||||
case "$MODE" in
|
||||
quick)
|
||||
case "$CMD" in
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*) echo "Usage: $0 quick [start|stop|status|url]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$SERVICE"
|
||||
echo "Tunnel stopped"
|
||||
;;
|
||||
status)
|
||||
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
;;
|
||||
url)
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
named)
|
||||
case "$CMD" in
|
||||
start) _named_start ;;
|
||||
stop) _named_stop ;;
|
||||
status) _named_status ;;
|
||||
url) echo "https://$TUNNEL_HOSTNAME" ;;
|
||||
setup) _named_setup ;;
|
||||
enable) _named_enable ;;
|
||||
disable) _named_disable ;;
|
||||
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
# backward compat: no mode prefix → quick tunnel
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|status|url]"
|
||||
echo "Usage: $0 [quick|named] [start|stop|status|url]"
|
||||
echo " $0 named setup # first-time named tunnel configuration"
|
||||
echo " $0 named enable # start at boot"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -530,6 +530,11 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
|
||||
+218
-4
@@ -249,8 +249,28 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Name Prefix Parser
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
|
||||
// Returns { prefix, suffix } or null if name does not match the pattern.
|
||||
function parseSessionPrefix(name) {
|
||||
if (!name) return null;
|
||||
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
|
||||
if (!m) return null;
|
||||
const prefix = m[1];
|
||||
const rest = name.slice(prefix.length);
|
||||
if (rest === "") return { prefix, suffix: "" };
|
||||
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CodemanApp Class — constructor and global state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -615,15 +635,15 @@ class CodemanApp {
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -637,6 +657,16 @@ class CodemanApp {
|
||||
this.closeHelp();
|
||||
}
|
||||
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
@@ -869,6 +899,153 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
// Remove dangerous elements
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
// Strip dangerous attributes from all elements
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialize back via a container
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
const viewer = document.getElementById('responseViewer');
|
||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||
if (!viewer) return;
|
||||
|
||||
const isOpen = viewer.classList.contains('visible');
|
||||
if (isOpen) {
|
||||
viewer.classList.remove('visible');
|
||||
backdrop.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
const data = await res.json();
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
||||
if (!lastResponse) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = await termRes.json();
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = termData.terminalBuffer
|
||||
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[()][A-Z0-9]/g, '')
|
||||
.replace(/\x1b[>=<]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
|
||||
// Reset state for fresh open
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (title) title.textContent = 'Last Response';
|
||||
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
|
||||
|
||||
viewer.classList.add('visible');
|
||||
backdrop.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFullContext() {
|
||||
if (!this.activeSessionId) return;
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (moreBtn) moreBtn.textContent = '...';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
||||
const data = await res.json();
|
||||
const messages = data.messages || [];
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rv-message';
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
||||
div.appendChild(role);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'rv-text';
|
||||
text.innerHTML = this._renderMarkdown(msg.text);
|
||||
div.appendChild(text);
|
||||
|
||||
body.appendChild(div);
|
||||
}
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} catch (err) {
|
||||
console.error('Failed to load context:', err);
|
||||
} finally {
|
||||
if (moreBtn) moreBtn.textContent = 'More';
|
||||
}
|
||||
}
|
||||
|
||||
async _onSessionNeedsRefresh() {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
@@ -1585,6 +1762,17 @@ class CodemanApp {
|
||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||
|
||||
// Inject tab-number badge if missing (added after initial render)
|
||||
if (!tab.querySelector('.tab-number')) {
|
||||
const idx = this.sessionOrder.indexOf(id);
|
||||
if (idx >= 0 && idx < 9) {
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'tab-number';
|
||||
numSpan.textContent = String(idx + 1);
|
||||
tab.insertBefore(numSpan, tab.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status indicator
|
||||
const statusEl = tab.querySelector('.tab-status');
|
||||
if (statusEl && !statusEl.classList.contains(status)) {
|
||||
@@ -1594,7 +1782,12 @@ class CodemanApp {
|
||||
// Update name if changed
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl && nameEl.textContent !== name) {
|
||||
nameEl.textContent = name;
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (_p && _p.suffix) {
|
||||
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Update task badge
|
||||
@@ -1672,6 +1865,7 @@ class CodemanApp {
|
||||
// Reorder to put active tab first
|
||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||
}
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
@@ -1698,11 +1892,12 @@ class CodemanApp {
|
||||
|
||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
</span>
|
||||
@@ -1711,6 +1906,7 @@ class CodemanApp {
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
@@ -1929,6 +2125,24 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
moveActiveTabRight() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Lifecycle — select, close, navigate
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -292,6 +292,9 @@ const SSE_EVENTS = {
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
|
||||
// Clipboard
|
||||
CLIPBOARD_WRITE: 'clipboard:write',
|
||||
|
||||
// Cases
|
||||
CASE_CREATED: 'case:created',
|
||||
CASE_LINKED: 'case:linked',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="google" content="notranslate">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="apple-touch-icon" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>Codeman</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||
@@ -26,6 +29,7 @@
|
||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
@@ -92,6 +96,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -331,6 +336,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
<span id="responseViewerTitle">Last Response</span>
|
||||
<div class="response-viewer-actions">
|
||||
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
|
||||
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response-viewer-body" id="responseViewerBody"></div>
|
||||
</div>
|
||||
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
|
||||
|
||||
<!-- Bottom Toolbar -->
|
||||
<footer class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -459,10 +477,9 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
|
||||
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
@@ -659,7 +676,10 @@
|
||||
<div class="form-section-header">Appearance</div>
|
||||
<div class="form-row">
|
||||
<label>Session Name</label>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
|
||||
</div>
|
||||
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
{
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a"
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Hide header brand on phones */
|
||||
/* Compact header brand on phones — acts as home button */
|
||||
.header-brand {
|
||||
display: none;
|
||||
padding-right: 0.25rem;
|
||||
margin-right: 0.2rem;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Font controls - compact on phones, visibility controlled by JS */
|
||||
@@ -1189,6 +1195,20 @@ html.mobile-init .file-browser-panel {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Response viewer — show eye icon in header on mobile */
|
||||
.btn-response-viewer-header {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
|
||||
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||
${expandIcon}
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -3241,4 +3246,68 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _onClipboardWrite(data) {
|
||||
const text = data?.text;
|
||||
if (typeof text !== 'string') return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
|
||||
} catch {
|
||||
this._showClipboardFallback(text);
|
||||
}
|
||||
},
|
||||
|
||||
_showClipboardFallback(text) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = 'background:#1e1e2e;border:1px solid #444;border-radius:8px;padding:16px;max-width:600px;width:90%;max-height:60vh;display:flex;flex-direction:column;gap:12px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
|
||||
const title = document.createElement('span');
|
||||
title.style.cssText = 'color:#cdd6f4;font-weight:600';
|
||||
title.textContent = 'Clipboard (browser blocked auto-copy)';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.style.cssText = 'background:none;border:none;color:#cdd6f4;font-size:18px;cursor:pointer';
|
||||
closeBtn.textContent = '\u00d7';
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.readOnly = true;
|
||||
textarea.style.cssText = 'background:#181825;color:#cdd6f4;border:1px solid #555;border-radius:4px;padding:8px;font-family:monospace;font-size:13px;resize:none;height:200px;width:100%';
|
||||
textarea.value = text;
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.style.cssText = 'background:#89b4fa;color:#1e1e2e;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;font-weight:600';
|
||||
copyBtn.textContent = 'Copy to Clipboard';
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(textarea);
|
||||
modal.appendChild(copyBtn);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
copyBtn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
overlay.remove();
|
||||
} catch {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
this.showToast('Copied (fallback)', 'success');
|
||||
overlay.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => overlay.remove();
|
||||
closeBtn.onclick = close;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||
},
|
||||
});
|
||||
@@ -287,8 +287,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// Use the newly created case data (API returns { success, case: { name, path } })
|
||||
caseData = createCaseData.case;
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -425,14 +425,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseData = await caseRes.json();
|
||||
let caseData = await caseRes.json();
|
||||
|
||||
// Create the case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -596,8 +610,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
// Populate session name input
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
// Populate session name input with prefix/suffix split
|
||||
const _modalParsed = parseSessionPrefix(session.name);
|
||||
const _prefixEl = document.getElementById('modalSessionPrefix');
|
||||
if (_modalParsed) {
|
||||
_prefixEl.textContent = _modalParsed.prefix + ': ';
|
||||
_prefixEl.style.display = '';
|
||||
document.getElementById('modalSessionName').value = _modalParsed.suffix;
|
||||
document.getElementById('modalSessionName').placeholder = 'Add description...';
|
||||
} else {
|
||||
_prefixEl.style.display = 'none';
|
||||
_prefixEl.textContent = '';
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||
}
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
@@ -644,7 +670,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async saveSessionName() {
|
||||
if (!this.editingSessionId) return;
|
||||
const name = document.getElementById('modalSessionName').value.trim();
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||
let name;
|
||||
if (parsed) {
|
||||
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
|
||||
} else {
|
||||
name = inputVal;
|
||||
}
|
||||
try {
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||
} catch (err) {
|
||||
@@ -864,29 +898,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!tabName) return;
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.innerHTML = '';
|
||||
|
||||
// If prefix detected, show it as non-editable label
|
||||
if (parsed) {
|
||||
const prefixLabel = document.createElement('span');
|
||||
prefixLabel.textContent = parsed.prefix + ': ';
|
||||
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
||||
tabName.appendChild(prefixLabel);
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name || '';
|
||||
input.placeholder = currentName;
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = async () => {
|
||||
const newName = input.value.trim();
|
||||
tabName.textContent = newName || originalContent;
|
||||
const suffix = input.value.trim();
|
||||
let fullName;
|
||||
if (parsed) {
|
||||
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
|
||||
} else {
|
||||
fullName = suffix;
|
||||
}
|
||||
tabName.textContent = fullName || originalContent;
|
||||
|
||||
if (newName && newName !== session.name) {
|
||||
if (fullName !== session.name) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName })
|
||||
body: JSON.stringify({ name: fullName })
|
||||
});
|
||||
} catch (err) {
|
||||
tabName.textContent = originalContent;
|
||||
|
||||
+311
-13
@@ -258,15 +258,16 @@ body {
|
||||
}
|
||||
|
||||
.session-tab.active {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
background: rgba(34, 197, 94, 0.15) !important;
|
||||
border: 2px solid #00FF66 !important;
|
||||
color: #fff !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||
.session-tab.tab-glow {
|
||||
animation: tab-glow 0.35s ease-out forwards;
|
||||
animation: tab-glow 0.35s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-glow {
|
||||
@@ -304,13 +305,36 @@ body {
|
||||
.session-tab[data-color="pink"] { border-left: 3px solid var(--session-pink); }
|
||||
|
||||
/* Active tabs with color get subtle background tint */
|
||||
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.1); }
|
||||
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.1); }
|
||||
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.1); }
|
||||
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.1); }
|
||||
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.1); }
|
||||
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.1); }
|
||||
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.1); }
|
||||
.session-tab.active[data-color="red"] { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.5); box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); }
|
||||
.session-tab.active[data-color="orange"] { background: rgba(249, 115, 22, 0.15); border-color: rgba(249, 115, 22, 0.5); box-shadow: 0 0 10px rgba(249, 115, 22, 0.3); }
|
||||
.session-tab.active[data-color="yellow"] { background: rgba(234, 179, 8, 0.15); border-color: rgba(234, 179, 8, 0.5); box-shadow: 0 0 10px rgba(234, 179, 8, 0.3); }
|
||||
.session-tab.active[data-color="green"] { background: rgba(34, 197, 94, 0.15); border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 10px rgba(34, 197, 94, 0.3); }
|
||||
.session-tab.active[data-color="blue"] { background: rgba(59, 130, 246, 0.15); border-color: rgba(59, 130, 246, 0.5); box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
|
||||
.session-tab.active[data-color="purple"] { background: rgba(168, 85, 247, 0.15); border-color: rgba(168, 85, 247, 0.5); box-shadow: 0 0 10px rgba(168, 85, 247, 0.3); }
|
||||
.session-tab.active[data-color="pink"] { background: rgba(236, 72, 153, 0.15); border-color: rgba(236, 72, 153, 0.5); box-shadow: 0 0 10px rgba(236, 72, 153, 0.3); }
|
||||
|
||||
/* Tab number indicator (Alt+N shortcut hint) */
|
||||
.session-tab .tab-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.session-tab.active .tab-number {
|
||||
background: rgba(0, 255, 102, 0.2) !important;
|
||||
border-color: rgba(0, 255, 102, 0.5) !important;
|
||||
color: #00FF66 !important;
|
||||
}
|
||||
|
||||
.session-tab .tab-info {
|
||||
display: flex;
|
||||
@@ -330,6 +354,15 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-tab .tab-prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-tab .tab-suffix {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||
.session-tab .tab-folder {
|
||||
font-size: 0.6rem;
|
||||
@@ -2224,6 +2257,24 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), color var(--transition-smooth);
|
||||
}
|
||||
|
||||
.history-show-more:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
@@ -5485,7 +5536,12 @@ kbd {
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.header-brand {
|
||||
display: none;
|
||||
padding-right: 0.4rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
@@ -6556,6 +6612,22 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.file-tree-download {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
padding: 0 4px;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-tree-item:hover .file-tree-download {
|
||||
display: inline;
|
||||
}
|
||||
.file-tree-download:hover {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* File tree indent levels */
|
||||
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
|
||||
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
|
||||
@@ -7741,6 +7813,232 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Hidden on desktop — only shown on mobile via mobile.css override */
|
||||
.btn-response-viewer-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85vh;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
z-index: 5000;
|
||||
flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
.response-viewer.visible {
|
||||
display: flex;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.response-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.response-viewer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.response-viewer-more {
|
||||
background: #2a2a4a;
|
||||
border: 1px solid #444;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.response-viewer-more:active {
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
.response-viewer-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Conversation thread messages */
|
||||
.rv-message {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
}
|
||||
|
||||
.rv-message:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.rv-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rv-role-user {
|
||||
color: #5c7cfa;
|
||||
}
|
||||
|
||||
.rv-role-assistant {
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer */
|
||||
.rv-text {
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rv-text p {
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.rv-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 1em 0 0.4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rv-text h1 { font-size: 1.3em; }
|
||||
.rv-text h2 { font-size: 1.15em; }
|
||||
.rv-text h3 { font-size: 1.05em; }
|
||||
|
||||
.rv-text code {
|
||||
background: #2a2a3e;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rv-text pre {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.rv-text pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rv-text ul, .rv-text ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.rv-text li {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.rv-text blockquote {
|
||||
border-left: 3px solid #444;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.3em 0 0.3em 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rv-text strong {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rv-text a {
|
||||
color: #5c7cfa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rv-text table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.6em 0;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rv-text th, .rv-text td {
|
||||
border: 1px solid #333;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rv-text th {
|
||||
background: #2a2a3e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.rv-text hr {
|
||||
border: none;
|
||||
border-top: 1px solid #333;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.response-viewer-body:empty::after {
|
||||
content: 'No response yet';
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.response-viewer-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 4999;
|
||||
}
|
||||
|
||||
.response-viewer-backdrop.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
CJK IME Input
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
+91
-16
@@ -1,30 +1,105 @@
|
||||
/**
|
||||
* @fileoverview Service worker for Web Push notifications.
|
||||
* @fileoverview Service worker for PWA install + Web Push notifications.
|
||||
*
|
||||
* Receives push events from the Codeman server (via web-push library) and displays
|
||||
* OS-level notifications. Handles notification clicks to focus an existing Codeman
|
||||
* tab or open a new one. Supports action buttons, per-session deep linking, and
|
||||
* critical notification persistence (requireInteraction).
|
||||
* App-shell caching: on install, precaches the core UI assets so the app
|
||||
* launches instantly and works offline (or on flaky connections). Uses a
|
||||
* network-first strategy for navigation and API calls, cache-first for
|
||||
* static assets.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
|
||||
* service worker takes control immediately without waiting for tab refresh.
|
||||
* Push notifications: receives push events from the Codeman server (via
|
||||
* web-push library) and displays OS-level notifications. Handles notification
|
||||
* clicks to focus an existing Codeman tab or open a new one.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate -- ensures the
|
||||
* latest service worker takes control immediately without waiting for tab
|
||||
* refresh.
|
||||
*
|
||||
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
|
||||
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
|
||||
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
// Codeman Service Worker — Web Push notifications
|
||||
// This service worker receives push events from the server and displays OS-level notifications.
|
||||
// It also handles notification clicks to focus or open the Codeman tab.
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/styles.css',
|
||||
'/mobile.css',
|
||||
'/constants.js',
|
||||
'/app.js',
|
||||
'/api-client.js',
|
||||
'/terminal-ui.js',
|
||||
'/session-ui.js',
|
||||
'/settings-ui.js',
|
||||
'/panels-ui.js',
|
||||
'/notification-manager.js',
|
||||
'/mobile-handlers.js',
|
||||
'/keyboard-accessory.js',
|
||||
'/voice-input.js',
|
||||
'/vendor/xterm.min.js',
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// Use addAll but don't fail install if some assets 404 (hashed filenames)
|
||||
return Promise.allSettled(
|
||||
APP_SHELL.map((url) => cache.add(url).catch(() => {}))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --- Activate: clean old caches, claim clients ---
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fetch: network-first with cache fallback ---
|
||||
// Network-first ensures deploys take effect immediately when online.
|
||||
// Cache is only used when the network is unavailable (offline/flaky).
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Skip non-GET, WebSocket upgrades, and SSE streams
|
||||
if (request.method !== 'GET') return;
|
||||
if (request.headers.get('upgrade') === 'websocket') return;
|
||||
if (request.headers.get('accept') === 'text/event-stream') return;
|
||||
if (request.url.includes('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
|
||||
// --- Push notifications ---
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
@@ -40,8 +115,8 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
@@ -75,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// No existing tab — open a new one
|
||||
// No existing tab -- open a new one
|
||||
return self.clients.openWindow(targetUrl);
|
||||
})
|
||||
);
|
||||
|
||||
+165
-52
@@ -77,9 +77,96 @@ Object.assign(CodemanApp.prototype, {
|
||||
// during composition, causing duplicate or garbled input.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||
|
||||
// Let Alt+digit pass through to browser (tab switching)
|
||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
// which the inner application recognizes as "insert newline" vs carriage return.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
if (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
this._localEchoOverlay?.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (text) {
|
||||
this._pendingInput += text;
|
||||
flushInput();
|
||||
}
|
||||
setTimeout(() => {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}, text ? 80 : 0);
|
||||
} else {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Android virtual keyboard fix: catch non-composition input events.
|
||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
||||
// to ignore the keydown. xterm.js expects the character to arrive via
|
||||
// composition events, but since there's no composition, the character is lost.
|
||||
// This listener catches those orphaned input events and forwards them to onData.
|
||||
{
|
||||
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
||||
let composing = false;
|
||||
let lastKeydownHandled = 0;
|
||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
||||
// Track when xterm handles a keydown normally (non-229 keyCode).
|
||||
// If xterm processed the keydown, it will emit onData itself --
|
||||
// the input event handler below must NOT re-send the character.
|
||||
xtermTextarea.addEventListener('keydown', (e) => {
|
||||
if (!e.isComposing && e.keyCode !== 229) {
|
||||
lastKeydownHandled = Date.now();
|
||||
}
|
||||
});
|
||||
xtermTextarea.addEventListener('input', (e) => {
|
||||
// Only handle insertText events outside of composition -- these are
|
||||
// the ones xterm.js misses on Android virtual keyboards.
|
||||
if (composing || e.isComposing) return;
|
||||
if (e.inputType !== 'insertText' || !e.data) return;
|
||||
// If xterm just handled a keydown (within 50ms), it already sent the
|
||||
// char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
|
||||
if (Date.now() - lastKeydownHandled < 50) return;
|
||||
// xterm.js may have already processed this via its own input handler.
|
||||
// Check if the textarea was cleared by xterm (value is empty or just
|
||||
// whitespace) -- if so, xterm handled it and we should not double-send.
|
||||
// Use a microtask to check after xterm's own handlers have run.
|
||||
const data = e.data;
|
||||
Promise.resolve().then(() => {
|
||||
// If xterm cleared the textarea, it processed the input -- skip.
|
||||
const val = xtermTextarea.value;
|
||||
if (!val || val.trim() === '') return;
|
||||
// xterm didn't process it -- forward to terminal as if typed.
|
||||
// Emit via onData path by writing to terminal's input handler.
|
||||
this.terminal._core.coreService.triggerDataEvent(data, true);
|
||||
// Clear the textarea to prevent xterm from processing it later.
|
||||
xtermTextarea.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
@@ -734,84 +821,110 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
|
||||
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
|
||||
* Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
|
||||
* Uses projectKey for grouping because workingDir decoding is lossy.
|
||||
* @returns {Promise<Array>} deduplicated session list, most recent first
|
||||
*/
|
||||
async _fetchHistorySessions(limit = 12) {
|
||||
async _fetchHistorySessions() {
|
||||
const res = await fetch('/api/history/sessions');
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
const byDir = new Map();
|
||||
const byProject = new Map();
|
||||
for (const s of sessions) {
|
||||
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
|
||||
byDir.get(s.workingDir).push(s);
|
||||
const key = s.projectKey || s.workingDir;
|
||||
if (!byProject.has(key)) byProject.set(key, []);
|
||||
byProject.get(key).push(s);
|
||||
}
|
||||
const items = [];
|
||||
for (const [, group] of byDir) {
|
||||
items.push(...group.slice(0, 2));
|
||||
for (const [, group] of byProject) {
|
||||
items.push(...group.slice(0, 3));
|
||||
}
|
||||
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
return items.slice(0, limit);
|
||||
return items;
|
||||
},
|
||||
|
||||
/** Build a single history item DOM element */
|
||||
_buildHistoryItem(s) {
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
return item;
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
async loadHistorySessions() {
|
||||
const container = document.getElementById('historySessions');
|
||||
const list = document.getElementById('historyList');
|
||||
if (!container || !list) return;
|
||||
|
||||
try {
|
||||
const display = await this._fetchHistorySessions(12);
|
||||
if (display.length === 0) {
|
||||
const allSessions = await this._fetchHistorySessions(30);
|
||||
if (allSessions.length === 0) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build DOM safely (no innerHTML with user data)
|
||||
list.replaceChildren();
|
||||
for (const s of display) {
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
// Render initial items
|
||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||
list.appendChild(this._buildHistoryItem(allSessions[i]));
|
||||
}
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
list.appendChild(item);
|
||||
// Add "Show More" button if there are more items
|
||||
if (allSessions.length > initialCount) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
for (let i = initialCount; i < allSessions.length; i++) {
|
||||
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
|
||||
}
|
||||
moreBtn.remove();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* marked v15.0.7 - a markdown parser
|
||||
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
|
||||
* https://github.com/markedjs/marked
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegexLine truncated
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @fileoverview Clipboard routes.
|
||||
* Accepts text via POST and broadcasts to connected browsers for clipboard write.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
|
||||
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.post('/api/clipboard', async (req) => {
|
||||
const body = req.body as { text?: string; sessionId?: string };
|
||||
const text = body?.text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return { success: false, error: 'Missing or empty "text" field' };
|
||||
}
|
||||
ctx.broadcast(SseEvent.ClipboardWrite, {
|
||||
text,
|
||||
sessionId: body.sessionId ?? null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
// Serve raw file content (for images/binary files)
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (!filePath) {
|
||||
@@ -292,6 +292,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
|
||||
@@ -15,4 +15,5 @@ export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -52,6 +54,9 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
|
||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||||
@@ -182,6 +187,36 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
// "No conversation found" errors from Claude CLI.
|
||||
let validatedResumeId = body.resumeSessionId;
|
||||
if (validatedResumeId) {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let found = false;
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(sessionFile);
|
||||
if (stat.size > 4000) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist in this project dir
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir doesn't exist
|
||||
}
|
||||
if (!found) {
|
||||
console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`);
|
||||
validatedResumeId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
@@ -203,7 +238,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: body.resumeSessionId,
|
||||
resumeSessionId: validatedResumeId,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -494,6 +529,46 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
|
||||
// Map key names to hex byte sequences
|
||||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||||
'S-Enter': ['0a'], // \n (line feed)
|
||||
'C-Enter': ['0a'], // \n (line feed)
|
||||
};
|
||||
const hex = KEY_HEX_MAP[key];
|
||||
if (!hex) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Resize Terminal ==========
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||
@@ -505,6 +580,117 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let transcriptText = '';
|
||||
let transcriptTimestamp = '';
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Search from end for last assistant message with text
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const textBlocks = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text);
|
||||
if (textBlocks.length > 0) {
|
||||
transcriptText = textBlocks.join('\n\n');
|
||||
transcriptTimestamp = entry.timestamp || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
if (transcriptText) break; // Found it, stop scanning directories
|
||||
} catch {
|
||||
// File doesn't exist in this project dir, continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// projects dir doesn't exist
|
||||
}
|
||||
|
||||
// If ?context=full, return all user+assistant messages for conversation view
|
||||
const query = req.query as { context?: string };
|
||||
if (query.context === 'full' && transcriptText) {
|
||||
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: (entry.message.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
// Skip system/command messages
|
||||
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
|
||||
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const text = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text)
|
||||
.join('\n\n');
|
||||
if (text) {
|
||||
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (allMessages.length > 0) break;
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
|
||||
}
|
||||
|
||||
return {
|
||||
text: transcriptText,
|
||||
timestamp: transcriptTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Get Terminal Buffer ==========
|
||||
|
||||
// Query params:
|
||||
@@ -737,12 +923,23 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
const linkedCasePath = linkedCases[caseName];
|
||||
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
|
||||
if (!existsSync(casePath)) {
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
@@ -916,7 +1113,7 @@ export function registerSessionRoutes(
|
||||
text.length < 8
|
||||
)
|
||||
continue;
|
||||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
|
||||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '\u2026' : text;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
}
|
||||
@@ -924,6 +1121,74 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
*
|
||||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||||
*
|
||||
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
|
||||
* joined by '_' or '-' to find an existing child directory, then recurse.
|
||||
* E.g. for segments [AI, project, Mirror] inside /Workspace:
|
||||
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
|
||||
*/
|
||||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||||
const segments = encoded.split('-');
|
||||
|
||||
const isDir = async (p: string): Promise<boolean> =>
|
||||
fs
|
||||
.stat(p)
|
||||
.then((s) => s.isDirectory())
|
||||
.catch(() => false);
|
||||
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
// Try progressively longer child names by joining segments with '_' or '-'
|
||||
let matched = false;
|
||||
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
||||
const maxLook = Math.min(i + 4, segments.length);
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
// Build candidate child name from segments[i..end]
|
||||
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
} else {
|
||||
// Build with underscores between joined segments
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
// Build with dashes (literal)
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
}
|
||||
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
current = candidate;
|
||||
i = end + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// No directory match found — append as-is and move on
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExists = await fs
|
||||
.access(current)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return finalExists ? current : process.env.HOME || '/tmp';
|
||||
}
|
||||
|
||||
/** Read the first 16KB of a file for content sniffing. */
|
||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
@@ -974,15 +1239,11 @@ export function registerSessionRoutes(
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. The encoding replaces '/' with '-',
|
||||
// which is lossy when path components contain '-'. Do naive decode first,
|
||||
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
||||
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
||||
const dirExists = await fs
|
||||
.access(naiveDecode)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||
// verify which decoded path actually exists on disk.
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
@@ -1004,24 +1265,31 @@ export function registerSessionRoutes(
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
if (fileStat.size < 50000) {
|
||||
if (
|
||||
!head ||
|
||||
(!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"'))
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
|
||||
// For large files, head may not contain user messages (e.g. /init followed
|
||||
// by large system entries). Check the tail as well.
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
|
||||
if (!foundContent) continue; // No conversation content — skip
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
registerRespawnRoutes,
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerClipboardRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
@@ -650,6 +651,7 @@ export class WebServer extends EventEmitter {
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx);
|
||||
}
|
||||
|
||||
@@ -319,6 +319,11 @@ export const OrchestratorCompleted = 'orchestrator:completed' as const;
|
||||
/** Orchestrator error. */
|
||||
export const OrchestratorError = 'orchestrator:error' as const;
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Clipboard content pushed to browser. */
|
||||
export const ClipboardWrite = 'clipboard:write' as const;
|
||||
|
||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New case directory created. */
|
||||
@@ -486,6 +491,9 @@ export const SseEvent = {
|
||||
OrchestratorCompleted,
|
||||
OrchestratorError,
|
||||
|
||||
// Clipboard
|
||||
ClipboardWrite,
|
||||
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
|
||||
|
||||
```bash
|
||||
# Run all mobile tests
|
||||
npx vitest run --config mobile-test/vitest.config.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Run a single test file
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/keyboard.test.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
|
||||
|
||||
# Quick mode — 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npx vitest run --config mobile-test/vitest.config.ts
|
||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Full device matrix only (135 devices)
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/device-matrix.test.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||
|
||||
# Update visual baselines (delete old baselines, re-run)
|
||||
rm -rf mobile-test/snapshots/*.png
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts
|
||||
rm -rf test/mobile/snapshots/*.png
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
|
||||
```
|
||||
|
||||
## Test Files
|
||||
@@ -126,7 +126,7 @@ Swipe simulation includes intermediate move points and timing to satisfy `SwipeH
|
||||
|
||||
Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison:
|
||||
|
||||
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked)
|
||||
- **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
|
||||
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
|
||||
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
|
||||
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -96,7 +96,7 @@ export const SUBAGENT = {
|
||||
export const VISUAL = {
|
||||
DEFAULT_THRESHOLD: 0.1,
|
||||
MAX_DIFF_PERCENT: 0.5,
|
||||
SNAPSHOT_DIR: 'mobile-test/snapshots',
|
||||
SNAPSHOT_DIR: 'test/mobile/snapshots',
|
||||
} as const;
|
||||
|
||||
// Visual regression breakpoints (width values for screenshot comparison)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -166,19 +166,19 @@ describe('Visual Regression', () => {
|
||||
* To update visual baselines:
|
||||
*
|
||||
* 1. Delete the specific snapshot:
|
||||
* rm mobile-test/snapshots/<name>.png
|
||||
* rm test/mobile/snapshots/<name>.png
|
||||
*
|
||||
* 2. Re-run the test:
|
||||
* npx vitest run mobile-test/visual-regression.test.ts
|
||||
* npx vitest run test/mobile/visual-regression.test.ts
|
||||
*
|
||||
* 3. Review the new baseline:
|
||||
* The test will create a fresh .png and pass (isNewBaseline = true).
|
||||
*
|
||||
* To update ALL baselines:
|
||||
* rm -rf mobile-test/snapshots/ && npx vitest run mobile-test/visual-regression.test.ts
|
||||
* rm -rf test/mobile/snapshots/ && npx vitest run test/mobile/visual-regression.test.ts
|
||||
*
|
||||
* Diff images for failed comparisons are saved as <name>.diff.png
|
||||
* alongside the baseline in mobile-test/snapshots/.
|
||||
* alongside the baseline in test/mobile/snapshots/.
|
||||
*/
|
||||
it('placeholder — see comments above for baseline update instructions', () => {
|
||||
expect(true).toBe(true);
|
||||
@@ -1,10 +1,14 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['mobile-test/**/*.test.ts'],
|
||||
include: ['test/mobile/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
fileParallelism: false,
|
||||
testTimeout: 60_000,
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB |
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user