mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
1074 lines
41 KiB
JavaScript
1074 lines
41 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* capture-readme-screenshots.mjs
|
|||
|
|
*
|
|||
|
|
* Captures deterministic README screenshots using Playwright + page.route() mock injection.
|
|||
|
|
* No real Claude CLI or server needed — all API responses are mocked.
|
|||
|
|
*
|
|||
|
|
* Usage: node scripts/capture-readme-screenshots.mjs
|
|||
|
|
* Port: 3199 (static file server)
|
|||
|
|
* Output: docs/images/ and docs/screenshots/
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { chromium } from 'playwright';
|
|||
|
|
import { createServer } from 'http';
|
|||
|
|
import { readFileSync, existsSync } from 'fs';
|
|||
|
|
import { join, extname } from 'path';
|
|||
|
|
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');
|
|||
|
|
|
|||
|
|
// Non-destructive review run: which skin to force + where to write.
|
|||
|
|
// Does NOT touch docs/images or docs/screenshots. Override via env.
|
|||
|
|
const SKIN = process.env.SKIN || 'daylight-blue';
|
|||
|
|
const OUT_DIR = process.env.SCREENSHOT_OUT_DIR || join(PROJECT_ROOT, 'screenshots-readme', SKIN);
|
|||
|
|
const PORT = 3199;
|
|||
|
|
const VIEWPORT = { width: 1280, height: 720 };
|
|||
|
|
const DEVICE_SCALE_FACTOR = 1;
|
|||
|
|
|
|||
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||
|
|
|
|||
|
|
// ─── MIME Types ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const MIME_TYPES = {
|
|||
|
|
'.html': 'text/html',
|
|||
|
|
'.js': 'text/javascript',
|
|||
|
|
'.css': 'text/css',
|
|||
|
|
'.json': 'application/json',
|
|||
|
|
'.png': 'image/png',
|
|||
|
|
'.svg': 'image/svg+xml',
|
|||
|
|
'.ico': 'image/x-icon',
|
|||
|
|
'.woff2': 'font/woff2',
|
|||
|
|
'.woff': 'font/woff',
|
|||
|
|
'.ttf': 'font/ttf',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── Static File Server ──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function startStaticServer() {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const server = createServer((req, res) => {
|
|||
|
|
let urlPath = req.url.split('?')[0];
|
|||
|
|
if (urlPath === '/') urlPath = '/index.html';
|
|||
|
|
|
|||
|
|
const filePath = join(PUBLIC_DIR, urlPath);
|
|||
|
|
|
|||
|
|
if (!existsSync(filePath)) {
|
|||
|
|
res.writeHead(404);
|
|||
|
|
res.end('Not Found');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const data = readFileSync(filePath);
|
|||
|
|
const ext = extname(filePath);
|
|||
|
|
res.writeHead(200, {
|
|||
|
|
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
|||
|
|
'Cache-Control': 'no-cache',
|
|||
|
|
});
|
|||
|
|
res.end(data);
|
|||
|
|
} catch {
|
|||
|
|
res.writeHead(500);
|
|||
|
|
res.end('Internal Server Error');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
server.listen(PORT, () => {
|
|||
|
|
console.log(`Static server on http://localhost:${PORT}`);
|
|||
|
|
resolve(server);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Mock Data ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const SESSION_IDS = {
|
|||
|
|
w1: 'sess-w1-0001',
|
|||
|
|
w3: 'sess-w3-0003',
|
|||
|
|
w4: 'sess-w4-0004',
|
|||
|
|
w5: 'sess-w5-0005',
|
|||
|
|
s1: 'sess-s1-0006',
|
|||
|
|
s2: 'sess-s2-0007',
|
|||
|
|
s3: 'sess-s3-0008',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const RALPH_SESSION_ID = 'sess-ralph-demo';
|
|||
|
|
const RALPH_SHELL_ID = 'sess-ralph-shell';
|
|||
|
|
|
|||
|
|
function makeSession(id, name, mode, status, extra = {}) {
|
|||
|
|
return {
|
|||
|
|
id,
|
|||
|
|
pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000),
|
|||
|
|
status,
|
|||
|
|
workingDir: '/home/arkon/codeman-cases/testcase',
|
|||
|
|
currentTaskId: null,
|
|||
|
|
createdAt: Date.now() - 3600000,
|
|||
|
|
lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000),
|
|||
|
|
name,
|
|||
|
|
mode,
|
|||
|
|
autoClearEnabled: false,
|
|||
|
|
autoClearThreshold: 140000,
|
|||
|
|
autoCompactEnabled: false,
|
|||
|
|
autoCompactThreshold: 110000,
|
|||
|
|
autoCompactPrompt: '',
|
|||
|
|
imageWatcherEnabled: false,
|
|||
|
|
totalCost: mode === 'claude' ? 0.12 : 0,
|
|||
|
|
inputTokens: mode === 'claude' ? 18000 : 0,
|
|||
|
|
outputTokens: mode === 'claude' ? 11500 : 0,
|
|||
|
|
ralphEnabled: false,
|
|||
|
|
niceEnabled: false,
|
|||
|
|
niceValue: 10,
|
|||
|
|
color: 'default',
|
|||
|
|
flickerFilterEnabled: false,
|
|||
|
|
cliVersion: '2.1.177',
|
|||
|
|
cliModel: 'Opus 4.8',
|
|||
|
|
cliAccountType: 'Claude Max',
|
|||
|
|
cliLatestVersion: '2.1.177',
|
|||
|
|
messageCount: mode === 'claude' ? 15 : 0,
|
|||
|
|
isWorking: status === 'busy',
|
|||
|
|
lastPromptTime: Date.now() - 30000,
|
|||
|
|
bufferStats: {
|
|||
|
|
terminalBufferSize: 4096,
|
|||
|
|
textOutputSize: 2048,
|
|||
|
|
messageCount: 15,
|
|||
|
|
maxTerminalBuffer: 2097152,
|
|||
|
|
maxTextOutput: 1048576,
|
|||
|
|
maxMessages: 1000,
|
|||
|
|
},
|
|||
|
|
taskStats: { total: 0, running: 0, completed: 0, failed: 0 },
|
|||
|
|
taskTree: [],
|
|||
|
|
tokens: {
|
|||
|
|
input: mode === 'claude' ? 18000 : 0,
|
|||
|
|
output: mode === 'claude' ? 11500 : 0,
|
|||
|
|
total: mode === 'claude' ? 29500 : 0,
|
|||
|
|
},
|
|||
|
|
autoClear: { enabled: false, threshold: 140000 },
|
|||
|
|
nice: { enabled: false, niceValue: 10 },
|
|||
|
|
ralphLoop: null,
|
|||
|
|
ralphTodos: [],
|
|||
|
|
ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 },
|
|||
|
|
respawnEnabled: false,
|
|||
|
|
respawnConfig: null,
|
|||
|
|
respawn: null,
|
|||
|
|
claudeSessionId: `claude-${id}`,
|
|||
|
|
...extra,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Standard session set — kept small (4 tabs) so the header isn't crowded.
|
|||
|
|
// w1/w3/w4 are the sessions the scenes select; s1 adds a shell tab for variety.
|
|||
|
|
const STANDARD_SESSIONS = [
|
|||
|
|
makeSession(SESSION_IDS.w1, 'w1-testcase', 'claude', 'busy'),
|
|||
|
|
makeSession(SESSION_IDS.w3, 'w3-testcase', 'claude', 'busy'),
|
|||
|
|
makeSession(SESSION_IDS.w4, 'w4-testcase', 'claude', 'idle'),
|
|||
|
|
makeSession(SESSION_IDS.s1, 's1-testcase', 'shell', 'busy'),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Ralph demo sessions (2 tabs)
|
|||
|
|
const RALPH_SESSIONS = [
|
|||
|
|
makeSession(RALPH_SESSION_ID, 'ralph-8tasks-demo', 'claude', 'busy', {
|
|||
|
|
ralphEnabled: true,
|
|||
|
|
tokens: { input: 22000, output: 15300, total: 37300 },
|
|||
|
|
inputTokens: 22000,
|
|||
|
|
outputTokens: 15300,
|
|||
|
|
totalCost: 0.28,
|
|||
|
|
ralphLoop: {
|
|||
|
|
enabled: true,
|
|||
|
|
active: true,
|
|||
|
|
completionPhrase: 'ALL_TASKS_DONE',
|
|||
|
|
startedAt: Date.now() - 60000,
|
|||
|
|
cycleCount: 3,
|
|||
|
|
maxIterations: null,
|
|||
|
|
elapsedHours: null,
|
|||
|
|
},
|
|||
|
|
ralphTodos: [
|
|||
|
|
{ id: '1', text: 'Add TypeScript types to all functions', status: 'completed' },
|
|||
|
|
{ id: '2', text: 'Add input validation with proper error messages', status: 'completed' },
|
|||
|
|
{ id: '3', text: 'Add JSDoc documentation to each function', status: 'completed' },
|
|||
|
|
{ id: '4', text: 'Add unit tests for formatDate and parseJSON', status: 'in_progress' },
|
|||
|
|
{ id: '5', text: 'Add unit tests for debounce and deepClone', status: 'pending' },
|
|||
|
|
{ id: '6', text: 'Add unit tests for slugify and truncate', status: 'pending' },
|
|||
|
|
{ id: '7', text: 'Add unit tests for randomId and groupBy', status: 'pending' },
|
|||
|
|
{ id: '8', text: 'Create an index.ts that exports all utilities', status: 'pending' },
|
|||
|
|
{ id: '9', text: 'Add TypeScript types to all functions', status: 'completed' },
|
|||
|
|
],
|
|||
|
|
ralphTodoStats: { total: 9, completed: 4, percentComplete: 44 },
|
|||
|
|
}),
|
|||
|
|
makeSession(RALPH_SHELL_ID, 's1-demo-testing', 'shell', 'busy'),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Mock mux (tmux) sessions for Monitor panel
|
|||
|
|
const MUX_SESSIONS = [
|
|||
|
|
{
|
|||
|
|
sessionId: 'mux-w1',
|
|||
|
|
name: 'w1-testcase',
|
|||
|
|
muxName: 'w1-testcase',
|
|||
|
|
mode: 'claude',
|
|||
|
|
pid: 292239,
|
|||
|
|
stats: { memoryMB: 2.3, cpuPercent: 0, childCount: 1 },
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
sessionId: 'mux-w3',
|
|||
|
|
name: 'w3-testcase',
|
|||
|
|
muxName: 'w3-testcase',
|
|||
|
|
mode: 'claude',
|
|||
|
|
pid: 292394,
|
|||
|
|
stats: { memoryMB: 2.4, cpuPercent: 0.1, childCount: 1 },
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
sessionId: 'mux-w4',
|
|||
|
|
name: 'w4-testcase',
|
|||
|
|
muxName: 'w4-testcase',
|
|||
|
|
mode: 'claude',
|
|||
|
|
pid: 292497,
|
|||
|
|
stats: { memoryMB: 2.4, cpuPercent: 0, childCount: 1 },
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Mock subagents for subagent-spawn screenshot
|
|||
|
|
const MOCK_SUBAGENTS = [
|
|||
|
|
{
|
|||
|
|
agentId: 'agent-001',
|
|||
|
|
sessionId: 'claude-sess-w1-0001',
|
|||
|
|
projectHash: 'abc123',
|
|||
|
|
filePath: '/tmp/agent-001.jsonl',
|
|||
|
|
startedAt: new Date(Date.now() - 120000).toISOString(),
|
|||
|
|
lastActivityAt: Date.now() - 5000,
|
|||
|
|
status: 'active',
|
|||
|
|
toolCallCount: 12,
|
|||
|
|
entryCount: 45,
|
|||
|
|
fileSize: 32000,
|
|||
|
|
description: 'Find and document all API endpoints in src/',
|
|||
|
|
model: 'claude-haiku-4-5-20251001',
|
|||
|
|
modelShort: 'haiku',
|
|||
|
|
totalInputTokens: 15000,
|
|||
|
|
totalOutputTokens: 8000,
|
|||
|
|
parentSessionId: SESSION_IDS.w1,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
agentId: 'agent-002',
|
|||
|
|
sessionId: 'claude-sess-w1-0001',
|
|||
|
|
projectHash: 'abc123',
|
|||
|
|
filePath: '/tmp/agent-002.jsonl',
|
|||
|
|
startedAt: new Date(Date.now() - 90000).toISOString(),
|
|||
|
|
lastActivityAt: Date.now() - 3000,
|
|||
|
|
status: 'active',
|
|||
|
|
toolCallCount: 8,
|
|||
|
|
entryCount: 30,
|
|||
|
|
fileSize: 22000,
|
|||
|
|
description: 'Explore and understand test structure in test/',
|
|||
|
|
model: 'claude-haiku-4-5-20251001',
|
|||
|
|
modelShort: 'haiku',
|
|||
|
|
totalInputTokens: 12000,
|
|||
|
|
totalOutputTokens: 6000,
|
|||
|
|
parentSessionId: SESSION_IDS.w1,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
agentId: 'agent-003',
|
|||
|
|
sessionId: 'claude-sess-w1-0001',
|
|||
|
|
projectHash: 'abc123',
|
|||
|
|
filePath: '/tmp/agent-003.jsonl',
|
|||
|
|
startedAt: new Date(Date.now() - 60000).toISOString(),
|
|||
|
|
lastActivityAt: Date.now() - 8000,
|
|||
|
|
status: 'active',
|
|||
|
|
toolCallCount: 5,
|
|||
|
|
entryCount: 18,
|
|||
|
|
fileSize: 14000,
|
|||
|
|
description: 'Analyze TypeScript type definitions in src/types.ts',
|
|||
|
|
model: 'claude-haiku-4-5-20251001',
|
|||
|
|
modelShort: 'haiku',
|
|||
|
|
totalInputTokens: 8000,
|
|||
|
|
totalOutputTokens: 4000,
|
|||
|
|
parentSessionId: SESSION_IDS.w1,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function buildInitPayload(sessions, subagents = []) {
|
|||
|
|
const respawnStatus = {};
|
|||
|
|
for (const s of sessions) {
|
|||
|
|
respawnStatus[s.id] = {
|
|||
|
|
state: 'idle',
|
|||
|
|
cycleCount: 0,
|
|||
|
|
lastActivityTime: Date.now(),
|
|||
|
|
timeSinceActivity: 0,
|
|||
|
|
promptDetected: false,
|
|||
|
|
workingDetected: false,
|
|||
|
|
detection: {},
|
|||
|
|
config: null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
version: '0.1556',
|
|||
|
|
sessions,
|
|||
|
|
scheduledRuns: [],
|
|||
|
|
respawnStatus,
|
|||
|
|
globalStats: {
|
|||
|
|
totalInputTokens: 145000,
|
|||
|
|
totalOutputTokens: 87000,
|
|||
|
|
totalCost: 1.82,
|
|||
|
|
totalSessionsCreated: 12,
|
|||
|
|
firstRecordedAt: Date.now() - 86400000,
|
|||
|
|
lastUpdatedAt: Date.now(),
|
|||
|
|
},
|
|||
|
|
subagents,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Terminal Content (ANSI) ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
// Colors
|
|||
|
|
const RST = '\x1b[0m';
|
|||
|
|
const RED = '\x1b[31m';
|
|||
|
|
const GRN = '\x1b[32m';
|
|||
|
|
const YEL = '\x1b[33m';
|
|||
|
|
const BLU = '\x1b[34m';
|
|||
|
|
const MAG = '\x1b[35m';
|
|||
|
|
const CYN = '\x1b[36m';
|
|||
|
|
const GRY = '\x1b[90m';
|
|||
|
|
const WHT = '\x1b[37m';
|
|||
|
|
const BOLD = '\x1b[1m';
|
|||
|
|
const DIM = '\x1b[2m';
|
|||
|
|
|
|||
|
|
// Claude Code init banner (matching the overview screenshot)
|
|||
|
|
// Claude TUI input-box border — spans nearly the full terminal width (~158 cols
|
|||
|
|
// at 1280px) so the input box and status reach the right edge like the wrapped
|
|||
|
|
// conversation above. A short rule leaves an empty bottom-right corner.
|
|||
|
|
const BOX = '─'.repeat(157);
|
|||
|
|
|
|||
|
|
// Right-align `right` against `left` across `width` cols, ignoring ANSI SGR codes
|
|||
|
|
// for length — used to push token/version status to the right edge of the footer.
|
|||
|
|
// 157 ≈ the terminal's full col count (158 at 1280px), so the rule + status reach
|
|||
|
|
// the same right edge the wrapped conversation does (no lower-right sliver).
|
|||
|
|
const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|||
|
|
const spread = (left, right, width = 157) =>
|
|||
|
|
left + ' '.repeat(Math.max(2, width - visibleLen(left) - visibleLen(right))) + right;
|
|||
|
|
|
|||
|
|
// Claude scenes are { body, footer }: `body` scrolls from the top, `footer`
|
|||
|
|
// (the input box + status bar) is pinned to the bottom of the terminal by
|
|||
|
|
// injectState(), with blank padding filling the gap — exactly like the real
|
|||
|
|
// Claude Code TUI. This keeps every screenshot looking full-height regardless
|
|||
|
|
// of how short the scripted conversation is.
|
|||
|
|
const TERMINAL_INIT = {
|
|||
|
|
body: [
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} I'll implement the utility module end-to-end — strict TypeScript types on every export, runtime input validation with clear error messages, JSDoc with examples, and a full Vitest suite. Let me read the current file and the test setup first so I match the existing conventions.`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Read${RST}(src/utils.ts)`,
|
|||
|
|
` ${GRY}░${RST} Read ${BOLD}84${RST} lines ${GRY}·${RST} exports: formatDate, parseJSON, debounce, deepClone, slugify, truncate, randomId, groupBy`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Read${RST}(config/vitest.config.ts)`,
|
|||
|
|
` ${GRY}░${RST} Read ${BOLD}41${RST} lines ${GRY}·${RST} globals enabled, node environment, coverage via v8`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} The functions currently accept ${BOLD}any${RST} and assume well-formed input. I'll add explicit parameter and return types, guard every one against null/undefined with descriptive errors, and document the expected shapes with JSDoc examples.`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Edit${RST}(src/utils.ts)`,
|
|||
|
|
` ${GRY}░${RST} Updated with ${GRN}+42${RST} ${GRY}/${RST} ${RED}-6${RST} ${GRY}·${RST} src/utils.ts is now ${BOLD}126${RST} lines, fully typed and validated`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Write${RST}(src/index.ts)`,
|
|||
|
|
` ${GRY}░${RST} Wrote ${BOLD}11${RST} lines ${GRY}·${RST} barrel file re-exporting every utility for one clean import surface`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Write${RST}(test/utils.test.ts)`,
|
|||
|
|
` ${GRY}░${RST} Wrote ${BOLD}96${RST} lines ${GRY}·${RST} 4 describe blocks, 18 assertions covering happy paths and edge cases`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Bash${RST}(npm test -- utils.test.ts)`,
|
|||
|
|
` ${GRY}░${RST} ${GRN}✓${RST} formatDate ${GRY}(3ms)${RST} ${GRN}✓${RST} parseJSON ${GRY}(1ms)${RST} ${GRN}✓${RST} debounce ${GRY}(12ms)${RST} ${GRN}✓${RST} deepClone ${GRY}(2ms)${RST} ${GRN}✓${RST} slugify ${GRY}(1ms)${RST}`,
|
|||
|
|
` ${GRY}░${RST} ${GRN}✓${RST} truncate ${GRY}(1ms)${RST} ${GRN}✓${RST} randomId ${GRY}(1ms)${RST} ${GRN}✓${RST} groupBy ${GRY}(2ms)${RST} ${GRY}·${RST} ${BOLD}Tests${RST} ${GRN}8 passed (8)${RST} ${GRY}·${RST} ${GRY}Duration 312ms${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} All green. The utilities now have strict types across all 8 exports, runtime validation with descriptive errors, JSDoc usage examples, a barrel export in src/index.ts, and 8 passing unit tests.`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} Done — fully typed, validated, documented, re-exported, and covered by green tests. Want me to wire these into the existing modules next, or add property-based tests with fast-check?`,
|
|||
|
|
],
|
|||
|
|
footer: [
|
|||
|
|
BOX,
|
|||
|
|
`${GRY}❯${RST} now add property-based tests with fast-check for parseJSON and deepClone█`,
|
|||
|
|
BOX,
|
|||
|
|
` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 84,712 out: 1,203 ctx: 21%${RST}`,
|
|||
|
|
spread(
|
|||
|
|
` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
|||
|
|
`${BOLD}84.7k tokens${RST}`
|
|||
|
|
),
|
|||
|
|
spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Claude working output (for multi-session-dashboard / subagent-spawn)
|
|||
|
|
const TERMINAL_WORKING = {
|
|||
|
|
body: [
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} The codebase is essentially a template/scaffold with just a CLAUDE.md file and empty ${BOLD}src/${RST} directory. Since there's already a comprehensive`,
|
|||
|
|
`CLAUDE.md file present, let me read it fully and suggest improvements based on the /init command requirements:`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
|||
|
|
` ${GRY}░${RST} Read ${BOLD}407${RST} lines`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -maxdepth 2 -type f -not -path "*/node_modules/*" | head)`,
|
|||
|
|
` ${GRY}░${RST} ./CLAUDE.md ${GRY}·${RST} ./package.json ${GRY}·${RST} ./tsconfig.json ${GRY}·${RST} ${GRY}(src/ is empty)${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} Now I understand the situation. This is a ${BOLD}template CLAUDE.md file${RST} designed for the Claude Code framework with the "Ralph Loop" autonomous work`,
|
|||
|
|
`mode. The current file is very comprehensive for workflow instructions but lacks the key technical information that /init is supposed to generate:`,
|
|||
|
|
'',
|
|||
|
|
`1. ${BOLD}No build/test/lint commands${RST} - The file references npm test, npm run lint, npm run build but these are generic examples, not project-specific`,
|
|||
|
|
`2. ${BOLD}No architecture documentation${RST} - The session log mentions files that don't exist in the empty repo`,
|
|||
|
|
`3. ${BOLD}Placeholder tech stack${RST} - Still shows [TECHNOLOGIES_USED]`,
|
|||
|
|
`4. ${BOLD}Too much workflow/process documentation${RST} - Much of this duplicates Claude Code's built-in behavior`,
|
|||
|
|
'',
|
|||
|
|
`The /init command is asking me to create a CLAUDE.md focused on ${BOLD}codebase-specific guidance${RST} (commands, architecture), not workflow processes. Since`,
|
|||
|
|
`this is essentially an empty project, I should create a minimal CLAUDE.md that:`,
|
|||
|
|
`1. Has the required header`,
|
|||
|
|
`2. Notes that the project needs initialization`,
|
|||
|
|
`3. Removes the verbose workflow documentation that's either built-in to Claude Code or should be in separate docs`,
|
|||
|
|
'',
|
|||
|
|
`Let me create an improved, streamlined CLAUDE.md appropriate for this empty project state.`,
|
|||
|
|
'',
|
|||
|
|
`${MAG}✻${RST} ${YEL}Caramelizing...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 54s · ↓ 2.1k tokens)${RST}`,
|
|||
|
|
],
|
|||
|
|
footer: [
|
|||
|
|
BOX,
|
|||
|
|
`${GRY}❯${RST} █`,
|
|||
|
|
BOX,
|
|||
|
|
` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 28,163 out: 2,104 ctx: 14%${RST}`,
|
|||
|
|
spread(
|
|||
|
|
` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
|||
|
|
`${BOLD}28.1k tokens${RST}`
|
|||
|
|
),
|
|||
|
|
spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Ralph terminal content (matching ralph-tracker screenshot)
|
|||
|
|
const TERMINAL_RALPH = {
|
|||
|
|
body: [
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/*.test.ts")`,
|
|||
|
|
` ${GRY}░${RST} Found ${BOLD}0${RST} files`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/test/**")`,
|
|||
|
|
` ${GRY}░${RST} Found ${BOLD}0${RST} files`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/package.json")`,
|
|||
|
|
` ${GRY}░${RST} Found ${BOLD}0${RST} files`,
|
|||
|
|
'',
|
|||
|
|
`${MAG}✻${RST} ${YEL}Adding unit tests for formatDate and parseJSON...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · ${BOLD}ctrl+t${RST}${GRY} to hide todos · 1m 27s · ↓ 5.8k tokens · thinking)${RST}`,
|
|||
|
|
` ${GRY}░${RST} ${GRY}☒${RST} Add TypeScript types to all functions`,
|
|||
|
|
` ${GRY}☒${RST} Add input validation with proper error messages`,
|
|||
|
|
` ${GRY}☒${RST} Add JSDoc documentation to each function`,
|
|||
|
|
` ${GRY}░${RST} ${BOLD}Add unit tests for formatDate and parseJSON${RST}`,
|
|||
|
|
` ${GRY}░${RST} ☐ Add unit tests for debounce and deepClone`,
|
|||
|
|
` ${GRY}░${RST} ☐ Add unit tests for slugify and truncate`,
|
|||
|
|
` ${GRY}░${RST} ☐ Add unit tests for randomId and groupBy`,
|
|||
|
|
` ${GRY}░${RST} ☐ Create an index.ts that exports all utilities`,
|
|||
|
|
],
|
|||
|
|
footer: [
|
|||
|
|
BOX,
|
|||
|
|
`${GRY}❯${RST} █`,
|
|||
|
|
'',
|
|||
|
|
BOX,
|
|||
|
|
` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
|||
|
|
` ${BOLD}37267 tokens${RST}`,
|
|||
|
|
` ${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`,
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Subagent window content (tool call activity)
|
|||
|
|
const SUBAGENT_ACTIVITY = {
|
|||
|
|
'agent-001': [
|
|||
|
|
{ type: 'tool', tool: 'Glob', input: { pattern: 'src/**/*.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' },
|
|||
|
|
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/server.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' },
|
|||
|
|
{ type: 'tool', tool: 'Grep', input: { pattern: 'app\\.get|app\\.post|app\\.put|app\\.delete', path: 'src/' }, timestamp: new Date().toISOString(), agentId: 'agent-001' },
|
|||
|
|
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/schemas.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' },
|
|||
|
|
{ type: 'message', role: 'assistant', text: 'Found 47 API endpoints across server.ts. Documenting REST paths...', timestamp: new Date().toISOString(), agentId: 'agent-001' },
|
|||
|
|
],
|
|||
|
|
'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/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' },
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Subagent spawn terminal content
|
|||
|
|
const TERMINAL_SUBAGENT = {
|
|||
|
|
body: [
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
|||
|
|
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
|||
|
|
` ${GRY}░${RST} ./src/index.ts`,
|
|||
|
|
` ${GRY}░${RST} ./src/types.ts`,
|
|||
|
|
` ${GRY}░${RST} ./src/session.ts`,
|
|||
|
|
` ${GRY}░${RST} ./src/web/server.ts`,
|
|||
|
|
` ${GRY}░${RST} ./src/web/schemas.ts`,
|
|||
|
|
` ${GRY}░${RST} ./test/session.test.ts`,
|
|||
|
|
` ${GRY}░${RST} ${GRY}... (14 more)${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
|||
|
|
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
|||
|
|
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
|||
|
|
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
|||
|
|
'',
|
|||
|
|
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
|||
|
|
'',
|
|||
|
|
` ${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls${RST} — Glob, Read(server.ts), Grep(endpoints)...`,
|
|||
|
|
` ${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls${RST} — Glob, Read(test-utils), Read(vitest.config)...`,
|
|||
|
|
` ${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}5 tool calls${RST} — Read(types.ts), Grep(interface)...`,
|
|||
|
|
'',
|
|||
|
|
`${GRN}●${RST} ${DIM}171.8k, 13s │ 1.7k tokens │ thinking${RST}`,
|
|||
|
|
],
|
|||
|
|
footer: [
|
|||
|
|
spread(
|
|||
|
|
` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
|||
|
|
`${BOLD}171.8k tokens${RST}`
|
|||
|
|
),
|
|||
|
|
spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Flatten a { body, footer } scene into the raw buffer the mocked /terminal
|
|||
|
|
// fetch returns. injectState() re-renders it bottom-anchored once the terminal
|
|||
|
|
// exists and its real row count is known; this naive join is only the fallback
|
|||
|
|
// the fetch path needs before that happens.
|
|||
|
|
function joinScene(scene) {
|
|||
|
|
return [...scene.body, '', '', ...scene.footer].join('\r\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Route Interceptors ──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function setupRoutes(page, initPayload, terminalContent) {
|
|||
|
|
const terminalBuffer = joinScene(terminalContent);
|
|||
|
|
// CRITICAL: Block SSE entirely to prevent reconnection loops that clear state.
|
|||
|
|
// We'll inject data directly via page.evaluate() instead.
|
|||
|
|
await page.route('**/api/events', async (route) => {
|
|||
|
|
await route.abort();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Terminal buffer endpoint — used by selectSession()
|
|||
|
|
await page.route('**/api/sessions/*/terminal**', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
terminalBuffer,
|
|||
|
|
status: 'busy',
|
|||
|
|
fullSize: terminalBuffer.length,
|
|||
|
|
truncated: false,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Mux sessions (subpath routes before base route)
|
|||
|
|
await page.route('**/api/mux-sessions/**', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ success: true }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.route('**/api/mux-sessions', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ sessions: MUX_SESSIONS, muxAvailable: true }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Settings
|
|||
|
|
await page.route('**/api/settings', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
showSubagents: true,
|
|||
|
|
subagentTrackingEnabled: true,
|
|||
|
|
subagentActiveTabOnly: false,
|
|||
|
|
showMonitor: true,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Subagent window states (restore)
|
|||
|
|
await page.route('**/api/subagent-window-states', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Subagent parents (restore)
|
|||
|
|
await page.route('**/api/subagent-parents', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Session-specific subagents
|
|||
|
|
await page.route('**/api/sessions/*/subagents', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ success: true, data: initPayload.subagents || [] }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Interactive attach (no-op)
|
|||
|
|
await page.route('**/api/sessions/*/interactive', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ success: true }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Resize (no-op)
|
|||
|
|
await page.route('**/api/sessions/*/resize', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ success: true }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Catch-all for any remaining API endpoints
|
|||
|
|
await page.route('**/api/**', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ success: true }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Inject mock state into the app and render.
|
|||
|
|
* Bypasses SSE entirely — calls handleInit directly, then selects a session
|
|||
|
|
* and writes terminal content.
|
|||
|
|
*/
|
|||
|
|
async function injectState(page, initPayload, terminalContent, activeSessionId) {
|
|||
|
|
// Wait for app to be ready
|
|||
|
|
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
|
|||
|
|
await sleep(1000);
|
|||
|
|
|
|||
|
|
// Cancel the SSE fallback timer and inject state directly
|
|||
|
|
await page.evaluate((payload) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
// Cancel the init fallback timer (prevents double handleInit)
|
|||
|
|
if (app._initFallbackTimer) {
|
|||
|
|
clearTimeout(app._initFallbackTimer);
|
|||
|
|
app._initFallbackTimer = null;
|
|||
|
|
}
|
|||
|
|
// Inject state
|
|||
|
|
app.handleInit(payload);
|
|||
|
|
}, initPayload);
|
|||
|
|
|
|||
|
|
await sleep(1500);
|
|||
|
|
|
|||
|
|
// Select the target session (this triggers terminal fetch via our mocked route)
|
|||
|
|
if (activeSessionId) {
|
|||
|
|
await page.evaluate((sid) => {
|
|||
|
|
window.app.activeSessionId = null; // Force re-select
|
|||
|
|
window.app.selectSession(sid);
|
|||
|
|
}, activeSessionId);
|
|||
|
|
await sleep(2000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await sleep(1000);
|
|||
|
|
|
|||
|
|
// Clean up UI elements that look wrong in screenshots
|
|||
|
|
await page.evaluate(() => {
|
|||
|
|
// Kill SSE reconnection entirely and hide the connection indicator
|
|||
|
|
const app2 = window.app;
|
|||
|
|
if (app2) {
|
|||
|
|
// Stop reconnection timers
|
|||
|
|
if (app2.sseReconnectTimeout) clearTimeout(app2.sseReconnectTimeout);
|
|||
|
|
if (app2.eventSource) { app2.eventSource.close(); app2.eventSource = null; }
|
|||
|
|
app2._connectionStatus = 'connected';
|
|||
|
|
// Monkey-patch so it never re-shows
|
|||
|
|
app2._updateConnectionIndicator = () => {};
|
|||
|
|
app2.connectSSE = () => {};
|
|||
|
|
app2.setConnectionStatus = () => {};
|
|||
|
|
}
|
|||
|
|
// Remove the indicator element from the DOM entirely
|
|||
|
|
const indicator = document.getElementById('connectionIndicator');
|
|||
|
|
if (indicator) indicator.remove();
|
|||
|
|
|
|||
|
|
// Hide respawn banner (shows "idle" state which isn't needed in screenshots)
|
|||
|
|
const respawnBanner = document.getElementById('respawnBanner');
|
|||
|
|
if (respawnBanner) respawnBanner.style.display = 'none';
|
|||
|
|
|
|||
|
|
// Fix system stats display (CPU/MEM) — inject mock values
|
|||
|
|
const statsEl = document.getElementById('headerSystemStats');
|
|||
|
|
if (statsEl) {
|
|||
|
|
statsEl.innerHTML = `
|
|||
|
|
<span class="stat-label">CPU</span>
|
|||
|
|
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 24%"></div></div>
|
|||
|
|
<span class="stat-value">24%</span>
|
|||
|
|
<span class="stat-label">MEM</span>
|
|||
|
|
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 51%"></div></div>
|
|||
|
|
<span class="stat-value">16.9G</span>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Hide subagents panel by default (will be shown per-scenario)
|
|||
|
|
const subagentsPanel = document.getElementById('subagentsPanel');
|
|||
|
|
if (subagentsPanel) subagentsPanel.style.display = 'none';
|
|||
|
|
|
|||
|
|
// Ensure monitor panel is closed by default (will be opened per-scenario)
|
|||
|
|
const monitorPanel = document.getElementById('monitorPanel');
|
|||
|
|
if (monitorPanel) monitorPanel.classList.remove('open');
|
|||
|
|
|
|||
|
|
// Slim the header for a cleaner screenshot: keep ONLY CPU/MEM stats and the
|
|||
|
|
// settings gear. Hide the token count and the other header affordances
|
|||
|
|
// (font A-/A+ controls, lifecycle-log doc icon). The response-viewer /
|
|||
|
|
// multi-monitor / notification buttons are already hidden by default.
|
|||
|
|
const headerHide = [
|
|||
|
|
'#headerTokens',
|
|||
|
|
'.header-font-controls',
|
|||
|
|
'.btn-lifecycle-log',
|
|||
|
|
'.btn-response-viewer-header',
|
|||
|
|
'.btn-multimonitor',
|
|||
|
|
'.btn-notifications',
|
|||
|
|
];
|
|||
|
|
for (const sel of headerHide) {
|
|||
|
|
document.querySelectorAll(sel).forEach((el) => {
|
|||
|
|
el.style.setProperty('display', 'none', 'important');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Make the terminal / Claude pane dominate the window: slim the header and
|
|||
|
|
// toolbar (default 36px + 42px ≈ 13% chrome) down to ~7%, so the terminal
|
|||
|
|
// fills ~93% of the height. injectState re-fits the terminal to the enlarged
|
|||
|
|
// container right after this, so the extra space becomes real terminal rows.
|
|||
|
|
const slim = document.createElement('style');
|
|||
|
|
slim.id = 'codeman-screenshot-slim';
|
|||
|
|
slim.textContent = `
|
|||
|
|
:root { --header-height: 22px !important; --toolbar-height: 28px !important; }
|
|||
|
|
.header { min-height: 22px !important; padding: 1px 10px !important; align-items: center !important; }
|
|||
|
|
.session-tab { padding-top: 1px !important; padding-bottom: 1px !important; }
|
|||
|
|
.toolbar { height: 28px !important; min-height: 28px !important; padding: 0 10px !important; }
|
|||
|
|
.btn-toolbar { padding: 2px 8px !important; }
|
|||
|
|
.toolbar-select, .toolbar-input { padding-top: 2px !important; padding-bottom: 2px !important; }
|
|||
|
|
`;
|
|||
|
|
document.head.appendChild(slim);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Let the layout reflow to its FINAL height before sizing the terminal. The
|
|||
|
|
// cleanup above hides the respawn banner (~48px), so the terminal container
|
|||
|
|
// only grows to its full height now — fitting earlier under-counts rows and
|
|||
|
|
// leaves a dead strip below the last row.
|
|||
|
|
await sleep(700);
|
|||
|
|
|
|||
|
|
// Render the scene bottom-anchored at the terminal's true full size: re-fit to
|
|||
|
|
// the settled container, size rows to fill it exactly (fit() under-counts on
|
|||
|
|
// initial load — the WebGL cell metric lags — which is the "switch tabs/reload
|
|||
|
|
// to fix it" symptom), then neutralize later re-fits. `body` scrolls from the
|
|||
|
|
// top; `footer` (input box + status bar) is pinned to the bottom with blank
|
|||
|
|
// padding between — exactly like the real Claude Code TUI. Runs unconditionally
|
|||
|
|
// so it overrides whatever the mocked /terminal fetch wrote.
|
|||
|
|
await page.evaluate((scene) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
const term = app.terminal;
|
|||
|
|
if (!term) return;
|
|||
|
|
try {
|
|||
|
|
app.fitAddon?.fit();
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const cont = document.getElementById('terminalContainer');
|
|||
|
|
const cellH = term._core?._renderService?.dimensions?.css?.cell?.height || 21;
|
|||
|
|
if (cont && cellH) {
|
|||
|
|
const fitRows = Math.max(1, Math.floor(cont.clientHeight / cellH));
|
|||
|
|
if (fitRows !== term.rows) term.resize(term.cols, fitRows);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
if (app.fitAddon) app.fitAddon.fit = () => {};
|
|||
|
|
const rows = term.rows || 27;
|
|||
|
|
const cols = term.cols || 158;
|
|||
|
|
const body = scene.body || [];
|
|||
|
|
const footer = scene.footer || [];
|
|||
|
|
// Count how many TERMINAL ROWS a line array actually occupies, accounting for
|
|||
|
|
// line wrapping — a long paragraph wraps to 2+ rows. Using the raw array
|
|||
|
|
// length under-counts wrapped lines and leaves a dead band above the footer
|
|||
|
|
// (and term.write() is async so measuring the live cursor afterward is
|
|||
|
|
// unreliable). Strip ANSI SGR codes first so only VISIBLE width counts.
|
|||
|
|
const rowsFor = (arr) =>
|
|||
|
|
arr.reduce((sum, line) => {
|
|||
|
|
const visible = line.replace(/\x1b\[[0-9;]*m/g, '');
|
|||
|
|
return sum + Math.max(1, Math.ceil(visible.length / cols));
|
|||
|
|
}, 0);
|
|||
|
|
const pad = Math.max(0, rows - rowsFor(body) - rowsFor(footer));
|
|||
|
|
const lines = [...body, ...Array(pad).fill(''), ...footer];
|
|||
|
|
term.clear();
|
|||
|
|
term.reset();
|
|||
|
|
term.write(lines.join('\r\n'));
|
|||
|
|
term.scrollToBottom();
|
|||
|
|
}, terminalContent);
|
|||
|
|
|
|||
|
|
// Final settle — let xterm.js WebGL renderer, fonts, and layout stabilize
|
|||
|
|
await sleep(1500);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Screenshot Scenarios ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function captureOverview(page) {
|
|||
|
|
console.log('\n1/4 Capturing claude-overview.png...');
|
|||
|
|
|
|||
|
|
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
|||
|
|
await setupRoutes(page, initPayload, TERMINAL_INIT);
|
|||
|
|
|
|||
|
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|||
|
|
await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4);
|
|||
|
|
|
|||
|
|
// Open Monitor panel and populate mux sessions
|
|||
|
|
await page.evaluate((muxSessions) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
if (!app) return;
|
|||
|
|
app.muxSessions = muxSessions;
|
|||
|
|
const panel = document.getElementById('monitorPanel');
|
|||
|
|
if (panel) {
|
|||
|
|
panel.classList.add('open');
|
|||
|
|
app._renderMuxSessionsImmediate();
|
|||
|
|
}
|
|||
|
|
}, MUX_SESSIONS);
|
|||
|
|
await sleep(1500);
|
|||
|
|
|
|||
|
|
await page.screenshot({
|
|||
|
|
path: join(OUT_DIR, 'claude-overview.png'),
|
|||
|
|
fullPage: false,
|
|||
|
|
});
|
|||
|
|
console.log(' Saved: ' + join(OUT_DIR, 'claude-overview.png'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function captureSubagentSpawn(page) {
|
|||
|
|
console.log('\n2/4 Capturing subagent-spawn.png...');
|
|||
|
|
|
|||
|
|
const initPayload = buildInitPayload(STANDARD_SESSIONS, MOCK_SUBAGENTS);
|
|||
|
|
await setupRoutes(page, initPayload, TERMINAL_SUBAGENT);
|
|||
|
|
|
|||
|
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|||
|
|
await injectState(page, initPayload, TERMINAL_SUBAGENT, SESSION_IDS.w1);
|
|||
|
|
|
|||
|
|
// Open subagent windows and populate activity
|
|||
|
|
await page.evaluate((activity) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
if (!app) return;
|
|||
|
|
|
|||
|
|
// Populate subagent activity data
|
|||
|
|
for (const [agentId, entries] of Object.entries(activity)) {
|
|||
|
|
app.subagentActivity.set(agentId, entries);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Open subagent windows (first two for cleaner screenshot)
|
|||
|
|
app.openSubagentWindow('agent-001');
|
|||
|
|
app.openSubagentWindow('agent-002');
|
|||
|
|
}, SUBAGENT_ACTIVITY);
|
|||
|
|
await sleep(2000);
|
|||
|
|
|
|||
|
|
// Position windows nicely for 1280x720 viewport
|
|||
|
|
await page.evaluate(() => {
|
|||
|
|
const app = window.app;
|
|||
|
|
if (!app) return;
|
|||
|
|
|
|||
|
|
const windows = Array.from(app.subagentWindows.values());
|
|||
|
|
if (windows.length >= 2) {
|
|||
|
|
const w1 = windows[0].element;
|
|||
|
|
w1.style.left = '420px';
|
|||
|
|
w1.style.top = '40px';
|
|||
|
|
w1.style.width = '420px';
|
|||
|
|
w1.style.height = '320px';
|
|||
|
|
|
|||
|
|
const w2 = windows[1].element;
|
|||
|
|
w2.style.left = '850px';
|
|||
|
|
w2.style.top = '40px';
|
|||
|
|
w2.style.width = '420px';
|
|||
|
|
w2.style.height = '320px';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
await sleep(1500);
|
|||
|
|
|
|||
|
|
await page.screenshot({
|
|||
|
|
path: join(OUT_DIR, 'subagent-spawn.png'),
|
|||
|
|
fullPage: false,
|
|||
|
|
});
|
|||
|
|
console.log(' Saved: ' + join(OUT_DIR, 'subagent-spawn.png'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function captureRalphTracker(page) {
|
|||
|
|
console.log('\n3/5 Capturing ralph-tracker-8tasks-44percent.png...');
|
|||
|
|
|
|||
|
|
const initPayload = buildInitPayload(RALPH_SESSIONS);
|
|||
|
|
await setupRoutes(page, initPayload, TERMINAL_RALPH);
|
|||
|
|
|
|||
|
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|||
|
|
await injectState(page, initPayload, TERMINAL_RALPH, RALPH_SESSION_ID);
|
|||
|
|
|
|||
|
|
// Ensure Ralph state panel is visible and expanded
|
|||
|
|
await page.evaluate((sessionId) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
if (!app) return;
|
|||
|
|
|
|||
|
|
const session = app.sessions.get(sessionId);
|
|||
|
|
if (session) {
|
|||
|
|
app.ralphStates.set(sessionId, {
|
|||
|
|
loop: session.ralphLoop,
|
|||
|
|
todos: session.ralphTodos || [],
|
|||
|
|
});
|
|||
|
|
app.ralphStatePanelCollapsed = false;
|
|||
|
|
app.ralphClosedSessions.delete(sessionId);
|
|||
|
|
app._renderRalphStatePanelImmediate();
|
|||
|
|
}
|
|||
|
|
}, RALPH_SESSION_ID);
|
|||
|
|
await sleep(1500);
|
|||
|
|
|
|||
|
|
await page.screenshot({
|
|||
|
|
path: join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png'),
|
|||
|
|
fullPage: false,
|
|||
|
|
});
|
|||
|
|
console.log(' Saved: ' + join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function captureMultiSessionDashboard(page) {
|
|||
|
|
console.log('\n3/4 Capturing multi-session-dashboard.png...');
|
|||
|
|
|
|||
|
|
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
|||
|
|
await setupRoutes(page, initPayload, TERMINAL_WORKING);
|
|||
|
|
|
|||
|
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|||
|
|
await injectState(page, initPayload, TERMINAL_WORKING, SESSION_IDS.w3);
|
|||
|
|
|
|||
|
|
await page.screenshot({
|
|||
|
|
path: join(OUT_DIR, 'multi-session-dashboard.png'),
|
|||
|
|
fullPage: false,
|
|||
|
|
});
|
|||
|
|
console.log(' Saved: ' + join(OUT_DIR, 'multi-session-dashboard.png'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function captureMultiSessionMonitor(page) {
|
|||
|
|
console.log('\n4/4 Capturing multi-session-monitor.png...');
|
|||
|
|
|
|||
|
|
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
|||
|
|
await setupRoutes(page, initPayload, TERMINAL_INIT);
|
|||
|
|
|
|||
|
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|||
|
|
await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4);
|
|||
|
|
|
|||
|
|
// Open Monitor panel and populate mux sessions
|
|||
|
|
await page.evaluate((muxSessions) => {
|
|||
|
|
const app = window.app;
|
|||
|
|
if (!app) return;
|
|||
|
|
app.muxSessions = muxSessions;
|
|||
|
|
const panel = document.getElementById('monitorPanel');
|
|||
|
|
if (panel) {
|
|||
|
|
panel.classList.add('open');
|
|||
|
|
app._renderMuxSessionsImmediate();
|
|||
|
|
}
|
|||
|
|
}, MUX_SESSIONS);
|
|||
|
|
await sleep(1500);
|
|||
|
|
|
|||
|
|
await page.screenshot({
|
|||
|
|
path: join(OUT_DIR, 'multi-session-monitor.png'),
|
|||
|
|
fullPage: false,
|
|||
|
|
});
|
|||
|
|
console.log(' Saved: ' + join(OUT_DIR, 'multi-session-monitor.png'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
console.log('='.repeat(60));
|
|||
|
|
console.log(`Codeman README Screenshot Capture — skin: ${SKIN}`);
|
|||
|
|
console.log('='.repeat(60));
|
|||
|
|
console.log(`Port: ${PORT} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`);
|
|||
|
|
console.log(`Output: ${OUT_DIR}`);
|
|||
|
|
console.log('');
|
|||
|
|
|
|||
|
|
const server = await startStaticServer();
|
|||
|
|
let browser;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
browser = await chromium.launch({
|
|||
|
|
headless: true,
|
|||
|
|
args: [
|
|||
|
|
'--no-sandbox',
|
|||
|
|
'--disable-setuid-sandbox',
|
|||
|
|
'--disable-dev-shm-usage',
|
|||
|
|
'--disable-gpu',
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Each scenario gets its own fresh page to avoid route conflicts.
|
|||
|
|
// (Ralph tracker intentionally omitted — de-prioritized feature.)
|
|||
|
|
for (const scenario of [
|
|||
|
|
captureOverview,
|
|||
|
|
captureSubagentSpawn,
|
|||
|
|
captureMultiSessionDashboard,
|
|||
|
|
captureMultiSessionMonitor,
|
|||
|
|
]) {
|
|||
|
|
const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: DEVICE_SCALE_FACTOR });
|
|||
|
|
const page = await context.newPage();
|
|||
|
|
page.setDefaultTimeout(30000);
|
|||
|
|
|
|||
|
|
// Force the chosen theme skin before any page script runs (matches the
|
|||
|
|
// pre-paint <head> contract in index.html: localStorage 'codeman:skin').
|
|||
|
|
await page.addInitScript((skin) => {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem('codeman:skin', skin);
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
}, SKIN);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await scenario(page);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(` ERROR: ${err.message}`);
|
|||
|
|
} finally {
|
|||
|
|
await context.close();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('\n' + '='.repeat(60));
|
|||
|
|
console.log('All screenshots captured!');
|
|||
|
|
console.log('='.repeat(60));
|
|||
|
|
console.log(`\nOutput files (in ${OUT_DIR}):`);
|
|||
|
|
console.log(' claude-overview.png');
|
|||
|
|
console.log(' subagent-spawn.png');
|
|||
|
|
console.log(' multi-session-dashboard.png');
|
|||
|
|
console.log(' multi-session-monitor.png');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('\nFatal error:', err.message);
|
|||
|
|
console.error(err.stack);
|
|||
|
|
process.exitCode = 1;
|
|||
|
|
} finally {
|
|||
|
|
if (browser) await browser.close().catch(() => {});
|
|||
|
|
server.close();
|
|||
|
|
console.log('\nDone.');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle interrupts
|
|||
|
|
process.on('SIGINT', () => {
|
|||
|
|
console.log('\nInterrupted.');
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
main();
|