mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
feat: UI improvements - buttons, monitor panel, system stats
- Redesign Run Claude button with purple gradient - Redesign Run Shell button with dark green gradient - Add + button to create new cases from toolbar - Add Create Case modal for quick case creation - Add Kill All modal with two options (tabs only vs full kill) - Add CPU/Memory progress bars with color gradients - Make monitor panel detachable as floating draggable window - Monitor panel resizable when detached (min 350x250) - Default CLAUDE.md template now uses /home/arkon/default/CLAUDE.md - Streamline CLAUDE.md documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,20 +33,6 @@ npx vitest run test/session.test.ts # Single file
|
||||
npx vitest run -t "should create session" # By pattern
|
||||
```
|
||||
|
||||
### Test Files
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `session.test.ts` | Session creation, PTY modes, token tracking |
|
||||
| `respawn-controller.test.ts` | State machine transitions, config updates |
|
||||
| `scheduled-runs.test.ts` | Timed runs, iteration cleanup |
|
||||
| `quick-start.test.ts` | Case creation + session startup |
|
||||
| `sse-events.test.ts` | Event broadcasting, client reconnection |
|
||||
| `integration-flows.test.ts` | Multi-step workflows |
|
||||
| `edge-cases.test.ts` | Error handling, boundary conditions |
|
||||
| `session-cleanup.test.ts` | Process termination, buffer management |
|
||||
| `pty-interactive.test.ts` | Terminal resize, input handling |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -64,7 +50,7 @@ src/
|
||||
├── types.ts # All TypeScript interfaces
|
||||
├── web/
|
||||
│ ├── server.ts # Fastify REST API + SSE + session restoration
|
||||
│ └── public/ # Static frontend files
|
||||
│ └── public/ # Static frontend files (vanilla JS, xterm.js)
|
||||
└── templates/
|
||||
└── claude-md.ts # CLAUDE.md generator for new cases
|
||||
```
|
||||
@@ -78,43 +64,19 @@ src/
|
||||
|
||||
### Key Components
|
||||
|
||||
- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot execution, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal` events. Maintains terminal buffer for reconnections. Includes buffer management for long-running sessions (12-24+ hours) with automatic trimming. Tracks input/output tokens and supports auto-clear at configurable threshold.
|
||||
- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal` events.
|
||||
|
||||
- **TaskTracker** (`src/task-tracker.ts`): Detects Claude's background Task tool usage from JSON output. Builds a tree of parent-child task relationships. Emits `taskCreated`, `taskUpdated`, `taskCompleted`, `taskFailed` events. Used by Session to track background work.
|
||||
- **RespawnController** (`src/respawn-controller.ts`): State machine that keeps interactive sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → repeats.
|
||||
|
||||
- **RespawnController** (`src/respawn-controller.ts`): State machine that keeps interactive sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → repeats. Configurable timeouts, prompts, and step toggles.
|
||||
- **ScreenManager** (`src/screen-manager.ts`): Manages GNU screen sessions for persistent terminals. Screens survive server restarts.
|
||||
|
||||
- **RalphLoop** (`src/ralph-loop.ts`): Autonomous task assignment controller. Monitors sessions for idle state, assigns tasks from queue, detects completion via `<promise>PHRASE</promise>` markers. Supports time-aware loops with minimum duration.
|
||||
|
||||
- **WebServer** (`src/web/server.ts`): Fastify server with REST API + SSE. Manages sessions, scheduled runs, respawn controllers, and case directories. Broadcasts all events to connected clients. Restores screen sessions on startup.
|
||||
|
||||
- **ScreenManager** (`src/screen-manager.ts`): Manages GNU screen sessions for persistent terminals. Tracks screens in `~/.claudeman/screens.json`. Provides process stats (memory, CPU, children) and reconciliation for dead screens. Screens survive server restarts.
|
||||
|
||||
### Type Definitions
|
||||
|
||||
All TypeScript interfaces are centralized in `src/types.ts`:
|
||||
- `SessionState`, `TaskState`, `RalphLoopState` - Core state types
|
||||
- `RespawnConfig`, `AppConfig` - Configuration types
|
||||
- `ApiErrorCode`, `createErrorResponse()` - Consistent API error handling
|
||||
- Request/Response types for API endpoints (`CreateSessionRequest`, `QuickStartResponse`, etc.)
|
||||
- **WebServer** (`src/web/server.ts`): Fastify server with REST API + SSE. All endpoints under `/api/`. See file for full route list.
|
||||
|
||||
### Session Modes
|
||||
|
||||
**One-Shot Mode** (`runPrompt(prompt)`):
|
||||
- Execute a single prompt and receive completion event
|
||||
- Used for scheduled runs and quick API calls
|
||||
- Session exits after prompt completes
|
||||
|
||||
**Interactive Mode** (`startInteractive()`):
|
||||
- Persistent PTY terminal with full Claude CLI access
|
||||
- Supports terminal resize for proper formatting
|
||||
- Terminal buffer persisted for client reconnections
|
||||
- Works with RespawnController for autonomous cycling
|
||||
|
||||
**Shell Mode** (`startShell()`):
|
||||
- Plain bash/zsh terminal without Claude
|
||||
- Useful for running commands alongside Claude sessions
|
||||
- Same PTY features (resize, buffer persistence)
|
||||
- **One-Shot** (`runPrompt(prompt)`): Execute single prompt, receive completion event, session exits
|
||||
- **Interactive** (`startInteractive()`): Persistent PTY terminal with resize support, buffer persistence
|
||||
- **Shell** (`startShell()`): Plain bash/zsh terminal without Claude
|
||||
|
||||
## Code Patterns
|
||||
|
||||
@@ -132,13 +94,11 @@ const msg = JSON.parse(cleanLine) as ClaudeMessage;
|
||||
|
||||
### PTY Spawn Modes
|
||||
|
||||
**One-shot mode** (prompt execution with JSON output for token tracking):
|
||||
```typescript
|
||||
// One-shot mode (JSON output for token tracking)
|
||||
pty.spawn('claude', ['-p', '--dangerously-skip-permissions', '--output-format', 'stream-json', prompt], { ... })
|
||||
```
|
||||
|
||||
**Interactive mode** (persistent terminal, tokens parsed from status line):
|
||||
```typescript
|
||||
// Interactive mode (tokens parsed from status line)
|
||||
pty.spawn('claude', ['--dangerously-skip-permissions'], { ... })
|
||||
```
|
||||
|
||||
@@ -146,44 +106,10 @@ pty.spawn('claude', ['--dangerously-skip-permissions'], { ... })
|
||||
|
||||
Session detects idle by watching for prompt character (`❯` or `\u276f`) and waiting 2 seconds without activity. RespawnController uses the same patterns plus spinner characters to detect working state.
|
||||
|
||||
### Long-Running Session Support
|
||||
### Token Tracking
|
||||
|
||||
Sessions are optimized for 12-24+ hour runs with automatic buffer management:
|
||||
|
||||
**Buffer Limits:**
|
||||
- Terminal buffer: 5MB max, trims to 4MB when exceeded
|
||||
- Text output: 2MB max, trims to 1.5MB when exceeded
|
||||
- Messages: 1000 max, keeps most recent 800 when exceeded
|
||||
- Line buffer: 64KB max with 100ms periodic flush (prevents unbounded growth from long lines)
|
||||
- Completed tasks: 100 max in TaskTracker (auto-removes oldest)
|
||||
- Respawn terminal buffer: 1MB max, trims to 512KB
|
||||
|
||||
**Performance Optimizations:**
|
||||
- Server-side terminal batching at 60fps (16ms intervals)
|
||||
- SSE event batching: `session:output` at 50ms, `task:updated` at 100ms
|
||||
- Client-side requestAnimationFrame batching for smooth rendering
|
||||
- Frontend `renderSessionTabs()` debounced at 100ms
|
||||
- Parallel screen stats fetching via `Promise.all()`
|
||||
- Configurable xterm scrollback (default 5000 lines)
|
||||
- Buffer statistics available via session details for monitoring
|
||||
|
||||
**Automatic Cleanup:**
|
||||
- Scheduled runs auto-deleted after 1 hour of completion
|
||||
- Sessions cleaned up after each scheduled run iteration (prevents leaks)
|
||||
|
||||
**Buffer Stats Response:**
|
||||
```typescript
|
||||
{
|
||||
bufferStats: {
|
||||
terminalBufferSize: number; // Current terminal buffer size in bytes
|
||||
textOutputSize: number; // Current text output size in bytes
|
||||
messageCount: number; // Number of parsed messages
|
||||
maxTerminalBuffer: number; // Max allowed terminal buffer
|
||||
maxTextOutput: number; // Max allowed text output
|
||||
maxMessages: number; // Max allowed messages
|
||||
}
|
||||
}
|
||||
```
|
||||
- **One-shot mode**: Uses `--output-format stream-json` for detailed token usage from JSON
|
||||
- **Interactive mode**: Parses tokens from Claude's status line (e.g., "123.4k tokens"), estimates 60/40 input/output split
|
||||
|
||||
### Respawn Controller State Machine
|
||||
|
||||
@@ -191,174 +117,48 @@ Sessions are optimized for 12-24+ hour runs with automatic buffer management:
|
||||
WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING
|
||||
```
|
||||
|
||||
Default config (`RespawnConfig` in `src/types.ts`):
|
||||
- `idleTimeoutMs`: 5000 (5s after prompt)
|
||||
- `updatePrompt`: "update all the docs and CLAUDE.md"
|
||||
- `interStepDelayMs`: 1000 (1s between steps)
|
||||
- `sendClear`: true (send /clear after update)
|
||||
- `sendInit`: true (send /init after /clear)
|
||||
|
||||
### Token Tracking & Auto-Clear
|
||||
|
||||
Session tracks input/output tokens differently depending on mode:
|
||||
|
||||
**One-shot mode (`runPrompt`)**: Uses `--output-format stream-json` to get JSON output with detailed token usage from `msg.message.usage.input_tokens` and `output_tokens`.
|
||||
|
||||
**Interactive mode (`startInteractive`)**: Parses tokens from Claude's status line display (e.g., "123.4k tokens"). Since only total is shown, estimates 60/40 input/output split.
|
||||
|
||||
```typescript
|
||||
{
|
||||
tokens: {
|
||||
input: number; // Total input tokens used
|
||||
output: number; // Total output tokens used
|
||||
total: number; // Combined total
|
||||
},
|
||||
autoClear: {
|
||||
enabled: boolean; // Whether auto-clear is active
|
||||
threshold: number; // Token threshold (default 100000)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, auto-clear waits for idle state, sends `/clear`, and resets token counts.
|
||||
|
||||
### Screen Session Initialization
|
||||
|
||||
GNU screen creates blank space at the top when initializing a session. This is handled after attaching to the screen:
|
||||
GNU screen creates blank space at top when initializing. After attaching:
|
||||
- **Claude sessions**: Clear buffer, emit `clearTerminal` event, client clears xterm
|
||||
- **Shell sessions**: Clear buffer, send `clear\n` command
|
||||
|
||||
- **Claude sessions**: After 100ms, clears terminal buffer and emits `clearTerminal` event. Client receives `session:clearTerminal` via SSE and clears/resets its xterm.
|
||||
- **Shell sessions**: After 100ms, clears terminal buffer and sends `clear\n` command to the shell.
|
||||
|
||||
### Tab Switching
|
||||
|
||||
When switching between Claude session tabs, the terminal buffer may have been rendered at a different terminal size. To fix the "squished" display:
|
||||
### Tab Switching Fix
|
||||
|
||||
When switching Claude session tabs, terminal may be rendered at wrong size. Fix sequence:
|
||||
1. Clear and reset xterm
|
||||
2. Write the terminal buffer
|
||||
2. Write terminal buffer
|
||||
3. Send resize to update PTY dimensions
|
||||
4. Send Ctrl+L (`\x0c`) to trigger Claude CLI to redraw at the correct size
|
||||
|
||||
This only applies to Claude sessions (not shell sessions) since Claude CLI responds to Ctrl+L by redrawing its interface.
|
||||
4. Send Ctrl+L (`\x0c`) to trigger Claude CLI redraw
|
||||
|
||||
### SSE Events
|
||||
|
||||
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
|
||||
|
||||
Event categories (prefixes): `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events include `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `respawn:stateChanged`. See `src/web/server.ts` for the full event catalog.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
REST API served by Fastify at `src/web/server.ts`. All endpoints are under `/api/`.
|
||||
|
||||
**Sessions:**
|
||||
- `GET /api/sessions` - List all sessions
|
||||
- `POST /api/sessions` - Create session `{ workingDir, mode?, name? }`
|
||||
- `GET /api/sessions/:id` - Get session details (includes bufferStats)
|
||||
- `GET /api/sessions/:id/output` - Get text output buffer
|
||||
- `DELETE /api/sessions/:id` - Kill and remove session
|
||||
- `DELETE /api/sessions` - Kill all sessions
|
||||
- `PUT /api/sessions/:id/name` - Rename session `{ name }`
|
||||
- `POST /api/sessions/:id/interactive` - Start interactive Claude terminal
|
||||
- `POST /api/sessions/:id/shell` - Start shell terminal (no Claude)
|
||||
- `POST /api/sessions/:id/input` - Send input to session `{ input }`
|
||||
- `POST /api/sessions/:id/resize` - Resize terminal `{ cols, rows }`
|
||||
- `POST /api/sessions/:id/run` - Run one-shot prompt `{ prompt }`
|
||||
- `GET /api/sessions/:id/terminal` - Get terminal buffer
|
||||
|
||||
**Respawn Controller:**
|
||||
- `GET /api/sessions/:id/respawn` - Get respawn state
|
||||
- `POST /api/sessions/:id/respawn/start` - Start respawn `{ config? }`
|
||||
- `POST /api/sessions/:id/respawn/stop` - Stop respawn
|
||||
- `PUT /api/sessions/:id/respawn/config` - Update config
|
||||
- `POST /api/sessions/:id/respawn/enable` - Enable on running session `{ config?, durationMinutes? }`
|
||||
- `POST /api/sessions/:id/auto-clear` - Configure auto-clear `{ enabled, threshold? }`
|
||||
|
||||
**Cases & Quick Start:**
|
||||
- `GET /api/cases` - List cases in `~/claudeman-cases/`
|
||||
- `POST /api/cases` - Create case `{ name, description? }`
|
||||
- `GET /api/cases/:name` - Get case info
|
||||
- `POST /api/quick-start` - Create case + interactive session `{ caseName? }`
|
||||
|
||||
**Scheduled Runs:**
|
||||
- `GET /api/scheduled` - List scheduled runs
|
||||
- `POST /api/scheduled` - Create scheduled run `{ prompt, workingDir?, durationMinutes }`
|
||||
- `GET /api/scheduled/:id` - Get run status
|
||||
- `DELETE /api/scheduled/:id` - Cancel run
|
||||
|
||||
**Screen Management:**
|
||||
- `GET /api/screens` - List screen sessions with stats
|
||||
- `DELETE /api/screens/:sessionId` - Kill screen session
|
||||
- `POST /api/screens/reconcile` - Clean up dead screens
|
||||
- `POST /api/screens/stats/start` - Start resource monitoring
|
||||
- `POST /api/screens/stats/stop` - Stop resource monitoring
|
||||
|
||||
**System:**
|
||||
- `GET /api/system/stats` - Get CPU and memory usage `{ cpu, memory: { usedMB, totalMB, percent } }`
|
||||
|
||||
**Other:**
|
||||
- `GET /api/events` - SSE stream for real-time updates
|
||||
- `GET /api/status` - Full state snapshot
|
||||
- `GET /api/settings` - Get app settings
|
||||
- `PUT /api/settings` - Update settings
|
||||
- `POST /api/run` - Quick run prompt without creating persistent session `{ prompt, workingDir? }`
|
||||
|
||||
## E2E Testing with agent-browser
|
||||
|
||||
For UI testing, use [agent-browser](https://github.com/vercel-labs/agent-browser). A full E2E test plan is documented in `.claude/skills/e2e-test.md`.
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
npx agent-browser install # Download Chromium (one-time)
|
||||
npx tsx src/index.ts web & # Start server
|
||||
|
||||
# Basic test flow
|
||||
npx agent-browser open http://localhost:3000
|
||||
npx agent-browser snapshot # Get accessibility tree with element refs
|
||||
npx agent-browser click @e5 # Click by element ref
|
||||
npx agent-browser find text "Run Claude" click # Or use semantic locators
|
||||
npx agent-browser screenshot /tmp/test.png
|
||||
npx agent-browser close
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
The web UI (`src/web/public/`) uses vanilla JavaScript with:
|
||||
- **xterm.js**: Terminal emulator with configurable scrollback (default 5000 lines)
|
||||
- **xterm-addon-fit**: Auto-resize terminal to container
|
||||
- **Server-Sent Events**: Real-time updates from `/api/events`
|
||||
- **System Stats**: CPU and memory usage displayed in header (2s polling)
|
||||
- **No build step**: Static files served directly by Fastify
|
||||
|
||||
Key files:
|
||||
- `app.js` - Main application logic, SSE handling, session management
|
||||
- `index.html` - Single page with embedded styles
|
||||
- `styles.css` - All CSS styles
|
||||
- Libraries loaded from CDN (xterm.js, addons)
|
||||
Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events: `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `respawn:stateChanged`.
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### New API Endpoint
|
||||
|
||||
1. Add types to `src/types.ts` (request/response interfaces)
|
||||
2. Add route in `src/web/server.ts` within the `buildServer()` function
|
||||
3. Follow existing patterns: use `createErrorResponse()` for errors
|
||||
1. Add types to `src/types.ts`
|
||||
2. Add route in `src/web/server.ts` within `buildServer()`
|
||||
3. Use `createErrorResponse()` for errors
|
||||
|
||||
### New SSE Event
|
||||
|
||||
1. Add event type constant in `src/web/server.ts` (see `broadcast()` calls)
|
||||
2. Emit from appropriate component (Session, RespawnController, etc.)
|
||||
3. Handle in `src/web/public/app.js` `handleSSEEvent()` switch
|
||||
1. Emit from component via `broadcast()` in server.ts
|
||||
2. Handle in `src/web/public/app.js` `handleSSEEvent()` switch
|
||||
|
||||
### New Session Event
|
||||
|
||||
1. Add to `SessionEvents` interface in `src/session.ts`
|
||||
2. Emit in `src/session.ts` via `this.emit()`
|
||||
2. Emit via `this.emit()`
|
||||
3. Subscribe in `src/web/server.ts` when wiring session to SSE
|
||||
4. Handle in `src/web/public/app.js` SSE event listener
|
||||
4. Handle in frontend SSE listener
|
||||
|
||||
## Notes
|
||||
|
||||
- State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json`
|
||||
- Cases are created in `~/claudeman-cases/` by default
|
||||
- Sessions are wrapped in GNU screen for persistence across server restarts
|
||||
- Tests use vitest with mocking via `vi.mock()` - no real Claude CLI spawned
|
||||
- Cases created in `~/claudeman-cases/` by default
|
||||
- Sessions wrapped in GNU screen for persistence across server restarts
|
||||
- Tests use vitest with `vi.mock()` - no real Claude CLI spawned
|
||||
- Long-running sessions (12-24+ hours) supported with automatic buffer trimming
|
||||
- E2E testing available via agent-browser (see `.claude/skills/e2e-test.md`)
|
||||
|
||||
+190
-21
@@ -1470,6 +1470,92 @@ class ClaudemanApp {
|
||||
this.toggleMonitorPanel();
|
||||
}
|
||||
|
||||
// ========== Monitor Panel Detach & Drag ==========
|
||||
|
||||
toggleMonitorDetach() {
|
||||
const panel = document.getElementById('monitorPanel');
|
||||
const detachBtn = document.getElementById('monitorDetachBtn');
|
||||
|
||||
if (panel.classList.contains('detached')) {
|
||||
// Re-attach to bottom
|
||||
panel.classList.remove('detached');
|
||||
panel.style.top = '';
|
||||
panel.style.left = '';
|
||||
panel.style.width = '';
|
||||
panel.style.height = '';
|
||||
if (detachBtn) {
|
||||
detachBtn.innerHTML = '↗'; // Detach icon
|
||||
detachBtn.title = 'Detach panel';
|
||||
}
|
||||
} else {
|
||||
// Detach as floating window
|
||||
panel.classList.add('detached');
|
||||
panel.classList.add('open'); // Ensure it's visible
|
||||
if (detachBtn) {
|
||||
detachBtn.innerHTML = '↙'; // Attach icon
|
||||
detachBtn.title = 'Attach panel';
|
||||
}
|
||||
// Setup drag functionality
|
||||
this.setupMonitorDrag();
|
||||
}
|
||||
}
|
||||
|
||||
setupMonitorDrag() {
|
||||
const panel = document.getElementById('monitorPanel');
|
||||
const header = document.getElementById('monitorPanelHeader');
|
||||
|
||||
if (!panel || !header) return;
|
||||
|
||||
let isDragging = false;
|
||||
let startX, startY, startLeft, startTop;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
// Only drag from header, not from buttons
|
||||
if (e.target.closest('button')) return;
|
||||
if (!panel.classList.contains('detached')) return;
|
||||
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
|
||||
let newLeft = startLeft + dx;
|
||||
let newTop = startTop + dy;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const rect = panel.getBoundingClientRect();
|
||||
newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft));
|
||||
newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop));
|
||||
|
||||
panel.style.left = newLeft + 'px';
|
||||
panel.style.top = newTop + 'px';
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
// Remove existing listeners before adding new ones
|
||||
header.removeEventListener('mousedown', header._dragHandler);
|
||||
header._dragHandler = onMouseDown;
|
||||
header.addEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
|
||||
renderTaskPanel() {
|
||||
const session = this.sessions.get(this.activeSessionId);
|
||||
const body = document.getElementById('backgroundTasksBody');
|
||||
@@ -1561,32 +1647,103 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
async killAllSessions() {
|
||||
killAllSessions() {
|
||||
const count = this.screenSessions?.length || 0;
|
||||
if (count === 0) {
|
||||
alert('No sessions to kill');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(`Kill all ${count} session(s)? This cannot be undone.`);
|
||||
if (!confirmed) return;
|
||||
// Show the kill all modal
|
||||
document.getElementById('killAllCount').textContent = count;
|
||||
document.getElementById('killAllModal').classList.add('active');
|
||||
}
|
||||
|
||||
closeKillAllModal() {
|
||||
document.getElementById('killAllModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async confirmKillAll(killScreens) {
|
||||
this.closeKillAllModal();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sessions', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// Clear local state
|
||||
if (killScreens) {
|
||||
// Kill everything including screens
|
||||
const res = await fetch('/api/sessions', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.sessions.clear();
|
||||
this.screenSessions = [];
|
||||
this.activeSessionId = null;
|
||||
this.renderSessionTabs();
|
||||
this.renderScreenSessions();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this.toast('All sessions and screens killed', 'success');
|
||||
}
|
||||
} else {
|
||||
// Just remove tabs, keep screens running
|
||||
this.sessions.clear();
|
||||
this.screenSessions = [];
|
||||
this.activeSessionId = null;
|
||||
this.renderSessionTabs();
|
||||
this.renderScreenSessions();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this.toast('All tabs removed, screens still running', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to kill all sessions:', err);
|
||||
alert('Failed to kill sessions: ' + err.message);
|
||||
console.error('Failed to kill sessions:', err);
|
||||
this.toast('Failed to kill sessions: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Create Case Modal ==========
|
||||
|
||||
showCreateCaseModal() {
|
||||
document.getElementById('newCaseName').value = '';
|
||||
document.getElementById('newCaseDescription').value = '';
|
||||
document.getElementById('createCaseModal').classList.add('active');
|
||||
document.getElementById('newCaseName').focus();
|
||||
}
|
||||
|
||||
closeCreateCaseModal() {
|
||||
document.getElementById('createCaseModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async createCase() {
|
||||
const name = document.getElementById('newCaseName').value.trim();
|
||||
const description = document.getElementById('newCaseDescription').value.trim();
|
||||
|
||||
if (!name) {
|
||||
this.toast('Please enter a case name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
this.toast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.toast(`Case "${name}" created`, 'success');
|
||||
// Reload cases and select the new one
|
||||
await this.loadQuickStartCases();
|
||||
document.getElementById('quickStartCase').value = name;
|
||||
this.updateDirDisplayForCase(name);
|
||||
} else {
|
||||
this.toast(data.error || 'Failed to create case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create case:', err);
|
||||
this.toast('Failed to create case: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1701,26 +1858,38 @@ class ClaudemanApp {
|
||||
|
||||
updateSystemStatsDisplay(stats) {
|
||||
const cpuEl = document.getElementById('statCpu');
|
||||
const cpuBar = document.getElementById('statCpuBar');
|
||||
const memEl = document.getElementById('statMem');
|
||||
const memBar = document.getElementById('statMemBar');
|
||||
|
||||
if (cpuEl) {
|
||||
cpuEl.textContent = `CPU: ${stats.cpu}%`;
|
||||
// Color based on usage
|
||||
if (cpuEl && cpuBar) {
|
||||
cpuEl.textContent = `${stats.cpu}%`;
|
||||
cpuBar.style.width = `${Math.min(100, stats.cpu)}%`;
|
||||
|
||||
// Color classes based on usage
|
||||
cpuBar.classList.remove('medium', 'high');
|
||||
cpuEl.classList.remove('high');
|
||||
if (stats.cpu > 80) {
|
||||
cpuBar.classList.add('high');
|
||||
cpuEl.classList.add('high');
|
||||
} else {
|
||||
cpuEl.classList.remove('high');
|
||||
} else if (stats.cpu > 50) {
|
||||
cpuBar.classList.add('medium');
|
||||
}
|
||||
}
|
||||
|
||||
if (memEl) {
|
||||
if (memEl && memBar) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
memEl.textContent = `Mem: ${memGB}GB`;
|
||||
// Color based on usage
|
||||
memEl.textContent = `${memGB}G`;
|
||||
memBar.style.width = `${Math.min(100, stats.memory.percent)}%`;
|
||||
|
||||
// Color classes based on usage
|
||||
memBar.classList.remove('medium', 'high');
|
||||
memEl.classList.remove('high');
|
||||
if (stats.memory.percent > 80) {
|
||||
memBar.classList.add('high');
|
||||
memEl.classList.add('high');
|
||||
} else {
|
||||
memEl.classList.remove('high');
|
||||
} else if (stats.memory.percent > 50) {
|
||||
memBar.classList.add('medium');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
-11
@@ -36,8 +36,20 @@
|
||||
</div>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||
<span class="stat-cpu" id="statCpu">CPU: --</span>
|
||||
<span class="stat-mem" id="statMem">Mem: --</span>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">CPU</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statCpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">MEM</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">⚙</button>
|
||||
</div>
|
||||
@@ -74,7 +86,7 @@
|
||||
<div class="toolbar-left">
|
||||
<!-- Run Claude -->
|
||||
<div class="toolbar-group">
|
||||
<button class="btn-toolbar btn-primary" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
|
||||
<button class="btn-toolbar btn-claude" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
|
||||
Run Claude
|
||||
</button>
|
||||
<div class="tab-count-group" title="Number of tabs to open (1-10)">
|
||||
@@ -82,12 +94,15 @@
|
||||
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="10" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
|
||||
</div>
|
||||
<button class="btn-toolbar" onclick="app.runShell()" title="Run Shell">
|
||||
Shell
|
||||
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
||||
Run Shell
|
||||
</button>
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<div class="case-select-group">
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory -->
|
||||
@@ -117,7 +132,7 @@
|
||||
<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</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Screens</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>
|
||||
@@ -130,14 +145,16 @@
|
||||
|
||||
<!-- Monitor Panel (combined Process Monitor + Background Tasks) -->
|
||||
<div class="monitor-panel" id="monitorPanel">
|
||||
<div class="monitor-panel-header">
|
||||
<div class="monitor-panel-header" id="monitorPanelHeader">
|
||||
<span>Monitor</span>
|
||||
<div class="monitor-panel-actions">
|
||||
<button class="btn-icon-sm btn-danger-sm" onclick="app.killAllSessions()" title="Kill all sessions">✕</button>
|
||||
<button class="btn-icon-sm btn-danger-sm" onclick="app.killAllSessions()" title="Kill all sessions and their screen processes">✕</button>
|
||||
<button class="btn-icon-sm" onclick="app.reconcileScreens()" title="Refresh screens">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleMonitorDetach()" title="Detach/Attach panel" id="monitorDetachBtn">↗</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleMonitorPanel()" title="Toggle panel" id="monitorToggleBtn">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitor-resize-handle" id="monitorResizeHandle"></div>
|
||||
<div class="monitor-panel-body">
|
||||
<div class="monitor-section">
|
||||
<div class="monitor-section-header">
|
||||
@@ -312,6 +329,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Case Modal -->
|
||||
<div class="modal" id="createCaseModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Case</h3>
|
||||
<button class="modal-close" onclick="app.closeCreateCaseModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+">
|
||||
<span class="form-hint">Letters, numbers, hyphens, underscores only</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.createCase()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kill All Confirmation Modal -->
|
||||
<div class="modal" id="killAllModal">
|
||||
<div class="modal-backdrop" onclick="app.closeKillAllModal()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Kill All Sessions</h3>
|
||||
<button class="modal-close" onclick="app.closeKillAllModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-warning-text">Choose how to close <span id="killAllCount">0</span> session(s):</p>
|
||||
</div>
|
||||
<div class="close-options">
|
||||
<button class="close-option" onclick="app.confirmKillAll(false)">
|
||||
<span class="close-option-title">Remove All Tabs</span>
|
||||
<span class="close-option-desc">Screen sessions keep running in background</span>
|
||||
</button>
|
||||
<button class="close-option close-option-danger" onclick="app.confirmKillAll(true)">
|
||||
<span class="close-option-title">Kill All Claude + Screens</span>
|
||||
<span class="close-option-desc">Terminate everything completely</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-footer-cancel">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.closeKillAllModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Button -->
|
||||
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
|
||||
</div>
|
||||
|
||||
+185
-6
@@ -190,7 +190,7 @@ body {
|
||||
.header-system-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.5rem;
|
||||
@@ -198,11 +198,64 @@ body {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.header-system-stats span {
|
||||
white-space: nowrap;
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.header-system-stats .high {
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
width: 40px;
|
||||
height: 6px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-bar-cpu {
|
||||
background: linear-gradient(90deg, var(--green) 0%, var(--green) 100%);
|
||||
}
|
||||
|
||||
.stat-bar-cpu.medium {
|
||||
background: linear-gradient(90deg, var(--green) 0%, var(--yellow) 100%);
|
||||
}
|
||||
|
||||
.stat-bar-cpu.high {
|
||||
background: linear-gradient(90deg, var(--yellow) 0%, var(--red) 100%);
|
||||
}
|
||||
|
||||
.stat-bar-mem {
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
}
|
||||
|
||||
.stat-bar-mem.medium {
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--yellow) 100%);
|
||||
}
|
||||
|
||||
.stat-bar-mem.high {
|
||||
background: linear-gradient(90deg, var(--yellow) 0%, var(--red) 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.stat-value.high {
|
||||
color: var(--red);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -482,6 +535,47 @@ body {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Claude Button - Special styling */
|
||||
.btn-toolbar.btn-claude {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.4rem 0.85rem;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-claude:hover {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-claude .btn-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Shell Button - Special styling */
|
||||
.btn-toolbar.btn-shell {
|
||||
background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%);
|
||||
border-color: #2d5a2d;
|
||||
color: #8fbc8f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell:hover {
|
||||
background: linear-gradient(135deg, #234023 0%, #2d5a2d 100%);
|
||||
border-color: #3d7a3d;
|
||||
box-shadow: 0 0 8px rgba(34, 139, 34, 0.4);
|
||||
color: #a8d8a8;
|
||||
}
|
||||
|
||||
/* Button icon styling */
|
||||
.btn-toolbar .btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-success {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
@@ -523,12 +617,17 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Case select group with + button */
|
||||
.case-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
padding: 0.4rem 1.5rem 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
@@ -548,6 +647,29 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-case-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--green);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-case-add:hover {
|
||||
background: var(--green);
|
||||
color: white;
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
@@ -1528,6 +1650,39 @@ kbd {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Detached floating state */
|
||||
.monitor-panel.detached {
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
top: 100px;
|
||||
left: calc(50% - 300px);
|
||||
width: 600px;
|
||||
height: 500px;
|
||||
max-height: none;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
transform: none;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
min-width: 350px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.monitor-panel.detached .monitor-panel-header {
|
||||
cursor: move;
|
||||
background: linear-gradient(180deg, var(--bg-hover) 0%, var(--bg-card) 100%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.monitor-panel.detached .monitor-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.monitor-panel.detached .monitor-section-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.monitor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1551,6 +1706,30 @@ kbd {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Resize handle for detached panel */
|
||||
.monitor-resize-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--border-light) 50%);
|
||||
border-radius: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.monitor-resize-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--text-muted);
|
||||
border-bottom: 2px solid var(--text-muted);
|
||||
}
|
||||
|
||||
.monitor-section {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
+10
-1
@@ -936,11 +936,20 @@ export class WebServer extends EventEmitter {
|
||||
// Helper to get custom CLAUDE.md template path from settings
|
||||
private getDefaultClaudeMdPath(): string | undefined {
|
||||
const settingsPath = join(homedir(), '.claudeman', 'settings.json');
|
||||
// Default template path (can be overridden in settings)
|
||||
const defaultPath = '/home/arkon/default/CLAUDE.md';
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
return settings.defaultClaudeMdPath || undefined;
|
||||
if (settings.defaultClaudeMdPath) {
|
||||
return settings.defaultClaudeMdPath;
|
||||
}
|
||||
}
|
||||
// Use default path if it exists
|
||||
if (existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read settings:', err);
|
||||
|
||||
Reference in New Issue
Block a user